Merge branch 'main' into feat/hitl-frontend

This commit is contained in:
twwu
2026-01-07 10:24:51 +08:00
147 changed files with 5186 additions and 1503 deletions

View File

@ -1,11 +1,8 @@
/* eslint-disable dify-i18n/require-ns-option */
import * as React from 'react'
import { useTranslation } from '#i18n'
import Form from '@/app/components/datasets/settings/form'
import { getLocaleOnServer, getTranslation } from '@/i18n-config/server'
const Settings = async () => {
const locale = await getLocaleOnServer()
const { t } = await getTranslation(locale, 'dataset-settings')
const Settings = () => {
const { t } = useTranslation('datasetSettings')
return (
<div className="h-full overflow-y-auto">

View File

@ -0,0 +1,41 @@
'use client'
import * as React from 'react'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
type AppCardSkeletonProps = {
count?: number
}
/**
* Skeleton placeholder for App cards during loading states.
* Matches the visual layout of AppCard component.
*/
export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps) => {
return (
<>
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="h-[160px] rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4"
>
<SkeletonContainer className="h-full">
<SkeletonRow>
<SkeletonRectangle className="h-10 w-10 rounded-lg" />
<div className="flex flex-1 flex-col gap-1">
<SkeletonRectangle className="h-4 w-2/3" />
<SkeletonRectangle className="h-3 w-1/3" />
</div>
</SkeletonRow>
<div className="mt-4 flex flex-col gap-2">
<SkeletonRectangle className="h-3 w-full" />
<SkeletonRectangle className="h-3 w-4/5" />
</div>
</SkeletonContainer>
</div>
))}
</>
)
})
AppCardSkeleton.displayName = 'AppCardSkeleton'

View File

@ -27,7 +27,9 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
@ -45,7 +47,7 @@ const List = () => {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [activeTab, setActiveTab] = useQueryState(
'category',
@ -89,6 +91,7 @@ const List = () => {
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
@ -172,6 +175,8 @@ const List = () => {
const pages = data?.pages ?? []
const hasAnyApp = (pages[0]?.total ?? 0) > 0
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = isLoading || (isFetching && pages.length === 0)
return (
<>
@ -205,23 +210,34 @@ const List = () => {
/>
</div>
</div>
{hasAnyApp
? (
<div className="relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6">
{isCurrentWorkspaceEditor
&& <NewAppCard ref={newAppCardRef} onSuccess={refetch} selectedAppType={activeTab} />}
{pages.map(({ data: apps }) => apps.map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
)))}
</div>
)
: (
<div className="relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6">
{isCurrentWorkspaceEditor
&& <NewAppCard ref={newAppCardRef} className="z-10" onSuccess={refetch} selectedAppType={activeTab} />}
<Empty />
</div>
)}
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
!hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
)}
{(() => {
if (showSkeleton)
return <AppCardSkeleton count={6} />
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))
}
// No apps - show empty state
return <Empty />
})()}
</div>
{isCurrentWorkspaceEditor && (
<div

View File

@ -25,6 +25,7 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
export type CreateAppCardProps = {
className?: string
isLoading?: boolean
onSuccess?: () => void
ref: React.RefObject<HTMLDivElement | null>
selectedAppType?: string
@ -33,6 +34,7 @@ export type CreateAppCardProps = {
const CreateAppCard = ({
ref,
className,
isLoading = false,
onSuccess,
selectedAppType,
}: CreateAppCardProps) => {
@ -56,7 +58,11 @@ const CreateAppCard = ({
return (
<div
ref={ref}
className={cn('relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg', className)}
className={cn(
'relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity',
isLoading && 'pointer-events-none opacity-50',
className,
)}
>
<div className="grow rounded-t-xl p-2">
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('createApp', { ns: 'app' })}</div>

View File

@ -4,7 +4,7 @@ import type {
GetValuesOptions,
} from '../types'
import { useCallback } from 'react'
import { getTransformedValuesWhenSecretInputPristine } from '../utils'
import { getTransformedValuesWhenSecretInputPristine } from '../utils/secret-input'
import { useCheckValidated } from './use-check-validated'
export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => {

View File

@ -1 +0,0 @@
export * from './secret-input'

View File

@ -0,0 +1,22 @@
import type { ZodSchema } from 'zod'
type SubmitValidator<T> = ({ value }: { value: T }) => { fields: Record<string, string> } | undefined
export const zodSubmitValidator = <T>(schema: ZodSchema<T>): SubmitValidator<T> => {
return ({ value }) => {
const result = schema.safeParse(value)
if (!result.success) {
const fieldErrors: Record<string, string> = {}
for (const issue of result.error.issues) {
const path = issue.path[0]
if (path === undefined)
continue
const key = String(path)
if (!fieldErrors[key])
fieldErrors[key] = issue.message
}
return { fields: fieldErrors }
}
return undefined
}
}

View File

@ -0,0 +1,97 @@
'use client'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import { DataSourceType } from '@/models/datasets'
import { cn } from '@/utils/classnames'
import s from '../index.module.css'
type DataSourceTypeSelectorProps = {
currentType: DataSourceType
disabled: boolean
onChange: (type: DataSourceType) => void
onClearPreviews: (type: DataSourceType) => void
}
type DataSourceLabelKey
= | 'stepOne.dataSourceType.file'
| 'stepOne.dataSourceType.notion'
| 'stepOne.dataSourceType.web'
type DataSourceOption = {
type: DataSourceType
iconClass?: string
labelKey: DataSourceLabelKey
}
const DATA_SOURCE_OPTIONS: DataSourceOption[] = [
{
type: DataSourceType.FILE,
labelKey: 'stepOne.dataSourceType.file',
},
{
type: DataSourceType.NOTION,
iconClass: s.notion,
labelKey: 'stepOne.dataSourceType.notion',
},
{
type: DataSourceType.WEB,
iconClass: s.web,
labelKey: 'stepOne.dataSourceType.web',
},
]
/**
* Data source type selector component for choosing between file, notion, and web sources.
*/
function DataSourceTypeSelector({
currentType,
disabled,
onChange,
onClearPreviews,
}: DataSourceTypeSelectorProps) {
const { t } = useTranslation()
const isWebEnabled = ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL
const handleTypeChange = useCallback((type: DataSourceType) => {
if (disabled)
return
onChange(type)
onClearPreviews(type)
}, [disabled, onChange, onClearPreviews])
const visibleOptions = useMemo(() => DATA_SOURCE_OPTIONS.filter((option) => {
if (option.type === DataSourceType.WEB)
return isWebEnabled
return true
}), [isWebEnabled])
return (
<div className="mb-8 grid grid-cols-3 gap-4">
{visibleOptions.map(option => (
<div
key={option.type}
className={cn(
s.dataSourceItem,
'system-sm-medium',
currentType === option.type && s.active,
disabled && currentType !== option.type && s.disabled,
)}
onClick={() => handleTypeChange(option.type)}
>
<span className={cn(s.datasetIcon, option.iconClass)} />
<span
title={t(option.labelKey, { ns: 'datasetCreation' }) || undefined}
className="truncate"
>
{t(option.labelKey, { ns: 'datasetCreation' })}
</span>
</div>
))}
</div>
)
}
export default DataSourceTypeSelector

View File

@ -0,0 +1,3 @@
export { default as DataSourceTypeSelector } from './data-source-type-selector'
export { default as NextStepButton } from './next-step-button'
export { default as PreviewPanel } from './preview-panel'

View File

@ -0,0 +1,30 @@
'use client'
import { RiArrowRightLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
type NextStepButtonProps = {
disabled: boolean
onClick: () => void
}
/**
* Reusable next step button component for dataset creation flow.
*/
function NextStepButton({ disabled, onClick }: NextStepButtonProps) {
const { t } = useTranslation()
return (
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={disabled} variant="primary" onClick={onClick}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('stepOne.button', { ns: 'datasetCreation' })}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
)
}
export default NextStepButton

View File

@ -0,0 +1,62 @@
'use client'
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { useTranslation } from 'react-i18next'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import FilePreview from '../../file-preview'
import NotionPagePreview from '../../notion-page-preview'
import WebsitePreview from '../../website/preview'
type PreviewPanelProps = {
currentFile: File | undefined
currentNotionPage: NotionPage | undefined
currentWebsite: CrawlResultItem | undefined
notionCredentialId: string
isShowPlanUpgradeModal: boolean
hideFilePreview: () => void
hideNotionPagePreview: () => void
hideWebsitePreview: () => void
hidePlanUpgradeModal: () => void
}
/**
* Right panel component for displaying file, notion page, or website previews.
*/
function PreviewPanel({
currentFile,
currentNotionPage,
currentWebsite,
notionCredentialId,
isShowPlanUpgradeModal,
hideFilePreview,
hideNotionPagePreview,
hideWebsitePreview,
hidePlanUpgradeModal,
}: PreviewPanelProps) {
const { t } = useTranslation()
return (
<div className="h-full w-1/2 overflow-y-auto">
{currentFile && <FilePreview file={currentFile} hidePreview={hideFilePreview} />}
{currentNotionPage && (
<NotionPagePreview
currentPage={currentNotionPage}
hidePreview={hideNotionPagePreview}
notionCredentialId={notionCredentialId}
/>
)}
{currentWebsite && <WebsitePreview payload={currentWebsite} hidePreview={hideWebsitePreview} />}
{isShowPlanUpgradeModal && (
<PlanUpgradeModal
show
onClose={hidePlanUpgradeModal}
title={t('upgrade.uploadMultiplePages.title', { ns: 'billing' })!}
description={t('upgrade.uploadMultiplePages.description', { ns: 'billing' })!}
/>
)}
</div>
)
}
export default PreviewPanel

View File

@ -0,0 +1,2 @@
export { default as usePreviewState } from './use-preview-state'
export type { PreviewActions, PreviewState, UsePreviewStateReturn } from './use-preview-state'

View File

@ -0,0 +1,70 @@
'use client'
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { useCallback, useState } from 'react'
export type PreviewState = {
currentFile: File | undefined
currentNotionPage: NotionPage | undefined
currentWebsite: CrawlResultItem | undefined
}
export type PreviewActions = {
showFilePreview: (file: File) => void
hideFilePreview: () => void
showNotionPagePreview: (page: NotionPage) => void
hideNotionPagePreview: () => void
showWebsitePreview: (website: CrawlResultItem) => void
hideWebsitePreview: () => void
}
export type UsePreviewStateReturn = PreviewState & PreviewActions
/**
* Custom hook for managing preview state across different data source types.
* Handles file, notion page, and website preview visibility.
*/
function usePreviewState(): UsePreviewStateReturn {
const [currentFile, setCurrentFile] = useState<File | undefined>()
const [currentNotionPage, setCurrentNotionPage] = useState<NotionPage | undefined>()
const [currentWebsite, setCurrentWebsite] = useState<CrawlResultItem | undefined>()
const showFilePreview = useCallback((file: File) => {
setCurrentFile(file)
}, [])
const hideFilePreview = useCallback(() => {
setCurrentFile(undefined)
}, [])
const showNotionPagePreview = useCallback((page: NotionPage) => {
setCurrentNotionPage(page)
}, [])
const hideNotionPagePreview = useCallback(() => {
setCurrentNotionPage(undefined)
}, [])
const showWebsitePreview = useCallback((website: CrawlResultItem) => {
setCurrentWebsite(website)
}, [])
const hideWebsitePreview = useCallback(() => {
setCurrentWebsite(undefined)
}, [])
return {
currentFile,
currentNotionPage,
currentWebsite,
showFilePreview,
hideFilePreview,
showNotionPagePreview,
hideNotionPagePreview,
showWebsitePreview,
hideWebsitePreview,
}
}
export default usePreviewState

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +1,25 @@
'use client'
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { DataSourceProvider, NotionPage } from '@/models/common'
import type { CrawlOptions, CrawlResultItem, FileItem } from '@/models/datasets'
import { RiArrowRightLine, RiFolder6Line } from '@remixicon/react'
import { RiFolder6Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import NotionConnector from '@/app/components/base/notion-connector'
import { NotionPageSelector } from '@/app/components/base/notion-page-selector'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
import { DataSourceType } from '@/models/datasets'
import { cn } from '@/utils/classnames'
import EmptyDatasetCreationModal from '../empty-dataset-creation-modal'
import FilePreview from '../file-preview'
import FileUploader from '../file-uploader'
import NotionPagePreview from '../notion-page-preview'
import Website from '../website'
import WebsitePreview from '../website/preview'
import { DataSourceTypeSelector, NextStepButton, PreviewPanel } from './components'
import { usePreviewState } from './hooks'
import s from './index.module.css'
import UpgradeCard from './upgrade-card'
@ -50,6 +46,24 @@ type IStepOneProps = {
authedDataSourceList: DataSourceAuth[]
}
// Helper function to check if notion is authenticated
function checkNotionAuth(authedDataSourceList: DataSourceAuth[]): boolean {
const notionSource = authedDataSourceList.find(item => item.provider === 'notion_datasource')
return Boolean(notionSource && notionSource.credentials_list.length > 0)
}
// Helper function to get notion credential list
function getNotionCredentialList(authedDataSourceList: DataSourceAuth[]) {
return authedDataSourceList.find(item => item.provider === 'notion_datasource')?.credentials_list || []
}
// Lookup table for checking multiple items by data source type
const MULTIPLE_ITEMS_CHECK: Record<DataSourceType, (props: { files: FileItem[], notionPages: NotionPage[], websitePages: CrawlResultItem[] }) => boolean> = {
[DataSourceType.FILE]: ({ files }) => files.length > 1,
[DataSourceType.NOTION]: ({ notionPages }) => notionPages.length > 1,
[DataSourceType.WEB]: ({ websitePages }) => websitePages.length > 1,
}
const StepOne = ({
datasetId,
dataSourceType: inCreatePageDataSourceType,
@ -72,76 +86,47 @@ const StepOne = ({
onCrawlOptionsChange,
authedDataSourceList,
}: IStepOneProps) => {
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
const [showModal, setShowModal] = useState(false)
const [currentFile, setCurrentFile] = useState<File | undefined>()
const [currentNotionPage, setCurrentNotionPage] = useState<NotionPage | undefined>()
const [currentWebsite, setCurrentWebsite] = useState<CrawlResultItem | undefined>()
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
const { plan, enableBilling } = useProviderContext()
const modalShowHandle = () => setShowModal(true)
const modalCloseHandle = () => setShowModal(false)
// Preview state management
const {
currentFile,
currentNotionPage,
currentWebsite,
showFilePreview,
hideFilePreview,
showNotionPagePreview,
hideNotionPagePreview,
showWebsitePreview,
hideWebsitePreview,
} = usePreviewState()
const updateCurrentFile = useCallback((file: File) => {
setCurrentFile(file)
}, [])
// Empty dataset modal state
const [showModal, { setTrue: openModal, setFalse: closeModal }] = useBoolean(false)
const hideFilePreview = useCallback(() => {
setCurrentFile(undefined)
}, [])
const updateCurrentPage = useCallback((page: NotionPage) => {
setCurrentNotionPage(page)
}, [])
const hideNotionPagePreview = useCallback(() => {
setCurrentNotionPage(undefined)
}, [])
const updateWebsite = useCallback((website: CrawlResultItem) => {
setCurrentWebsite(website)
}, [])
const hideWebsitePreview = useCallback(() => {
setCurrentWebsite(undefined)
}, [])
// Plan upgrade modal state
const [isShowPlanUpgradeModal, { setTrue: showPlanUpgradeModal, setFalse: hidePlanUpgradeModal }] = useBoolean(false)
// Computed values
const shouldShowDataSourceTypeList = !datasetId || (datasetId && !dataset?.data_source_type)
const isInCreatePage = shouldShowDataSourceTypeList
const dataSourceType = isInCreatePage ? inCreatePageDataSourceType : dataset?.data_source_type
const { plan, enableBilling } = useProviderContext()
const allFileLoaded = (files.length > 0 && files.every(file => file.file.id))
const hasNotin = notionPages.length > 0
// Default to FILE type when no type is provided from either source
const dataSourceType = isInCreatePage
? (inCreatePageDataSourceType ?? DataSourceType.FILE)
: (dataset?.data_source_type ?? DataSourceType.FILE)
const allFileLoaded = files.length > 0 && files.every(file => file.file.id)
const hasNotion = notionPages.length > 0
const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace
const isShowVectorSpaceFull = (allFileLoaded || hasNotin) && isVectorSpaceFull && enableBilling
const isShowVectorSpaceFull = (allFileLoaded || hasNotion) && isVectorSpaceFull && enableBilling
const supportBatchUpload = !enableBilling || plan.type !== Plan.sandbox
const notSupportBatchUpload = !supportBatchUpload
const [isShowPlanUpgradeModal, {
setTrue: showPlanUpgradeModal,
setFalse: hidePlanUpgradeModal,
}] = useBoolean(false)
const onStepChange = useCallback(() => {
if (notSupportBatchUpload) {
let isMultiple = false
if (dataSourceType === DataSourceType.FILE && files.length > 1)
isMultiple = true
const isNotionAuthed = useMemo(() => checkNotionAuth(authedDataSourceList), [authedDataSourceList])
const notionCredentialList = useMemo(() => getNotionCredentialList(authedDataSourceList), [authedDataSourceList])
if (dataSourceType === DataSourceType.NOTION && notionPages.length > 1)
isMultiple = true
if (dataSourceType === DataSourceType.WEB && websitePages.length > 1)
isMultiple = true
if (isMultiple) {
showPlanUpgradeModal()
return
}
}
doOnStepChange()
}, [dataSourceType, doOnStepChange, files.length, notSupportBatchUpload, notionPages.length, showPlanUpgradeModal, websitePages.length])
const nextDisabled = useMemo(() => {
const fileNextDisabled = useMemo(() => {
if (!files.length)
return true
if (files.some(file => !file.file.id))
@ -149,109 +134,50 @@ const StepOne = ({
return isShowVectorSpaceFull
}, [files, isShowVectorSpaceFull])
const isNotionAuthed = useMemo(() => {
if (!authedDataSourceList)
return false
const notionSource = authedDataSourceList.find(item => item.provider === 'notion_datasource')
if (!notionSource)
return false
return notionSource.credentials_list.length > 0
}, [authedDataSourceList])
// Clear previews when switching data source type
const handleClearPreviews = useCallback((newType: DataSourceType) => {
if (newType !== DataSourceType.FILE)
hideFilePreview()
if (newType !== DataSourceType.NOTION)
hideNotionPagePreview()
if (newType !== DataSourceType.WEB)
hideWebsitePreview()
}, [hideFilePreview, hideNotionPagePreview, hideWebsitePreview])
const notionCredentialList = useMemo(() => {
return authedDataSourceList.find(item => item.provider === 'notion_datasource')?.credentials_list || []
}, [authedDataSourceList])
// Handle step change with batch upload check
const onStepChange = useCallback(() => {
if (!supportBatchUpload && dataSourceType) {
const checkFn = MULTIPLE_ITEMS_CHECK[dataSourceType]
if (checkFn?.({ files, notionPages, websitePages })) {
showPlanUpgradeModal()
return
}
}
doOnStepChange()
}, [dataSourceType, doOnStepChange, files, supportBatchUpload, notionPages, showPlanUpgradeModal, websitePages])
return (
<div className="h-full w-full overflow-x-auto">
<div className="flex h-full w-full min-w-[1440px]">
{/* Left Panel - Form */}
<div className="relative h-full w-1/2 overflow-y-auto">
<div className="flex justify-end">
<div className={cn(s.form)}>
{
shouldShowDataSourceTypeList && (
{shouldShowDataSourceTypeList && (
<>
<div className={cn(s.stepHeader, 'system-md-semibold text-text-secondary')}>
{t('steps.one', { ns: 'datasetCreation' })}
</div>
)
}
{
shouldShowDataSourceTypeList && (
<div className="mb-8 grid grid-cols-3 gap-4">
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.FILE && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.FILE && s.disabled,
)}
onClick={() => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.FILE)
hideNotionPagePreview()
hideWebsitePreview()
}}
>
<span className={cn(s.datasetIcon)} />
<span
title={t('stepOne.dataSourceType.file', { ns: 'datasetCreation' })!}
className="truncate"
>
{t('stepOne.dataSourceType.file', { ns: 'datasetCreation' })}
</span>
</div>
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.NOTION && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.NOTION && s.disabled,
)}
onClick={() => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.NOTION)
hideFilePreview()
hideWebsitePreview()
}}
>
<span className={cn(s.datasetIcon, s.notion)} />
<span
title={t('stepOne.dataSourceType.notion', { ns: 'datasetCreation' })!}
className="truncate"
>
{t('stepOne.dataSourceType.notion', { ns: 'datasetCreation' })}
</span>
</div>
{(ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL) && (
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.WEB && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.WEB && s.disabled,
)}
onClick={() => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.WEB)
hideFilePreview()
hideNotionPagePreview()
}}
>
<span className={cn(s.datasetIcon, s.web)} />
<span
title={t('stepOne.dataSourceType.web', { ns: 'datasetCreation' })!}
className="truncate"
>
{t('stepOne.dataSourceType.web', { ns: 'datasetCreation' })}
</span>
</div>
)}
</div>
)
}
<DataSourceTypeSelector
currentType={dataSourceType}
disabled={dataSourceTypeDisable}
onChange={changeType}
onClearPreviews={handleClearPreviews}
/>
</>
)}
{/* File Data Source */}
{dataSourceType === DataSourceType.FILE && (
<>
<FileUploader
@ -260,7 +186,7 @@ const StepOne = ({
prepareFileList={updateFileList}
onFileListUpdate={updateFileList}
onFileUpdate={updateFile}
onPreview={updateCurrentFile}
onPreview={showFilePreview}
supportBatchUpload={supportBatchUpload}
/>
{isShowVectorSpaceFull && (
@ -268,24 +194,17 @@ const StepOne = ({
<VectorSpaceFull />
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={nextDisabled} variant="primary" onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('stepOne.button', { ns: 'datasetCreation' })}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
{
enableBilling && plan.type === Plan.sandbox && files.length > 0 && (
<div className="mt-5">
<div className="mb-4 h-px bg-divider-subtle"></div>
<UpgradeCard />
</div>
)
}
<NextStepButton disabled={fileNextDisabled} onClick={onStepChange} />
{enableBilling && plan.type === Plan.sandbox && files.length > 0 && (
<div className="mt-5">
<div className="mb-4 h-px bg-divider-subtle" />
<UpgradeCard />
</div>
)}
</>
)}
{/* Notion Data Source */}
{dataSourceType === DataSourceType.NOTION && (
<>
{!isNotionAuthed && <NotionConnector onSetting={onSetting} />}
@ -295,7 +214,7 @@ const StepOne = ({
<NotionPageSelector
value={notionPages.map(page => page.page_id)}
onSelect={updateNotionPages}
onPreview={updateCurrentPage}
onPreview={showNotionPagePreview}
credentialList={notionCredentialList}
onSelectCredential={updateNotionCredentialId}
datasetId={datasetId}
@ -306,23 +225,21 @@ const StepOne = ({
<VectorSpaceFull />
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={isShowVectorSpaceFull || !notionPages.length} variant="primary" onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('stepOne.button', { ns: 'datasetCreation' })}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
<NextStepButton
disabled={isShowVectorSpaceFull || !notionPages.length}
onClick={onStepChange}
/>
</>
)}
</>
)}
{/* Web Data Source */}
{dataSourceType === DataSourceType.WEB && (
<>
<div className={cn('mb-8 w-[640px]', !shouldShowDataSourceTypeList && 'mt-12')}>
<Website
onPreview={updateWebsite}
onPreview={showWebsitePreview}
checkedCrawlResult={websitePages}
onCheckedCrawlResultChange={updateWebsitePages}
onCrawlProviderChange={onWebsiteCrawlProviderChange}
@ -337,48 +254,43 @@ const StepOne = ({
<VectorSpaceFull />
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={isShowVectorSpaceFull || !websitePages.length} variant="primary" onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('stepOne.button', { ns: 'datasetCreation' })}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
<NextStepButton
disabled={isShowVectorSpaceFull || !websitePages.length}
onClick={onStepChange}
/>
</>
)}
{/* Empty Dataset Creation Link */}
{!datasetId && (
<>
<div className="my-8 h-px max-w-[640px] bg-divider-regular" />
<span className="inline-flex cursor-pointer items-center text-[13px] leading-4 text-text-accent" onClick={modalShowHandle}>
<span
className="inline-flex cursor-pointer items-center text-[13px] leading-4 text-text-accent"
onClick={openModal}
>
<RiFolder6Line className="mr-1 size-4" />
{t('stepOne.emptyDatasetCreation', { ns: 'datasetCreation' })}
</span>
</>
)}
</div>
<EmptyDatasetCreationModal show={showModal} onHide={modalCloseHandle} />
<EmptyDatasetCreationModal show={showModal} onHide={closeModal} />
</div>
</div>
<div className="h-full w-1/2 overflow-y-auto">
{currentFile && <FilePreview file={currentFile} hidePreview={hideFilePreview} />}
{currentNotionPage && (
<NotionPagePreview
currentPage={currentNotionPage}
hidePreview={hideNotionPagePreview}
notionCredentialId={notionCredentialId}
/>
)}
{currentWebsite && <WebsitePreview payload={currentWebsite} hidePreview={hideWebsitePreview} />}
{isShowPlanUpgradeModal && (
<PlanUpgradeModal
show
onClose={hidePlanUpgradeModal}
title={t('upgrade.uploadMultiplePages.title', { ns: 'billing' })!}
description={t('upgrade.uploadMultiplePages.description', { ns: 'billing' })!}
/>
)}
</div>
{/* Right Panel - Preview */}
<PreviewPanel
currentFile={currentFile}
currentNotionPage={currentNotionPage}
currentWebsite={currentWebsite}
notionCredentialId={notionCredentialId}
isShowPlanUpgradeModal={isShowPlanUpgradeModal}
hideFilePreview={hideFilePreview}
hideNotionPagePreview={hideNotionPagePreview}
hideWebsitePreview={hideWebsitePreview}
hidePlanUpgradeModal={hidePlanUpgradeModal}
/>
</div>
</div>
)

View File

@ -0,0 +1,36 @@
import type { DataSet } from '@/models/datasets'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import CornerLabel from '@/app/components/base/corner-label'
type CornerLabelsProps = {
dataset: DataSet
}
const CornerLabels = ({ dataset }: CornerLabelsProps) => {
const { t } = useTranslation()
if (!dataset.embedding_available) {
return (
<CornerLabel
label={t('cornerLabel.unavailable', { ns: 'dataset' })}
className="absolute right-0 top-0 z-10"
labelClassName="rounded-tr-xl"
/>
)
}
if (dataset.runtime_mode === 'rag_pipeline') {
return (
<CornerLabel
label={t('cornerLabel.pipeline', { ns: 'dataset' })}
className="absolute right-0 top-0 z-10"
labelClassName="rounded-tr-xl"
/>
)
}
return null
}
export default React.memo(CornerLabels)

View File

@ -0,0 +1,62 @@
import type { DataSet } from '@/models/datasets'
import { RiFileTextFill, RiRobot2Fill } from '@remixicon/react'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { cn } from '@/utils/classnames'
const EXTERNAL_PROVIDER = 'external'
type DatasetCardFooterProps = {
dataset: DataSet
}
const DatasetCardFooter = ({ dataset }: DatasetCardFooterProps) => {
const { t } = useTranslation()
const { formatTimeFromNow } = useFormatTimeFromNow()
const isExternalProvider = dataset.provider === EXTERNAL_PROVIDER
const documentCount = useMemo(() => {
const availableDocCount = dataset.total_available_documents ?? 0
if (availableDocCount < dataset.document_count)
return `${availableDocCount} / ${dataset.document_count}`
return `${dataset.document_count}`
}, [dataset.document_count, dataset.total_available_documents])
const documentCountTooltip = useMemo(() => {
const availableDocCount = dataset.total_available_documents ?? 0
if (availableDocCount < dataset.document_count)
return t('partialEnabled', { ns: 'dataset', count: dataset.document_count, num: availableDocCount })
return t('docAllEnabled', { ns: 'dataset', count: availableDocCount })
}, [t, dataset.document_count, dataset.total_available_documents])
return (
<div
className={cn(
'flex items-center gap-x-3 px-4 pb-3 pt-2 text-text-tertiary',
!dataset.embedding_available && 'opacity-30',
)}
>
<Tooltip popupContent={documentCountTooltip}>
<div className="flex items-center gap-x-1">
<RiFileTextFill className="size-3 text-text-quaternary" />
<span className="system-xs-medium">{documentCount}</span>
</div>
</Tooltip>
{!isExternalProvider && (
<Tooltip popupContent={`${dataset.app_count} ${t('appCount', { ns: 'dataset' })}`}>
<div className="flex items-center gap-x-1">
<RiRobot2Fill className="size-3 text-text-quaternary" />
<span className="system-xs-medium">{dataset.app_count}</span>
</div>
</Tooltip>
)}
<span className="system-xs-regular text-divider-deep">/</span>
<span className="system-xs-regular">{`${t('updated', { ns: 'dataset' })} ${formatTimeFromNow(dataset.updated_at * 1000)}`}</span>
</div>
)
}
export default React.memo(DatasetCardFooter)

View File

@ -0,0 +1,148 @@
import type { DataSet } from '@/models/datasets'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useKnowledge } from '@/hooks/use-knowledge'
import { DOC_FORM_ICON_WITH_BG, DOC_FORM_TEXT } from '@/models/datasets'
import { cn } from '@/utils/classnames'
const EXTERNAL_PROVIDER = 'external'
type DatasetCardHeaderProps = {
dataset: DataSet
}
// DocModeInfo component - placed before usage
type DocModeInfoProps = {
dataset: DataSet
isExternalProvider: boolean
isShowDocModeInfo: boolean
}
const DocModeInfo = ({
dataset,
isExternalProvider,
isShowDocModeInfo,
}: DocModeInfoProps) => {
const { t } = useTranslation()
const { formatIndexingTechniqueAndMethod } = useKnowledge()
if (isExternalProvider) {
return (
<div className="system-2xs-medium-uppercase flex items-center gap-x-3 text-text-tertiary">
<span>{t('externalKnowledgeBase', { ns: 'dataset' })}</span>
</div>
)
}
if (!isShowDocModeInfo)
return null
const indexingText = dataset.indexing_technique
? formatIndexingTechniqueAndMethod(
dataset.indexing_technique as 'economy' | 'high_quality',
dataset.retrieval_model_dict?.search_method as Parameters<typeof formatIndexingTechniqueAndMethod>[1],
)
: ''
return (
<div className="system-2xs-medium-uppercase flex items-center gap-x-3 text-text-tertiary">
{dataset.doc_form && (
<span
className="min-w-0 max-w-full truncate"
title={t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}
>
{t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}
</span>
)}
{dataset.indexing_technique && indexingText && (
<span
className="min-w-0 max-w-full truncate"
title={indexingText}
>
{indexingText}
</span>
)}
{dataset.is_multimodal && (
<span
className="min-w-0 max-w-full truncate"
title={t('multimodal', { ns: 'dataset' })}
>
{t('multimodal', { ns: 'dataset' })}
</span>
)}
</div>
)
}
// Main DatasetCardHeader component
const DatasetCardHeader = ({ dataset }: DatasetCardHeaderProps) => {
const { t } = useTranslation()
const { formatTimeFromNow } = useFormatTimeFromNow()
const isExternalProvider = dataset.provider === EXTERNAL_PROVIDER
const isShowChunkingModeIcon = dataset.doc_form && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published)
const isShowDocModeInfo = Boolean(
dataset.doc_form
&& dataset.indexing_technique
&& dataset.retrieval_model_dict?.search_method
&& (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published),
)
const chunkingModeIcon = dataset.doc_form ? DOC_FORM_ICON_WITH_BG[dataset.doc_form] : React.Fragment
const Icon = isExternalProvider ? DOC_FORM_ICON_WITH_BG.external : chunkingModeIcon
const iconInfo = useMemo(() => dataset.icon_info || {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
}, [dataset.icon_info])
const editTimeText = useMemo(
() => `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${formatTimeFromNow(dataset.updated_at * 1000)}`,
[t, dataset.updated_at, formatTimeFromNow],
)
return (
<div className={cn('flex items-center gap-x-3 px-4 pb-2 pt-4', !dataset.embedding_available && 'opacity-30')}>
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_type === 'image' ? undefined : iconInfo.icon_background}
imageUrl={iconInfo.icon_type === 'image' ? iconInfo.icon_url : undefined}
/>
{(isShowChunkingModeIcon || isExternalProvider) && (
<div className="absolute -bottom-1 -right-1 z-[5]">
<Icon className="size-4" />
</div>
)}
</div>
<div className="flex grow flex-col gap-y-1 overflow-hidden py-px">
<div
className="system-md-semibold truncate text-text-secondary"
title={dataset.name}
>
{dataset.name}
</div>
<div className="flex items-center gap-1 text-[10px] font-medium leading-[18px] text-text-tertiary">
<div className="truncate" title={dataset.author_name}>{dataset.author_name}</div>
<div>·</div>
<div className="truncate" title={editTimeText}>{editTimeText}</div>
</div>
<DocModeInfo
dataset={dataset}
isExternalProvider={isExternalProvider}
isShowDocModeInfo={isShowDocModeInfo}
/>
</div>
</div>
)
}
export default React.memo(DatasetCardHeader)

View File

@ -0,0 +1,55 @@
import type { DataSet } from '@/models/datasets'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import RenameDatasetModal from '../../../rename-modal'
type ModalState = {
showRenameModal: boolean
showConfirmDelete: boolean
confirmMessage: string
}
type DatasetCardModalsProps = {
dataset: DataSet
modalState: ModalState
onCloseRename: () => void
onCloseConfirm: () => void
onConfirmDelete: () => void
onSuccess?: () => void
}
const DatasetCardModals = ({
dataset,
modalState,
onCloseRename,
onCloseConfirm,
onConfirmDelete,
onSuccess,
}: DatasetCardModalsProps) => {
const { t } = useTranslation()
return (
<>
{modalState.showRenameModal && (
<RenameDatasetModal
show={modalState.showRenameModal}
dataset={dataset}
onClose={onCloseRename}
onSuccess={onSuccess}
/>
)}
{modalState.showConfirmDelete && (
<Confirm
title={t('deleteDatasetConfirmTitle', { ns: 'dataset' })}
content={modalState.confirmMessage}
isShow={modalState.showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={onCloseConfirm}
/>
)}
</>
)
}
export default React.memo(DatasetCardModals)

View File

@ -0,0 +1,18 @@
import type { DataSet } from '@/models/datasets'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type DescriptionProps = {
dataset: DataSet
}
const Description = ({ dataset }: DescriptionProps) => (
<div
className={cn('system-xs-regular line-clamp-2 h-10 px-4 py-1 text-text-tertiary', !dataset.embedding_available && 'opacity-30')}
title={dataset.description}
>
{dataset.description}
</div>
)
export default React.memo(Description)

View File

@ -0,0 +1,52 @@
import type { DataSet } from '@/models/datasets'
import { RiMoreFill } from '@remixicon/react'
import * as React from 'react'
import CustomPopover from '@/app/components/base/popover'
import { cn } from '@/utils/classnames'
import Operations from '../operations'
type OperationsPopoverProps = {
dataset: DataSet
isCurrentWorkspaceDatasetOperator: boolean
openRenameModal: () => void
handleExportPipeline: (include?: boolean) => void
detectIsUsedByApp: () => void
}
const OperationsPopover = ({
dataset,
isCurrentWorkspaceDatasetOperator,
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
}: OperationsPopoverProps) => (
<div className="absolute right-2 top-2 z-[15] hidden group-hover:block">
<CustomPopover
htmlContent={(
<Operations
showDelete={!isCurrentWorkspaceDatasetOperator}
showExportPipeline={dataset.runtime_mode === 'rag_pipeline'}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>
)}
className="z-20 min-w-[186px]"
popupClassName="rounded-xl bg-none shadow-none ring-0 min-w-[186px]"
position="br"
trigger="click"
btnElement={(
<div className="flex size-8 items-center justify-center rounded-[10px] hover:bg-state-base-hover">
<RiMoreFill className="h-5 w-5 text-text-tertiary" />
</div>
)}
btnClassName={open =>
cn(
'size-9 cursor-pointer justify-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0 shadow-lg shadow-shadow-shadow-5 ring-[2px] ring-inset ring-components-actionbar-bg hover:border-components-actionbar-border',
open ? 'border-components-actionbar-border bg-state-base-hover' : '',
)}
/>
</div>
)
export default React.memo(OperationsPopover)

View File

@ -0,0 +1,55 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import type { DataSet } from '@/models/datasets'
import * as React from 'react'
import TagSelector from '@/app/components/base/tag-management/selector'
import { cn } from '@/utils/classnames'
type TagAreaProps = {
dataset: DataSet
tags: Tag[]
setTags: (tags: Tag[]) => void
onSuccess?: () => void
isHoveringTagSelector: boolean
onClick: (e: React.MouseEvent) => void
}
const TagArea = React.forwardRef<HTMLDivElement, TagAreaProps>(({
dataset,
tags,
setTags,
onSuccess,
isHoveringTagSelector,
onClick,
}, ref) => (
<div
className={cn('relative w-full px-3', !dataset.embedding_available && 'opacity-30')}
onClick={onClick}
>
<div
ref={ref}
className={cn(
'invisible w-full group-hover:visible',
tags.length > 0 && 'visible',
)}
>
<TagSelector
position="bl"
type="knowledge"
targetID={dataset.id}
value={tags.map(tag => tag.id)}
selectedTags={tags}
onCacheUpdate={setTags}
onChange={onSuccess}
/>
</div>
<div
className={cn(
'absolute right-0 top-0 z-[5] h-full w-20 bg-tag-selector-mask-bg group-hover:bg-tag-selector-mask-hover-bg',
isHoveringTagSelector && 'hidden',
)}
/>
</div>
))
TagArea.displayName = 'TagArea'
export default TagArea

View File

@ -0,0 +1,138 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import type { DataSet } from '@/models/datasets'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useCheckDatasetUsage, useDeleteDataset } from '@/service/use-dataset-card'
import { useExportPipelineDSL } from '@/service/use-pipeline'
type ModalState = {
showRenameModal: boolean
showConfirmDelete: boolean
confirmMessage: string
}
type UseDatasetCardStateOptions = {
dataset: DataSet
onSuccess?: () => void
}
export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateOptions) => {
const { t } = useTranslation()
const [tags, setTags] = useState<Tag[]>(dataset.tags)
useEffect(() => {
setTags(dataset.tags)
}, [dataset.tags])
// Modal state
const [modalState, setModalState] = useState<ModalState>({
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
})
// Export state
const [exporting, setExporting] = useState(false)
// Modal handlers
const openRenameModal = useCallback(() => {
setModalState(prev => ({ ...prev, showRenameModal: true }))
}, [])
const closeRenameModal = useCallback(() => {
setModalState(prev => ({ ...prev, showRenameModal: false }))
}, [])
const closeConfirmDelete = useCallback(() => {
setModalState(prev => ({ ...prev, showConfirmDelete: false }))
}, [])
// API mutations
const { mutateAsync: checkUsage } = useCheckDatasetUsage()
const { mutateAsync: deleteDatasetMutation } = useDeleteDataset()
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
// Export pipeline handler
const handleExportPipeline = useCallback(async (include: boolean = false) => {
const { pipeline_id, name } = dataset
if (!pipeline_id || exporting)
return
try {
setExporting(true)
const { data } = await exportPipelineConfig({
pipelineId: pipeline_id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${name}.pipeline`
a.click()
URL.revokeObjectURL(url)
}
catch {
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
}
finally {
setExporting(false)
}
}, [dataset, exportPipelineConfig, exporting, t])
// Delete flow handlers
const detectIsUsedByApp = useCallback(async () => {
try {
const { is_using: isUsedByApp } = await checkUsage(dataset.id)
const message = isUsedByApp
? t('datasetUsedByApp', { ns: 'dataset' })!
: t('deleteDatasetConfirmContent', { ns: 'dataset' })!
setModalState(prev => ({
...prev,
confirmMessage: message,
showConfirmDelete: true,
}))
}
catch (e: unknown) {
if (e instanceof Response) {
const res = await e.json()
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
}
else {
Toast.notify({ type: 'error', message: (e as Error)?.message || 'Unknown error' })
}
}
}, [dataset.id, checkUsage, t])
const onConfirmDelete = useCallback(async () => {
try {
await deleteDatasetMutation(dataset.id)
Toast.notify({ type: 'success', message: t('datasetDeleted', { ns: 'dataset' }) })
onSuccess?.()
}
finally {
closeConfirmDelete()
}
}, [dataset.id, deleteDatasetMutation, onSuccess, t, closeConfirmDelete])
return {
// Tag state
tags,
setTags,
// Modal state
modalState,
openRenameModal,
closeRenameModal,
closeConfirmDelete,
// Export state
exporting,
// Handlers
handleExportPipeline,
detectIsUsedByApp,
onConfirmDelete,
}
}

View File

@ -1,28 +1,17 @@
'use client'
import type { Tag } from '@/app/components/base/tag-management/constant'
import type { DataSet } from '@/models/datasets'
import { RiFileTextFill, RiMoreFill, RiRobot2Fill } from '@remixicon/react'
import { useHover } from 'ahooks'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Confirm from '@/app/components/base/confirm'
import CornerLabel from '@/app/components/base/corner-label'
import CustomPopover from '@/app/components/base/popover'
import TagSelector from '@/app/components/base/tag-management/selector'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { useMemo, useRef } from 'react'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useKnowledge } from '@/hooks/use-knowledge'
import { DOC_FORM_ICON_WITH_BG, DOC_FORM_TEXT } from '@/models/datasets'
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import { cn } from '@/utils/classnames'
import RenameDatasetModal from '../../rename-modal'
import Operations from './operations'
import CornerLabels from './components/corner-labels'
import DatasetCardFooter from './components/dataset-card-footer'
import DatasetCardHeader from './components/dataset-card-header'
import DatasetCardModals from './components/dataset-card-modals'
import Description from './components/description'
import OperationsPopover from './components/operations-popover'
import TagArea from './components/tag-area'
import { useDatasetCardState } from './hooks/use-dataset-card-state'
const EXTERNAL_PROVIDER = 'external'
@ -35,320 +24,80 @@ const DatasetCard = ({
dataset,
onSuccess,
}: DatasetCardProps) => {
const { t } = useTranslation()
const { push } = useRouter()
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
const [tags, setTags] = useState<Tag[]>(dataset.tags)
const tagSelectorRef = useRef<HTMLDivElement>(null)
const isHoveringTagSelector = useHover(tagSelectorRef)
const [showRenameModal, setShowRenameModal] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [confirmMessage, setConfirmMessage] = useState<string>('')
const [exporting, setExporting] = useState(false)
const {
tags,
setTags,
modalState,
openRenameModal,
closeRenameModal,
closeConfirmDelete,
handleExportPipeline,
detectIsUsedByApp,
onConfirmDelete,
} = useDatasetCardState({ dataset, onSuccess })
const isExternalProvider = useMemo(() => {
return dataset.provider === EXTERNAL_PROVIDER
}, [dataset.provider])
const isExternalProvider = dataset.provider === EXTERNAL_PROVIDER
const isPipelineUnpublished = useMemo(() => {
return dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
}, [dataset.runtime_mode, dataset.is_published])
const isShowChunkingModeIcon = useMemo(() => {
return dataset.doc_form && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published)
}, [dataset.doc_form, dataset.runtime_mode, dataset.is_published])
const isShowDocModeInfo = useMemo(() => {
return dataset.doc_form && dataset.indexing_technique && dataset.retrieval_model_dict?.search_method && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published)
}, [dataset.doc_form, dataset.indexing_technique, dataset.retrieval_model_dict?.search_method, dataset.runtime_mode, dataset.is_published])
const chunkingModeIcon = dataset.doc_form ? DOC_FORM_ICON_WITH_BG[dataset.doc_form] : React.Fragment
const Icon = isExternalProvider ? DOC_FORM_ICON_WITH_BG.external : chunkingModeIcon
const iconInfo = dataset.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
const handleCardClick = (e: React.MouseEvent) => {
e.preventDefault()
if (isExternalProvider)
push(`/datasets/${dataset.id}/hitTesting`)
else if (isPipelineUnpublished)
push(`/datasets/${dataset.id}/pipeline`)
else
push(`/datasets/${dataset.id}/documents`)
}
const { formatIndexingTechniqueAndMethod } = useKnowledge()
const documentCount = useMemo(() => {
const availableDocCount = dataset.total_available_documents ?? 0
if (availableDocCount === dataset.document_count)
return `${dataset.document_count}`
if (availableDocCount < dataset.document_count)
return `${availableDocCount} / ${dataset.document_count}`
}, [dataset.document_count, dataset.total_available_documents])
const documentCountTooltip = useMemo(() => {
const availableDocCount = dataset.total_available_documents ?? 0
if (availableDocCount === dataset.document_count)
return t('docAllEnabled', { ns: 'dataset', count: availableDocCount })
if (availableDocCount < dataset.document_count)
return t('partialEnabled', { ns: 'dataset', count: dataset.document_count, num: availableDocCount })
}, [t, dataset.document_count, dataset.total_available_documents])
const { formatTimeFromNow } = useFormatTimeFromNow()
const editTimeText = useMemo(() => {
return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${formatTimeFromNow(dataset.updated_at * 1000)}`
}, [t, dataset.updated_at, formatTimeFromNow])
const openRenameModal = useCallback(() => {
setShowRenameModal(true)
}, [])
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
const handleExportPipeline = useCallback(async (include = false) => {
const { pipeline_id, name } = dataset
if (!pipeline_id)
return
if (exporting)
return
try {
setExporting(true)
const { data } = await exportPipelineConfig({
pipelineId: pipeline_id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${name}.pipeline`
a.click()
URL.revokeObjectURL(url)
}
catch {
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
}
finally {
setExporting(false)
}
}, [dataset, exportPipelineConfig, exporting, t])
const detectIsUsedByApp = useCallback(async () => {
try {
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
setConfirmMessage(isUsedByApp ? t('datasetUsedByApp', { ns: 'dataset' })! : t('deleteDatasetConfirmContent', { ns: 'dataset' })!)
setShowConfirmDelete(true)
}
catch (e: any) {
const res = await e.json()
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
}
}, [dataset.id, t])
const onConfirmDelete = useCallback(async () => {
try {
await deleteDataset(dataset.id)
Toast.notify({ type: 'success', message: t('datasetDeleted', { ns: 'dataset' }) })
if (onSuccess)
onSuccess()
}
finally {
setShowConfirmDelete(false)
}
}, [dataset.id, onSuccess, t])
useEffect(() => {
setTags(dataset.tags)
}, [dataset])
const handleTagAreaClick = (e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
}
return (
<>
<div
className="group relative col-span-1 flex h-[190px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5"
data-disable-nprogress={true}
onClick={(e) => {
e.preventDefault()
if (isExternalProvider)
push(`/datasets/${dataset.id}/hitTesting`)
else if (isPipelineUnpublished)
push(`/datasets/${dataset.id}/pipeline`)
else
push(`/datasets/${dataset.id}/documents`)
}}
onClick={handleCardClick}
>
{!dataset.embedding_available && (
<CornerLabel
label={t('cornerLabel.unavailable', { ns: 'dataset' })}
className="absolute right-0 top-0 z-10"
labelClassName="rounded-tr-xl"
/>
)}
{dataset.embedding_available && dataset.runtime_mode === 'rag_pipeline' && (
<CornerLabel
label={t('cornerLabel.pipeline', { ns: 'dataset' })}
className="absolute right-0 top-0 z-10"
labelClassName="rounded-tr-xl"
/>
)}
<div className={cn('flex items-center gap-x-3 px-4 pb-2 pt-4', !dataset.embedding_available && 'opacity-30')}>
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_type === 'image' ? undefined : iconInfo.icon_background}
imageUrl={iconInfo.icon_type === 'image' ? iconInfo.icon_url : undefined}
/>
{(isShowChunkingModeIcon || isExternalProvider) && (
<div className="absolute -bottom-1 -right-1 z-[5]">
<Icon className="size-4" />
</div>
)}
</div>
<div className="flex grow flex-col gap-y-1 overflow-hidden py-px">
<div
className="system-md-semibold truncate text-text-secondary"
title={dataset.name}
>
{dataset.name}
</div>
<div className="flex items-center gap-1 text-[10px] font-medium leading-[18px] text-text-tertiary">
<div className="truncate" title={dataset.author_name}>{dataset.author_name}</div>
<div>·</div>
<div className="truncate" title={editTimeText}>{editTimeText}</div>
</div>
<div className="system-2xs-medium-uppercase flex items-center gap-x-3 text-text-tertiary">
{isExternalProvider && <span>{t('externalKnowledgeBase', { ns: 'dataset' })}</span>}
{!isExternalProvider && isShowDocModeInfo && (
<>
{dataset.doc_form && (
<span
className="min-w-0 max-w-full truncate"
title={t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}
>
{t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}
</span>
)}
{dataset.indexing_technique && (
<span
className="min-w-0 max-w-full truncate"
title={formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method) as any}
>
{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method) as any}
</span>
)}
{dataset.is_multimodal && (
<span
className="min-w-0 max-w-full truncate"
title={t('multimodal', { ns: 'dataset' })}
>
{t('multimodal', { ns: 'dataset' })}
</span>
)}
</>
)}
</div>
</div>
</div>
<div
className={cn('system-xs-regular line-clamp-2 h-10 px-4 py-1 text-text-tertiary', !dataset.embedding_available && 'opacity-30')}
title={dataset.description}
>
{dataset.description}
</div>
<div
className={cn('relative w-full px-3', !dataset.embedding_available && 'opacity-30')}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div
ref={tagSelectorRef}
className={cn(
'invisible w-full group-hover:visible',
tags.length > 0 && 'visible',
)}
>
<TagSelector
position="bl"
type="knowledge"
targetID={dataset.id}
value={tags.map(tag => tag.id)}
selectedTags={tags}
onCacheUpdate={setTags}
onChange={onSuccess}
/>
</div>
{/* Tag Mask */}
<div
className={cn(
'absolute right-0 top-0 z-[5] h-full w-20 bg-tag-selector-mask-bg group-hover:bg-tag-selector-mask-hover-bg',
isHoveringTagSelector && 'hidden',
)}
/>
</div>
<div
className={cn(
'flex items-center gap-x-3 px-4 pb-3 pt-2 text-text-tertiary',
!dataset.embedding_available && 'opacity-30',
)}
>
<Tooltip popupContent={documentCountTooltip}>
<div className="flex items-center gap-x-1">
<RiFileTextFill className="size-3 text-text-quaternary" />
<span className="system-xs-medium">{documentCount}</span>
</div>
</Tooltip>
{!isExternalProvider && (
<Tooltip popupContent={`${dataset.app_count} ${t('appCount', { ns: 'dataset' })}`}>
<div className="flex items-center gap-x-1">
<RiRobot2Fill className="size-3 text-text-quaternary" />
<span className="system-xs-medium">{dataset.app_count}</span>
</div>
</Tooltip>
)}
<span className="system-xs-regular text-divider-deep">/</span>
<span className="system-xs-regular">{`${t('updated', { ns: 'dataset' })} ${formatTimeFromNow(dataset.updated_at * 1000)}`}</span>
</div>
<div className="absolute right-2 top-2 z-[15] hidden group-hover:block">
<CustomPopover
htmlContent={(
<Operations
showDelete={!isCurrentWorkspaceDatasetOperator}
showExportPipeline={dataset.runtime_mode === 'rag_pipeline'}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>
)}
className="z-20 min-w-[186px]"
popupClassName="rounded-xl bg-none shadow-none ring-0 min-w-[186px]"
position="br"
trigger="click"
btnElement={(
<div className="flex size-8 items-center justify-center rounded-[10px] hover:bg-state-base-hover">
<RiMoreFill className="h-5 w-5 text-text-tertiary" />
</div>
)}
btnClassName={open =>
cn(
'size-9 cursor-pointer justify-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0 shadow-lg shadow-shadow-shadow-5 ring-[2px] ring-inset ring-components-actionbar-bg hover:border-components-actionbar-border',
open ? 'border-components-actionbar-border bg-state-base-hover' : '',
)}
/>
</div>
</div>
{showRenameModal && (
<RenameDatasetModal
show={showRenameModal}
<CornerLabels dataset={dataset} />
<DatasetCardHeader dataset={dataset} />
<Description dataset={dataset} />
<TagArea
ref={tagSelectorRef}
dataset={dataset}
onClose={() => setShowRenameModal(false)}
tags={tags}
setTags={setTags}
onSuccess={onSuccess}
isHoveringTagSelector={isHoveringTagSelector}
onClick={handleTagAreaClick}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('deleteDatasetConfirmTitle', { ns: 'dataset' })}
content={confirmMessage}
isShow={showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
<DatasetCardFooter dataset={dataset} />
<OperationsPopover
dataset={dataset}
isCurrentWorkspaceDatasetOperator={isCurrentWorkspaceDatasetOperator}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>
)}
</div>
<DatasetCardModals
dataset={dataset}
modalState={modalState}
onCloseRename={closeRenameModal}
onCloseConfirm={closeConfirmDelete}
onConfirmDelete={onConfirmDelete}
onSuccess={onSuccess}
/>
</>
)
}

View File

@ -1,7 +1,5 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import component after mocks are set up
import Description from './index'
// ================================
@ -30,20 +28,18 @@ const commonTranslations: Record<string, string> = {
'operation.in': 'in',
}
// Mock getLocaleOnServer and translate
vi.mock('@/i18n-config/server', () => ({
getLocaleOnServer: vi.fn(() => Promise.resolve(mockDefaultLocale)),
getTranslation: vi.fn((locale: string, ns: string) => {
return Promise.resolve({
t: (key: string) => {
if (ns === 'plugin')
return pluginTranslations[key] || key
if (ns === 'common')
return commonTranslations[key] || key
return key
},
})
}),
// Mock i18n hooks
vi.mock('#i18n', () => ({
useLocale: vi.fn(() => mockDefaultLocale),
useTranslation: vi.fn((ns: string) => ({
t: (key: string) => {
if (ns === 'plugin')
return pluginTranslations[key] || key
if (ns === 'common')
return commonTranslations[key] || key
return key
},
})),
}))
// ================================
@ -59,29 +55,29 @@ describe('Description', () => {
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render without crashing', async () => {
const { container } = render(await Description({}))
it('should render without crashing', () => {
const { container } = render(<Description />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render h1 heading with empower text', async () => {
render(await Description({}))
it('should render h1 heading with empower text', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toBeInTheDocument()
expect(heading).toHaveTextContent('Empower your AI development')
})
it('should render h2 subheading', async () => {
render(await Description({}))
it('should render h2 subheading', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toBeInTheDocument()
})
it('should apply correct CSS classes to h1', async () => {
render(await Description({}))
it('should apply correct CSS classes to h1', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toHaveClass('title-4xl-semi-bold')
@ -90,8 +86,8 @@ describe('Description', () => {
expect(heading).toHaveClass('text-text-primary')
})
it('should apply correct CSS classes to h2', async () => {
render(await Description({}))
it('should apply correct CSS classes to h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('body-md-regular')
@ -104,14 +100,18 @@ describe('Description', () => {
// Non-Chinese Locale Rendering Tests
// ================================
describe('Non-Chinese Locale Rendering', () => {
it('should render discover text for en-US locale', async () => {
render(await Description({ locale: 'en-US' }))
beforeEach(() => {
mockDefaultLocale = 'en-US'
})
it('should render discover text for en-US locale', () => {
render(<Description />)
expect(screen.getByText(/Discover/)).toBeInTheDocument()
})
it('should render all category names', async () => {
render(await Description({ locale: 'en-US' }))
it('should render all category names', () => {
render(<Description />)
expect(screen.getByText('Models')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
@ -122,36 +122,36 @@ describe('Description', () => {
expect(screen.getByText('Bundles')).toBeInTheDocument()
})
it('should render "and" conjunction text', async () => {
render(await Description({ locale: 'en-US' }))
it('should render "and" conjunction text', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('and')
})
it('should render "in" preposition at the end for non-Chinese locales', async () => {
render(await Description({ locale: 'en-US' }))
it('should render "in" preposition at the end for non-Chinese locales', () => {
render(<Description />)
expect(screen.getByText('in')).toBeInTheDocument()
})
it('should render Dify Marketplace text at the end for non-Chinese locales', async () => {
render(await Description({ locale: 'en-US' }))
it('should render Dify Marketplace text at the end for non-Chinese locales', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should render category spans with styled underline effect', async () => {
const { container } = render(await Description({ locale: 'en-US' }))
it('should render category spans with styled underline effect', () => {
const { container } = render(<Description />)
const styledSpans = container.querySelectorAll('.body-md-medium.relative.z-\\[1\\]')
// 7 category spans (models, tools, datasources, triggers, agents, extensions, bundles)
expect(styledSpans.length).toBe(7)
})
it('should apply text-text-secondary class to category spans', async () => {
const { container } = render(await Description({ locale: 'en-US' }))
it('should apply text-text-secondary class to category spans', () => {
const { container } = render(<Description />)
const styledSpans = container.querySelectorAll('.text-text-secondary')
expect(styledSpans.length).toBeGreaterThanOrEqual(7)
@ -162,29 +162,33 @@ describe('Description', () => {
// Chinese (zh-Hans) Locale Rendering Tests
// ================================
describe('Chinese (zh-Hans) Locale Rendering', () => {
it('should render "in" text at the beginning for zh-Hans locale', async () => {
render(await Description({ locale: 'zh-Hans' }))
beforeEach(() => {
mockDefaultLocale = 'zh-Hans'
})
it('should render "in" text at the beginning for zh-Hans locale', () => {
render(<Description />)
// In zh-Hans mode, "in" appears at the beginning
const inElements = screen.getAllByText('in')
expect(inElements.length).toBeGreaterThanOrEqual(1)
})
it('should render Dify Marketplace text for zh-Hans locale', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render Dify Marketplace text for zh-Hans locale', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should render discover text for zh-Hans locale', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render discover text for zh-Hans locale', () => {
render(<Description />)
expect(screen.getByText(/Discover/)).toBeInTheDocument()
})
it('should render all categories for zh-Hans locale', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render all categories for zh-Hans locale', () => {
render(<Description />)
expect(screen.getByText('Models')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
@ -195,8 +199,8 @@ describe('Description', () => {
expect(screen.getByText('Bundles')).toBeInTheDocument()
})
it('should render both zh-Hans specific elements and shared elements', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render both zh-Hans specific elements and shared elements', () => {
render(<Description />)
// zh-Hans has specific element order: "in" -> Dify Marketplace -> Discover
// then the same category list with "and" -> Bundles
@ -206,61 +210,57 @@ describe('Description', () => {
})
// ================================
// Locale Prop Variations Tests
// Locale Variations Tests
// ================================
describe('Locale Prop Variations', () => {
it('should use default locale when locale prop is undefined', async () => {
describe('Locale Variations', () => {
it('should use en-US locale by default', () => {
mockDefaultLocale = 'en-US'
render(await Description({}))
render(<Description />)
// Should use the default locale from getLocaleOnServer
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should use provided locale prop instead of default', async () => {
it('should handle ja-JP locale as non-Chinese', () => {
mockDefaultLocale = 'ja-JP'
render(await Description({ locale: 'en-US' }))
// The locale prop should be used, triggering non-Chinese rendering
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toBeInTheDocument()
})
it('should handle ja-JP locale as non-Chinese', async () => {
render(await Description({ locale: 'ja-JP' }))
render(<Description />)
// Should render in non-Chinese format (discover first, then "in Dify Marketplace" at end)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should handle ko-KR locale as non-Chinese', async () => {
render(await Description({ locale: 'ko-KR' }))
it('should handle ko-KR locale as non-Chinese', () => {
mockDefaultLocale = 'ko-KR'
render(<Description />)
// Should render in non-Chinese format
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle de-DE locale as non-Chinese', async () => {
render(await Description({ locale: 'de-DE' }))
it('should handle de-DE locale as non-Chinese', () => {
mockDefaultLocale = 'de-DE'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle fr-FR locale as non-Chinese', async () => {
render(await Description({ locale: 'fr-FR' }))
it('should handle fr-FR locale as non-Chinese', () => {
mockDefaultLocale = 'fr-FR'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle pt-BR locale as non-Chinese', async () => {
render(await Description({ locale: 'pt-BR' }))
it('should handle pt-BR locale as non-Chinese', () => {
mockDefaultLocale = 'pt-BR'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle es-ES locale as non-Chinese', async () => {
render(await Description({ locale: 'es-ES' }))
it('should handle es-ES locale as non-Chinese', () => {
mockDefaultLocale = 'es-ES'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
@ -270,24 +270,27 @@ describe('Description', () => {
// Conditional Rendering Tests
// ================================
describe('Conditional Rendering', () => {
it('should render zh-Hans specific content when locale is zh-Hans', async () => {
const { container } = render(await Description({ locale: 'zh-Hans' }))
it('should render zh-Hans specific content when locale is zh-Hans', () => {
mockDefaultLocale = 'zh-Hans'
const { container } = render(<Description />)
// zh-Hans has additional span with mr-1 before "in" text at the start
const mrSpan = container.querySelector('span.mr-1')
expect(mrSpan).toBeInTheDocument()
})
it('should render non-Chinese specific content when locale is not zh-Hans', async () => {
render(await Description({ locale: 'en-US' }))
it('should render non-Chinese specific content when locale is not zh-Hans', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
// Non-Chinese has "in" and "Dify Marketplace" at the end
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should not render zh-Hans intro content for non-Chinese locales', async () => {
render(await Description({ locale: 'en-US' }))
it('should not render zh-Hans intro content for non-Chinese locales', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
// For en-US, the order should be Discover ... in Dify Marketplace
// The "in" text should only appear once at the end
@ -303,8 +306,9 @@ describe('Description', () => {
expect(inIndex).toBeLessThan(marketplaceIndex)
})
it('should render zh-Hans with proper word order', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render zh-Hans with proper word order', () => {
mockDefaultLocale = 'zh-Hans'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
@ -323,58 +327,58 @@ describe('Description', () => {
// Category Styling Tests
// ================================
describe('Category Styling', () => {
it('should apply underline effect with after pseudo-element styling', async () => {
const { container } = render(await Description({}))
it('should apply underline effect with after pseudo-element styling', () => {
const { container } = render(<Description />)
const categorySpan = container.querySelector('.after\\:absolute')
expect(categorySpan).toBeInTheDocument()
})
it('should apply correct after pseudo-element classes', async () => {
const { container } = render(await Description({}))
it('should apply correct after pseudo-element classes', () => {
const { container } = render(<Description />)
// Check for the specific after pseudo-element classes
const categorySpans = container.querySelectorAll('.after\\:bottom-\\[1\\.5px\\]')
expect(categorySpans.length).toBe(7)
})
it('should apply full width to after element', async () => {
const { container } = render(await Description({}))
it('should apply full width to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:w-full')
expect(categorySpans.length).toBe(7)
})
it('should apply correct height to after element', async () => {
const { container } = render(await Description({}))
it('should apply correct height to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:h-2')
expect(categorySpans.length).toBe(7)
})
it('should apply bg-text-text-selected to after element', async () => {
const { container } = render(await Description({}))
it('should apply bg-text-text-selected to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:bg-text-text-selected')
expect(categorySpans.length).toBe(7)
})
it('should have z-index 1 on category spans', async () => {
const { container } = render(await Description({}))
it('should have z-index 1 on category spans', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.z-\\[1\\]')
expect(categorySpans.length).toBe(7)
})
it('should apply left margin to category spans', async () => {
const { container } = render(await Description({}))
it('should apply left margin to category spans', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.ml-1')
expect(categorySpans.length).toBeGreaterThanOrEqual(7)
})
it('should apply both left and right margin to specific spans', async () => {
const { container } = render(await Description({}))
it('should apply both left and right margin to specific spans', () => {
const { container } = render(<Description />)
// Extensions and Bundles spans have both ml-1 and mr-1
const extensionsBundlesSpans = container.querySelectorAll('.ml-1.mr-1')
@ -386,28 +390,17 @@ describe('Description', () => {
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle empty props object', async () => {
const { container } = render(await Description({}))
expect(container.firstChild).toBeInTheDocument()
})
it('should render fragment as root element', async () => {
const { container } = render(await Description({}))
it('should render fragment as root element', () => {
const { container } = render(<Description />)
// Fragment renders h1 and h2 as direct children
expect(container.querySelector('h1')).toBeInTheDocument()
expect(container.querySelector('h2')).toBeInTheDocument()
})
it('should handle locale prop with undefined value', async () => {
render(await Description({ locale: undefined }))
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
})
it('should handle zh-Hant as non-Chinese simplified', async () => {
render(await Description({ locale: 'zh-Hant' }))
it('should handle zh-Hant as non-Chinese simplified', () => {
mockDefaultLocale = 'zh-Hant'
render(<Description />)
// zh-Hant is different from zh-Hans, should use non-Chinese format
const subheading = screen.getByRole('heading', { level: 2 })
@ -426,8 +419,8 @@ describe('Description', () => {
// Content Structure Tests
// ================================
describe('Content Structure', () => {
it('should have comma separators between categories', async () => {
render(await Description({}))
it('should have comma separators between categories', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
@ -436,8 +429,8 @@ describe('Description', () => {
expect(content).toMatch(/Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/)
})
it('should have "and" before last category (Bundles)', async () => {
render(await Description({}))
it('should have "and" before last category (Bundles)', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
@ -449,8 +442,9 @@ describe('Description', () => {
expect(andIndex).toBeLessThan(bundlesIndex)
})
it('should render all text elements in correct order for en-US', async () => {
render(await Description({ locale: 'en-US' }))
it('should render all text elements in correct order for en-US', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
@ -477,8 +471,9 @@ describe('Description', () => {
}
})
it('should render all text elements in correct order for zh-Hans', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render all text elements in correct order for zh-Hans', () => {
mockDefaultLocale = 'zh-Hans'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
@ -499,82 +494,48 @@ describe('Description', () => {
// Layout Tests
// ================================
describe('Layout', () => {
it('should have shrink-0 on h1 heading', async () => {
render(await Description({}))
it('should have shrink-0 on h1 heading', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toHaveClass('shrink-0')
})
it('should have shrink-0 on h2 subheading', async () => {
render(await Description({}))
it('should have shrink-0 on h2 subheading', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('shrink-0')
})
it('should have flex layout on h2', async () => {
render(await Description({}))
it('should have flex layout on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('flex')
})
it('should have items-center on h2', async () => {
render(await Description({}))
it('should have items-center on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('items-center')
})
it('should have justify-center on h2', async () => {
render(await Description({}))
it('should have justify-center on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('justify-center')
})
})
// ================================
// Translation Function Tests
// ================================
describe('Translation Functions', () => {
it('should call getTranslation for plugin namespace', async () => {
const { getTranslation } = await import('@/i18n-config/server')
render(await Description({ locale: 'en-US' }))
expect(getTranslation).toHaveBeenCalledWith('en-US', 'plugin')
})
it('should call getTranslation for common namespace', async () => {
const { getTranslation } = await import('@/i18n-config/server')
render(await Description({ locale: 'en-US' }))
expect(getTranslation).toHaveBeenCalledWith('en-US', 'common')
})
it('should call getLocaleOnServer when locale prop is undefined', async () => {
const { getLocaleOnServer } = await import('@/i18n-config/server')
render(await Description({}))
expect(getLocaleOnServer).toHaveBeenCalled()
})
it('should use locale prop when provided', async () => {
const { getTranslation } = await import('@/i18n-config/server')
render(await Description({ locale: 'ja-JP' }))
expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'plugin')
expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'common')
})
})
// ================================
// Accessibility Tests
// ================================
describe('Accessibility', () => {
it('should have proper heading hierarchy', async () => {
render(await Description({}))
it('should have proper heading hierarchy', () => {
render(<Description />)
const h1 = screen.getByRole('heading', { level: 1 })
const h2 = screen.getByRole('heading', { level: 2 })
@ -583,22 +544,22 @@ describe('Description', () => {
expect(h2).toBeInTheDocument()
})
it('should have readable text content', async () => {
render(await Description({}))
it('should have readable text content', () => {
render(<Description />)
const h1 = screen.getByRole('heading', { level: 1 })
expect(h1.textContent).not.toBe('')
})
it('should have visible h1 heading', async () => {
render(await Description({}))
it('should have visible h1 heading', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toBeVisible()
})
it('should have visible h2 heading', async () => {
render(await Description({}))
it('should have visible h2 heading', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toBeVisible()
@ -615,8 +576,8 @@ describe('Description Integration', () => {
mockDefaultLocale = 'en-US'
})
it('should render complete component structure', async () => {
const { container } = render(await Description({ locale: 'en-US' }))
it('should render complete component structure', () => {
const { container } = render(<Description />)
// Main headings
expect(container.querySelector('h1')).toBeInTheDocument()
@ -627,8 +588,9 @@ describe('Description Integration', () => {
expect(categorySpans.length).toBe(7)
})
it('should render complete zh-Hans structure', async () => {
const { container } = render(await Description({ locale: 'zh-Hans' }))
it('should render complete zh-Hans structure', () => {
mockDefaultLocale = 'zh-Hans'
const { container } = render(<Description />)
// Main headings
expect(container.querySelector('h1')).toBeInTheDocument()
@ -639,14 +601,16 @@ describe('Description Integration', () => {
expect(categorySpans.length).toBe(7)
})
it('should correctly switch between zh-Hans and en-US layouts', async () => {
it('should correctly differentiate between zh-Hans and en-US layouts', () => {
// Render en-US
const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' }))
mockDefaultLocale = 'en-US'
const { container: enContainer, unmount: unmountEn } = render(<Description />)
const enContent = enContainer.querySelector('h2')?.textContent || ''
unmountEn()
// Render zh-Hans
const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' }))
mockDefaultLocale = 'zh-Hans'
const { container: zhContainer } = render(<Description />)
const zhContent = zhContainer.querySelector('h2')?.textContent || ''
// Both should have all categories
@ -666,14 +630,16 @@ describe('Description Integration', () => {
expect(zhMarketplaceIndex).toBeLessThan(zhDiscoverIndex)
})
it('should maintain consistent styling across locales', async () => {
it('should maintain consistent styling across locales', () => {
// Render en-US
const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' }))
mockDefaultLocale = 'en-US'
const { container: enContainer, unmount: unmountEn } = render(<Description />)
const enCategoryCount = enContainer.querySelectorAll('.body-md-medium').length
unmountEn()
// Render zh-Hans
const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' }))
mockDefaultLocale = 'zh-Hans'
const { container: zhContainer } = render(<Description />)
const zhCategoryCount = zhContainer.querySelectorAll('.body-md-medium').length
// Both should have same number of styled category spans

View File

@ -1,17 +1,11 @@
/* eslint-disable dify-i18n/require-ns-option */
import type { Locale } from '@/i18n-config'
import { getLocaleOnServer, getTranslation } from '@/i18n-config/server'
import { useLocale, useTranslation } from '#i18n'
type DescriptionProps = {
locale?: Locale
}
const Description = async ({
locale: localeFromProps,
}: DescriptionProps) => {
const localeDefault = await getLocaleOnServer()
const { t } = await getTranslation(localeFromProps || localeDefault, 'plugin')
const { t: tCommon } = await getTranslation(localeFromProps || localeDefault, 'common')
const isZhHans = localeFromProps === 'zh-Hans'
const Description = () => {
const { t } = useTranslation('plugin')
const { t: tCommon } = useTranslation('common')
const locale = useLocale()
const isZhHans = locale === 'zh-Hans'
return (
<>

View File

@ -42,7 +42,7 @@ const Marketplace = async ({
scrollContainerId={scrollContainerId}
showSearchParams={showSearchParams}
>
<Description locale={locale} />
<Description />
<StickySearchAndSwitchWrapper
locale={locale}
pluginTypeSwitchClassName={pluginTypeSwitchClassName}

View File

@ -0,0 +1,163 @@
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fetchInitValidateStatus, fetchSetupStatus, sendForgotPasswordEmail } from '@/service/common'
import ForgotPasswordForm from './ForgotPasswordForm'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/service/common', () => ({
fetchSetupStatus: vi.fn(),
fetchInitValidateStatus: vi.fn(),
sendForgotPasswordEmail: vi.fn(),
}))
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus)
const mockSendForgotPasswordEmail = vi.mocked(sendForgotPasswordEmail)
const prepareLoadedState = () => {
mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' } as SetupStatusResponse)
mockFetchInitValidateStatus.mockResolvedValue({ status: 'finished' } as InitValidateStatusResponse)
}
describe('ForgotPasswordForm', () => {
beforeEach(() => {
vi.clearAllMocks()
prepareLoadedState()
})
it('should render form after loading', async () => {
render(<ForgotPasswordForm />)
expect(await screen.findByLabelText('login.email')).toBeInTheDocument()
})
it('should show validation error when email is empty', async () => {
render(<ForgotPasswordForm />)
await screen.findByLabelText('login.email')
fireEvent.click(screen.getByRole('button', { name: /login\.sendResetLink/ }))
await waitFor(() => {
expect(screen.getByText('login.error.emailInValid')).toBeInTheDocument()
})
expect(mockSendForgotPasswordEmail).not.toHaveBeenCalled()
})
it('should send reset email and navigate after confirmation', async () => {
mockSendForgotPasswordEmail.mockResolvedValue({ result: 'success', data: 'ok' } as any)
render(<ForgotPasswordForm />)
const emailInput = await screen.findByLabelText('login.email')
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.click(screen.getByRole('button', { name: /login\.sendResetLink/ }))
await waitFor(() => {
expect(mockSendForgotPasswordEmail).toHaveBeenCalledWith({
url: '/forgot-password',
body: { email: 'test@example.com' },
})
})
await waitFor(() => {
expect(screen.getByRole('button', { name: /login\.backToSignIn/ })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /login\.backToSignIn/ }))
expect(mockPush).toHaveBeenCalledWith('/signin')
})
it('should submit when form is submitted', async () => {
mockSendForgotPasswordEmail.mockResolvedValue({ result: 'success', data: 'ok' } as any)
render(<ForgotPasswordForm />)
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'test@example.com' } })
const form = screen.getByRole('button', { name: /login\.sendResetLink/ }).closest('form')
expect(form).not.toBeNull()
fireEvent.submit(form as HTMLFormElement)
await waitFor(() => {
expect(mockSendForgotPasswordEmail).toHaveBeenCalledWith({
url: '/forgot-password',
body: { email: 'test@example.com' },
})
})
})
it('should disable submit while request is in flight', async () => {
let resolveRequest: ((value: any) => void) | undefined
const requestPromise = new Promise((resolve) => {
resolveRequest = resolve
})
mockSendForgotPasswordEmail.mockReturnValue(requestPromise as any)
render(<ForgotPasswordForm />)
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'test@example.com' } })
const button = screen.getByRole('button', { name: /login\.sendResetLink/ })
fireEvent.click(button)
await waitFor(() => {
expect(button).toBeDisabled()
})
fireEvent.click(button)
expect(mockSendForgotPasswordEmail).toHaveBeenCalledTimes(1)
resolveRequest?.({ result: 'success', data: 'ok' })
await waitFor(() => {
expect(screen.getByRole('button', { name: /login\.backToSignIn/ })).toBeInTheDocument()
})
})
it('should keep form state when request fails', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSendForgotPasswordEmail.mockResolvedValue({ result: 'fail', data: 'error' } as any)
render(<ForgotPasswordForm />)
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'test@example.com' } })
fireEvent.click(screen.getByRole('button', { name: /login\.sendResetLink/ }))
await waitFor(() => {
expect(mockSendForgotPasswordEmail).toHaveBeenCalledTimes(1)
})
expect(screen.getByRole('button', { name: /login\.sendResetLink/ })).toBeInTheDocument()
expect(mockPush).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('should redirect to init when status is not started', async () => {
const originalLocation = window.location
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
})
mockFetchInitValidateStatus.mockResolvedValue({ status: 'not_started' } as InitValidateStatusResponse)
render(<ForgotPasswordForm />)
await waitFor(() => {
expect(window.location.href).toBe('/init')
})
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
})
})
})

View File

@ -1,15 +1,16 @@
'use client'
import type { InitValidateStatusResponse } from '@/models/common'
import { zodResolver } from '@hookform/resolvers/zod'
import { useStore } from '@tanstack/react-form'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import Button from '@/app/components/base/button'
import { formContext, useAppForm } from '@/app/components/base/form'
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
import {
fetchInitValidateStatus,
fetchSetupStatus,
@ -27,44 +28,45 @@ const accountFormSchema = z.object({
.email('error.emailInValid'),
})
type AccountFormValues = z.infer<typeof accountFormSchema>
const ForgotPasswordForm = () => {
const { t } = useTranslation()
const router = useRouter()
const [loading, setLoading] = useState(true)
const [isEmailSent, setIsEmailSent] = useState(false)
const { register, trigger, getValues, formState: { errors } } = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
const form = useAppForm({
defaultValues: { email: '' },
validators: {
onSubmit: zodSubmitValidator(accountFormSchema),
},
onSubmit: async ({ value }) => {
try {
const res = await sendForgotPasswordEmail({
url: '/forgot-password',
body: { email: value.email },
})
if (res.result === 'success')
setIsEmailSent(true)
else console.error('Email verification failed')
}
catch (error) {
console.error('Request failed:', error)
}
},
})
const handleSendResetPasswordEmail = async (email: string) => {
try {
const res = await sendForgotPasswordEmail({
url: '/forgot-password',
body: { email },
})
if (res.result === 'success')
setIsEmailSent(true)
else console.error('Email verification failed')
}
catch (error) {
console.error('Request failed:', error)
}
}
const isSubmitting = useStore(form.store, state => state.isSubmitting)
const emailErrors = useStore(form.store, state => state.fieldMeta.email?.errors)
const handleSendResetPasswordClick = async () => {
if (isSubmitting)
return
if (isEmailSent) {
router.push('/signin')
}
else {
const isValid = await trigger('email')
if (isValid) {
const email = getValues('email')
await handleSendResetPasswordEmail(email)
}
form.handleSubmit()
}
}
@ -94,30 +96,51 @@ const ForgotPasswordForm = () => {
</div>
<div className="mt-8 grow sm:mx-auto sm:w-full sm:max-w-md">
<div className="relative">
<form>
{!isEmailSent && (
<div className="mb-5">
<label
htmlFor="email"
className="my-2 flex items-center justify-between text-sm font-medium text-text-primary"
>
{t('email', { ns: 'login' })}
</label>
<div className="mt-1">
<Input
{...register('email')}
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
/>
{errors.email && <span className="text-sm text-red-400">{t(`${errors.email?.message}` as 'error.emailInValid', { ns: 'login' })}</span>}
<formContext.Provider value={form}>
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
{!isEmailSent && (
<div className="mb-5">
<label
htmlFor="email"
className="my-2 flex items-center justify-between text-sm font-medium text-text-primary"
>
{t('email', { ns: 'login' })}
</label>
<div className="mt-1">
<form.AppField
name="email"
>
{field => (
<Input
id="email"
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
/>
)}
</form.AppField>
{emailErrors && emailErrors.length > 0 && (
<span className="text-sm text-red-400">
{t(`${emailErrors[0]}` as 'error.emailInValid', { ns: 'login' })}
</span>
)}
</div>
</div>
)}
<div>
<Button variant="primary" className="w-full" disabled={isSubmitting} onClick={handleSendResetPasswordClick}>
{isEmailSent ? t('backToSignIn', { ns: 'login' }) : t('sendResetLink', { ns: 'login' })}
</Button>
</div>
)}
<div>
<Button variant="primary" className="w-full" onClick={handleSendResetPasswordClick}>
{isEmailSent ? t('backToSignIn', { ns: 'login' }) : t('sendResetLink', { ns: 'login' })}
</Button>
</div>
</form>
</form>
</formContext.Provider>
</div>
</div>
</>

View File

@ -0,0 +1,158 @@
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common'
import { encryptPassword } from '@/utils/encryption'
import InstallForm from './installForm'
const mockPush = vi.fn()
const mockReplace = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush, replace: mockReplace }),
}))
vi.mock('@/service/common', () => ({
fetchSetupStatus: vi.fn(),
fetchInitValidateStatus: vi.fn(),
setup: vi.fn(),
login: vi.fn(),
getSystemFeatures: vi.fn(),
}))
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus)
const mockSetup = vi.mocked(setup)
const mockLogin = vi.mocked(login)
const prepareLoadedState = () => {
mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' } as SetupStatusResponse)
mockFetchInitValidateStatus.mockResolvedValue({ status: 'finished' } as InitValidateStatusResponse)
}
describe('InstallForm', () => {
beforeEach(() => {
vi.clearAllMocks()
prepareLoadedState()
})
it('should render form after loading', async () => {
render(<InstallForm />)
expect(await screen.findByLabelText('login.email')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /login\.installBtn/ })).toBeInTheDocument()
})
it('should show validation error when required fields are empty', async () => {
render(<InstallForm />)
await screen.findByLabelText('login.email')
fireEvent.click(screen.getByRole('button', { name: /login\.installBtn/ }))
await waitFor(() => {
expect(screen.getByText('login.error.emailInValid')).toBeInTheDocument()
expect(screen.getByText('login.error.nameEmpty')).toBeInTheDocument()
})
expect(mockSetup).not.toHaveBeenCalled()
})
it('should submit and redirect to apps on successful login', async () => {
mockSetup.mockResolvedValue({ result: 'success' } as any)
mockLogin.mockResolvedValue({ result: 'success', data: { access_token: 'token' } } as any)
render(<InstallForm />)
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'admin@example.com' } })
fireEvent.change(screen.getByLabelText('login.name'), { target: { value: 'Admin' } })
fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'Password123' } })
const form = screen.getByRole('button', { name: /login\.installBtn/ }).closest('form')
expect(form).not.toBeNull()
fireEvent.submit(form as HTMLFormElement)
await waitFor(() => {
expect(mockSetup).toHaveBeenCalledWith({
body: {
email: 'admin@example.com',
name: 'Admin',
password: 'Password123',
language: 'en',
},
})
})
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith({
url: '/login',
body: {
email: 'admin@example.com',
password: encryptPassword('Password123'),
},
})
})
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
})
it('should redirect to sign in when login fails', async () => {
mockSetup.mockResolvedValue({ result: 'success' } as any)
mockLogin.mockResolvedValue({ result: 'fail', data: 'error', code: 'login_failed', message: 'login failed' } as any)
render(<InstallForm />)
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'admin@example.com' } })
fireEvent.change(screen.getByLabelText('login.name'), { target: { value: 'Admin' } })
fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'Password123' } })
fireEvent.click(screen.getByRole('button', { name: /login\.installBtn/ }))
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/signin')
})
})
it('should disable submit while request is in flight', async () => {
let resolveSetup: ((value: any) => void) | undefined
const setupPromise = new Promise((resolve) => {
resolveSetup = resolve
})
mockSetup.mockReturnValue(setupPromise as any)
mockLogin.mockResolvedValue({ result: 'success', data: { access_token: 'token' } } as any)
render(<InstallForm />)
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'admin@example.com' } })
fireEvent.change(screen.getByLabelText('login.name'), { target: { value: 'Admin' } })
fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'Password123' } })
const button = screen.getByRole('button', { name: /login\.installBtn/ })
fireEvent.click(button)
await waitFor(() => {
expect(button).toBeDisabled()
})
fireEvent.click(button)
expect(mockSetup).toHaveBeenCalledTimes(1)
resolveSetup?.({ result: 'success' })
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledTimes(1)
})
})
it('should redirect to sign in when setup is finished', async () => {
mockFetchSetupStatus.mockResolvedValue({ step: 'finished' } as SetupStatusResponse)
render(<InstallForm />)
await waitFor(() => {
expect(localStorage.setItem).toHaveBeenCalledWith('setup_status', 'finished')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})
})

View File

@ -1,18 +1,17 @@
'use client'
import type { SubmitHandler } from 'react-hook-form'
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
import { zodResolver } from '@hookform/resolvers/zod'
import { useDebounceFn } from 'ahooks'
import { useStore } from '@tanstack/react-form'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import Button from '@/app/components/base/button'
import { formContext, useAppForm } from '@/app/components/base/form'
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
import Input from '@/app/components/base/input'
import { validPassword } from '@/config'
import { useDocLink } from '@/context/i18n'
@ -33,8 +32,6 @@ const accountFormSchema = z.object({
}).regex(validPassword, 'error.passwordInvalid'),
})
type AccountFormValues = z.infer<typeof accountFormSchema>
const InstallForm = () => {
useDocumentTitle('')
const { t, i18n } = useTranslation()
@ -42,64 +39,49 @@ const InstallForm = () => {
const router = useRouter()
const [showPassword, setShowPassword] = React.useState(false)
const [loading, setLoading] = React.useState(true)
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
const form = useAppForm({
defaultValues: {
name: '',
password: '',
email: '',
},
})
validators: {
onSubmit: zodSubmitValidator(accountFormSchema),
},
onSubmit: async ({ value }) => {
// First, setup the admin account
await setup({
body: {
...value,
language: i18n.language,
},
})
const onSubmit: SubmitHandler<AccountFormValues> = async (data) => {
// First, setup the admin account
await setup({
body: {
...data,
language: i18n.language,
},
})
// Then, automatically login with the same credentials
const loginRes = await login({
url: '/login',
body: {
email: value.email,
password: encodePassword(value.password),
},
})
// Then, automatically login with the same credentials
const loginRes = await login({
url: '/login',
body: {
email: data.email,
password: encodePassword(data.password),
},
})
// Store tokens and redirect to apps if login successful
if (loginRes.result === 'success') {
router.replace('/apps')
}
else {
// Fallback to signin page if auto-login fails
router.replace('/signin')
}
}
const handleSetting = async () => {
if (isSubmitting)
return
handleSubmit(onSubmit)()
}
const { run: debouncedHandleKeyDown } = useDebounceFn(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSetting()
// Store tokens and redirect to apps if login successful
if (loginRes.result === 'success') {
router.replace('/apps')
}
else {
// Fallback to signin page if auto-login fails
router.replace('/signin')
}
},
{ wait: 200 },
)
})
const handleKeyDown = useCallback(debouncedHandleKeyDown, [debouncedHandleKeyDown])
const isSubmitting = useStore(form.store, state => state.isSubmitting)
const emailErrors = useStore(form.store, state => state.fieldMeta.email?.errors)
const nameErrors = useStore(form.store, state => state.fieldMeta.name?.errors)
const passwordErrors = useStore(form.store, state => state.fieldMeta.password?.errors)
useEffect(() => {
fetchSetupStatus().then((res: SetupStatusResponse) => {
@ -128,76 +110,111 @@ const InstallForm = () => {
</div>
<div className="mt-8 grow sm:mx-auto sm:w-full sm:max-w-md">
<div className="relative">
<form onSubmit={handleSubmit(onSubmit)} onKeyDown={handleKeyDown}>
<div className="mb-5">
<label htmlFor="email" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
{t('email', { ns: 'login' })}
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
{...register('email')}
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
className="system-sm-regular w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal px-3 py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
{errors.email && <span className="text-sm text-red-400">{t(`${errors.email?.message}` as 'error.emailInValid', { ns: 'login' })}</span>}
</div>
</div>
<div className="mb-5">
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
{t('name', { ns: 'login' })}
</label>
<div className="relative mt-1 rounded-md shadow-sm">
<input
{...register('name')}
placeholder={t('namePlaceholder', { ns: 'login' }) || ''}
className="system-sm-regular w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal px-3 py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
{errors.name && <span className="text-sm text-red-400">{t(`${errors.name.message}` as 'error.nameEmpty', { ns: 'login' })}</span>}
</div>
<div className="mb-5">
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
{t('password', { ns: 'login' })}
</label>
<div className="relative mt-1 rounded-md shadow-sm">
<input
{...register('password')}
type={showPassword ? 'text' : 'password'}
placeholder={t('passwordPlaceholder', { ns: 'login' }) || ''}
className="system-sm-regular w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal px-3 py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-text-quaternary hover:text-text-tertiary focus:text-text-tertiary focus:outline-none"
>
{showPassword ? '👀' : '😝'}
</button>
<formContext.Provider value={form}>
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
if (isSubmitting)
return
form.handleSubmit()
}}
>
<div className="mb-5">
<label htmlFor="email" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
{t('email', { ns: 'login' })}
</label>
<div className="mt-1">
<form.AppField name="email">
{field => (
<Input
id="email"
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
/>
)}
</form.AppField>
{emailErrors && emailErrors.length > 0 && (
<span className="text-sm text-red-400">
{t(`${emailErrors[0]}` as 'error.emailInValid', { ns: 'login' })}
</span>
)}
</div>
</div>
<div className={cn('mt-1 text-xs text-text-secondary', {
'text-red-400 !text-sm': errors.password,
})}
>
{t('error.passwordInvalid', { ns: 'login' })}
<div className="mb-5">
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
{t('name', { ns: 'login' })}
</label>
<div className="relative mt-1">
<form.AppField name="name">
{field => (
<Input
id="name"
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder={t('namePlaceholder', { ns: 'login' }) || ''}
/>
)}
</form.AppField>
</div>
{nameErrors && nameErrors.length > 0 && (
<span className="text-sm text-red-400">
{t(`${nameErrors[0]}` as 'error.nameEmpty', { ns: 'login' })}
</span>
)}
</div>
</div>
<div>
<Button variant="primary" className="w-full" onClick={handleSetting}>
{t('installBtn', { ns: 'login' })}
</Button>
</div>
</form>
<div className="mb-5">
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
{t('password', { ns: 'login' })}
</label>
<div className="relative mt-1">
<form.AppField name="password">
{field => (
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder={t('passwordPlaceholder', { ns: 'login' }) || ''}
/>
)}
</form.AppField>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-text-quaternary hover:text-text-tertiary focus:text-text-tertiary focus:outline-none"
>
{showPassword ? '👀' : '😝'}
</button>
</div>
</div>
<div className={cn('mt-1 text-xs text-text-secondary', {
'text-red-400 !text-sm': passwordErrors && passwordErrors.length > 0,
})}
>
{t('error.passwordInvalid', { ns: 'login' })}
</div>
</div>
<div>
<Button variant="primary" type="submit" disabled={isSubmitting} loading={isSubmitting} className="w-full">
{t('installBtn', { ns: 'login' })}
</Button>
</div>
</form>
</formContext.Provider>
<div className="mt-2 block w-full text-xs text-text-secondary">
{t('license.tip', { ns: 'login' })}
&nbsp;
&nbsp;
<Link
className="text-text-accent"
target="_blank"

View File

@ -11,6 +11,7 @@ import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import { login } from '@/service/common'
import { setWebAppAccessToken } from '@/service/webapp-auth'
import { encryptPassword } from '@/utils/encryption'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
@ -65,6 +66,7 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
})
if (res.result === 'success') {
// Track login success event
setWebAppAccessToken(res.data.access_token)
trackEvent('user_login_success', {
method: 'email_password',
is_invite: isInvite,

View File

@ -179,7 +179,7 @@ export default antfu(
// 'dify-i18n/no-as-any-in-t': ['error', { mode: 'all' }],
'dify-i18n/no-as-any-in-t': 'error',
// 'dify-i18n/no-legacy-namespace-prefix': 'error',
'dify-i18n/require-ns-option': 'error',
// 'dify-i18n/require-ns-option': 'error',
},
},
// i18n JSON validation rules

View File

@ -0,0 +1,10 @@
'use client'
import type { NamespaceCamelCase } from './i18next-config'
import { useTranslation as useTranslationOriginal } from 'react-i18next'
export function useTranslation(ns?: NamespaceCamelCase) {
return useTranslationOriginal(ns)
}
export { useLocale } from '@/context/i18n'

View File

@ -0,0 +1,16 @@
import type { NamespaceCamelCase } from './i18next-config'
import { use } from 'react'
import { getLocaleOnServer, getTranslation } from './server'
async function getI18nConfig(ns?: NamespaceCamelCase) {
const lang = await getLocaleOnServer()
return getTranslation(lang, ns)
}
export function useTranslation(ns?: NamespaceCamelCase) {
return use(getI18nConfig(ns))
}
export function useLocale() {
return use(getLocaleOnServer())
}

View File

@ -2,13 +2,13 @@ import type { i18n as I18nInstance } from 'i18next'
import type { Locale } from '.'
import type { NamespaceCamelCase, NamespaceKebabCase } from './i18next-config'
import { match } from '@formatjs/intl-localematcher'
import { camelCase, kebabCase } from 'es-toolkit/compat'
import { kebabCase } from 'es-toolkit/compat'
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import Negotiator from 'negotiator'
import { cookies, headers } from 'next/headers'
import { initReactI18next } from 'react-i18next/initReactI18next'
import serverOnlyContext from '@/utils/server-only-context'
import { serverOnlyContext } from '@/utils/server-only-context'
import { i18n } from '.'
const [getLocaleCache, setLocaleCache] = serverOnlyContext<Locale | null>(null)
@ -35,15 +35,14 @@ const getOrCreateI18next = async (lng: Locale) => {
return instance
}
export async function getTranslation(lng: Locale, ns: NamespaceKebabCase) {
const camelNs = camelCase(ns) as NamespaceCamelCase
export async function getTranslation(lng: Locale, ns?: NamespaceCamelCase) {
const i18nextInstance = await getOrCreateI18next(lng)
if (!i18nextInstance.hasLoadedNamespace(camelNs))
await i18nextInstance.loadNamespaces(camelNs)
if (ns && !i18nextInstance.hasLoadedNamespace(ns))
await i18nextInstance.loadNamespaces(ns)
return {
t: i18nextInstance.getFixedT(lng, camelNs),
t: i18nextInstance.getFixedT(lng, ns),
i18n: i18nextInstance,
}
}

View File

@ -3,7 +3,13 @@
"type": "module",
"version": "1.11.2",
"private": true,
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
"imports": {
"#i18n": {
"react-server": "./i18n-config/lib.server.ts",
"default": "./i18n-config/lib.client.ts"
}
},
"engines": {
"node": ">=v22.11.0"
},
@ -54,7 +60,6 @@
"@formatjs/intl-localematcher": "^0.5.10",
"@headlessui/react": "2.2.1",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2",
"@lexical/code": "^0.38.2",
"@lexical/link": "^0.38.2",
"@lexical/list": "^0.38.2",
@ -117,7 +122,6 @@
"react-18-input-autosize": "^3.0.0",
"react-dom": "19.2.3",
"react-easy-crop": "^5.5.3",
"react-hook-form": "^7.65.0",
"react-hotkeys-hook": "^4.6.2",
"react-i18next": "^16.5.0",
"react-markdown": "^9.1.0",

31
web/pnpm-lock.yaml generated
View File

@ -78,9 +78,6 @@ importers:
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@19.2.3)
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.68.0(react@19.2.3))
'@lexical/code':
specifier: ^0.38.2
version: 0.38.2
@ -267,9 +264,6 @@ importers:
react-easy-crop:
specifier: ^5.5.3
version: 5.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-hook-form:
specifier: ^7.65.0
version: 7.68.0(react@19.2.3)
react-hotkeys-hook:
specifier: ^4.6.2
version: 4.6.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -1878,11 +1872,6 @@ packages:
peerDependencies:
react: '>= 16 || ^19.0.0-rc'
'@hookform/resolvers@5.2.2':
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
peerDependencies:
react-hook-form: ^7.55.0
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@ -3211,9 +3200,6 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@storybook/addon-docs@9.1.13':
resolution: {integrity: sha512-V1nCo7bfC3kQ5VNVq0VDcHsIhQf507m+BxMA5SIYiwdJHljH2BXpW2fL3FFn9gv9Wp57AEEzhm+wh4zANaJgkg==}
peerDependencies:
@ -7436,12 +7422,6 @@ packages:
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
react-hook-form@7.68.0:
resolution: {integrity: sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-hotkeys-hook@4.6.2:
resolution: {integrity: sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q==}
peerDependencies:
@ -10516,11 +10496,6 @@ snapshots:
dependencies:
react: 19.2.3
'@hookform/resolvers@5.2.2(react-hook-form@7.68.0(react@19.2.3))':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.68.0(react@19.2.3)
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@ -11782,8 +11757,6 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@storybook/addon-docs@9.1.13(@types/react@19.2.7)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.2.7)(react@19.2.3)
@ -16931,10 +16904,6 @@ snapshots:
react-fast-compare@3.2.2: {}
react-hook-form@7.68.0(react@19.2.3):
dependencies:
react: 19.2.3
react-hotkeys-hook@4.6.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3

View File

@ -12,6 +12,7 @@ import type {
} from '@/models/app'
import type { App, AppModeEnum } from '@/types/app'
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
@ -107,6 +108,7 @@ export const useInfiniteAppList = (params: AppListParams, options?: { enabled?:
queryFn: ({ pageParam = normalizedParams.page }) => get<AppListResponse>('/apps', { params: { ...normalizedParams, page: pageParam } }),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: normalizedParams.page,
placeholderData: keepPreviousData,
...options,
})
}

View File

@ -0,0 +1,18 @@
import { useMutation } from '@tanstack/react-query'
import { checkIsUsedInApp, deleteDataset } from './datasets'
const NAME_SPACE = 'dataset-card'
export const useCheckDatasetUsage = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'check-usage'],
mutationFn: (datasetId: string) => checkIsUsedInApp(datasetId),
})
}
export const useDeleteDataset = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'delete'],
mutationFn: (datasetId: string) => deleteDataset(datasetId),
})
}

View File

@ -2,7 +2,7 @@
import { cache } from 'react'
export default <T>(defaultValue: T): [() => T, (v: T) => void] => {
export function serverOnlyContext<T>(defaultValue: T): [() => T, (v: T) => void] {
const getRef = cache(() => ({ current: defaultValue }))
const getValue = (): T => getRef().current