mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
Merge branch 'feat/agent-node-v2' into feat/support-agent-sandbox
This commit is contained in:
41
web/app/components/apps/app-card-skeleton.tsx
Normal file
41
web/app/components/apps/app-card-skeleton.tsx
Normal 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'
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -17,7 +17,7 @@ vi.mock('@/hooks/use-app-favicon', () => ({
|
||||
useAppFavicon: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/i18next-config', () => ({
|
||||
vi.mock('@/i18n-config/client', () => ({
|
||||
changeLanguage: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ import { useToastContext } from '@/app/components/base/toast'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useAppFavicon } from '@/hooks/use-app-favicon'
|
||||
import { changeLanguage } from '@/i18n-config/i18next-config'
|
||||
import { changeLanguage } from '@/i18n-config/client'
|
||||
import {
|
||||
delConversation,
|
||||
pinConversation,
|
||||
|
||||
@ -13,7 +13,7 @@ import { shareQueryKeys } from '@/service/use-share'
|
||||
import { CONVERSATION_ID_INFO } from '../constants'
|
||||
import { useEmbeddedChatbot } from './hooks'
|
||||
|
||||
vi.mock('@/i18n-config/i18next-config', () => ({
|
||||
vi.mock('@/i18n-config/client', () => ({
|
||||
changeLanguage: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ import { useToastContext } from '@/app/components/base/toast'
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { changeLanguage } from '@/i18n-config/i18next-config'
|
||||
import { changeLanguage } from '@/i18n-config/client'
|
||||
import { updateFeedback } from '@/service/share'
|
||||
import {
|
||||
useInvalidateShareConversations,
|
||||
|
||||
@ -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[]) => {
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from './secret-input'
|
||||
22
web/app/components/base/form/utils/zod-submit-validator.ts
Normal file
22
web/app/components/base/form/utils/zod-submit-validator.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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'
|
||||
@ -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
|
||||
@ -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
|
||||
@ -0,0 +1,2 @@
|
||||
export { default as usePreviewState } from './use-preview-state'
|
||||
export type { PreviewActions, PreviewState, UsePreviewStateReturn } from './use-preview-state'
|
||||
@ -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
|
||||
1204
web/app/components/datasets/create/step-one/index.spec.tsx
Normal file
1204
web/app/components/datasets/create/step-one/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiUser3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Account command dependency types - no external dependencies needed
|
||||
@ -21,6 +21,7 @@ export const accountCommand: SlashCommandHandler<AccountDeps> = {
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
return [{
|
||||
id: 'account',
|
||||
title: i18n.t('account.account', { ns: 'common', lng: locale }),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiDiscordLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Community command dependency types
|
||||
@ -22,6 +22,7 @@ export const communityCommand: SlashCommandHandler<CommunityDeps> = {
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
return [{
|
||||
id: 'community',
|
||||
title: i18n.t('userProfile.community', { ns: 'common', lng: locale }),
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiBookOpenLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { defaultDocBaseUrl } from '@/context/i18n'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
@ -19,6 +19,7 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
|
||||
// Direct execution function
|
||||
execute: () => {
|
||||
const i18n = getI18n()
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
const url = `${defaultDocBaseUrl}/${docLanguage}`
|
||||
@ -26,6 +27,7 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
return [{
|
||||
id: 'doc',
|
||||
title: i18n.t('userProfile.helpCenter', { ns: 'common', lng: locale }),
|
||||
@ -41,6 +43,7 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
},
|
||||
|
||||
register(_deps: DocDeps) {
|
||||
const i18n = getI18n()
|
||||
registerCommands({
|
||||
'navigation.doc': async (_args) => {
|
||||
// Get the current language from i18n
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiFeedbackLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Forum command dependency types
|
||||
@ -22,6 +22,7 @@ export const forumCommand: SlashCommandHandler<ForumDeps> = {
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
return [{
|
||||
id: 'forum',
|
||||
title: i18n.t('userProfile.forum', { ns: 'common', lng: locale }),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
@ -14,6 +14,7 @@ const buildLanguageCommands = (query: string): CommandSearchResult[] => {
|
||||
const list = languages.filter(item => item.supported && (
|
||||
!q || item.name.toLowerCase().includes(q) || String(item.value).toLowerCase().includes(q)
|
||||
))
|
||||
const i18n = getI18n()
|
||||
return list.map(item => ({
|
||||
id: `lang-${item.value}`,
|
||||
title: item.name,
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
import type { ActionItem } from '../types'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect } from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { setLocaleOnClient } from '@/i18n-config'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { accountCommand } from './account'
|
||||
import { executeCommand } from './command-bus'
|
||||
import { communityCommand } from './community'
|
||||
@ -14,6 +14,8 @@ import { slashCommandRegistry } from './registry'
|
||||
import { themeCommand } from './theme'
|
||||
import { zenCommand } from './zen'
|
||||
|
||||
const i18n = getI18n()
|
||||
|
||||
export const slashAction: ActionItem = {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
|
||||
@ -2,7 +2,7 @@ import type { CommandSearchResult } from '../types'
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiComputerLine, RiMoonLine, RiSunLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Theme dependency types
|
||||
@ -32,6 +32,7 @@ const THEME_ITEMS = [
|
||||
] as const
|
||||
|
||||
const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => {
|
||||
const i18n = getI18n()
|
||||
const q = query.toLowerCase()
|
||||
const list = THEME_ITEMS.filter(item =>
|
||||
!q
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiFullscreenLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { isInWorkflowPage } from '@/app/components/workflow/constants'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Zen command dependency types - no external dependencies needed
|
||||
@ -32,6 +32,7 @@ export const zenCommand: SlashCommandHandler<ZenDeps> = {
|
||||
execute: toggleZenMode,
|
||||
|
||||
async search(_args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
return [{
|
||||
id: 'zen',
|
||||
title: i18n.t('gotoAnything.actions.zenTitle', { ns: 'app', lng: locale }) || 'Zen Mode',
|
||||
|
||||
@ -15,7 +15,6 @@ import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import List from '@/app/components/plugins/marketplace/list'
|
||||
import ProviderCard from '@/app/components/plugins/provider-card'
|
||||
import { getLocaleOnClient } from '@/i18n-config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import {
|
||||
@ -33,7 +32,6 @@ const InstallFromMarketplace = ({
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const [collapse, setCollapse] = useState(false)
|
||||
const locale = getLocaleOnClient()
|
||||
const {
|
||||
plugins: allPlugins,
|
||||
isLoading: isAllPluginsLoading,
|
||||
@ -70,7 +68,6 @@ const InstallFromMarketplace = ({
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
locale={locale}
|
||||
cardContainerClassName="grid grid-cols-2 gap-2"
|
||||
cardRender={cardRender}
|
||||
emptyClassName="h-auto"
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { setLocaleOnClient } from '@/i18n-config'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
@ -25,6 +25,7 @@ export default function LanguagePage() {
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
|
||||
const handleSelectLanguage = async (item: Item) => {
|
||||
const url = '/account/interface-language'
|
||||
@ -35,7 +36,8 @@ export default function LanguagePage() {
|
||||
await updateUserProfile({ url, body: { [bodyKey]: item.value } })
|
||||
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
|
||||
setLocaleOnClient(item.value.toString() as Locale)
|
||||
setLocaleOnClient(item.value.toString() as Locale, false)
|
||||
router.refresh()
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: (e as Error).message })
|
||||
|
||||
@ -31,14 +31,19 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
|
||||
const ModelProviderPage = ({ searchText }: Props) => {
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
|
||||
const { t } = useTranslation()
|
||||
const { data: textGenerationDefaultModel } = useDefaultModel(ModelTypeEnum.textGeneration)
|
||||
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankDefaultModel } = useDefaultModel(ModelTypeEnum.rerank)
|
||||
const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
|
||||
const { data: ttsDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
|
||||
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
|
||||
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
|
||||
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
|
||||
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
|
||||
const { modelProviders: providers } = useProviderContext()
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const defaultModelNotConfigured = !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
|
||||
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|
||||
|| isEmbeddingsDefaultModelLoading
|
||||
|| isRerankDefaultModelLoading
|
||||
|| isSpeech2textDefaultModelLoading
|
||||
|| isTTSDefaultModelLoading
|
||||
const defaultModelNotConfigured = !isDefaultModelLoading && !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
|
||||
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
|
||||
const configuredProviders: ModelProvider[] = []
|
||||
const notConfiguredProviders: ModelProvider[] = []
|
||||
@ -106,6 +111,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
rerankDefaultModel={rerankDefaultModel}
|
||||
speech2textDefaultModel={speech2textDefaultModel}
|
||||
ttsDefaultModel={ttsDefaultModel}
|
||||
isLoading={isDefaultModelLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -14,7 +14,6 @@ import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import List from '@/app/components/plugins/marketplace/list'
|
||||
import ProviderCard from '@/app/components/plugins/provider-card'
|
||||
import { getLocaleOnClient } from '@/i18n-config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import {
|
||||
@ -32,7 +31,6 @@ const InstallFromMarketplace = ({
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const [collapse, setCollapse] = useState(false)
|
||||
const locale = getLocaleOnClient()
|
||||
const {
|
||||
plugins: allPlugins,
|
||||
isLoading: isAllPluginsLoading,
|
||||
@ -69,7 +67,6 @@ const InstallFromMarketplace = ({
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
locale={locale}
|
||||
cardContainerClassName="grid grid-cols-2 gap-2"
|
||||
cardRender={cardRender}
|
||||
emptyClassName="h-auto"
|
||||
|
||||
@ -3,7 +3,7 @@ import type {
|
||||
DefaultModel,
|
||||
DefaultModelResponse,
|
||||
} from '../declarations'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { RiEqualizer2Line, RiLoader2Line } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
@ -32,6 +32,7 @@ type SystemModelSelectorProps = {
|
||||
speech2textDefaultModel: DefaultModelResponse | undefined
|
||||
ttsDefaultModel: DefaultModelResponse | undefined
|
||||
notConfigured: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
textGenerationDefaultModel,
|
||||
@ -40,6 +41,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
speech2textDefaultModel,
|
||||
ttsDefaultModel,
|
||||
notConfigured,
|
||||
isLoading,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
@ -129,13 +131,16 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
crossAxis: 8,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => setOpen(v => !v)}>
|
||||
<Button
|
||||
className="relative"
|
||||
variant={notConfigured ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RiEqualizer2Line className="mr-1 h-3.5 w-3.5" />
|
||||
{isLoading
|
||||
? <RiLoader2Line className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
: <RiEqualizer2Line className="mr-1 h-3.5 w-3.5" />}
|
||||
{t('modelProvider.systemModelSettings', { ns: 'common' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { ToastProvider } from './base/toast'
|
||||
import I18N from './i18n'
|
||||
|
||||
export type II18NServerProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const I18NServer = async ({
|
||||
children,
|
||||
}: II18NServerProps) => {
|
||||
const locale = await getLocaleOnServer()
|
||||
|
||||
return (
|
||||
<I18N {...{ locale }}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</I18N>
|
||||
)
|
||||
}
|
||||
|
||||
export default I18NServer
|
||||
@ -1,45 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { usePrefetchQuery } from '@tanstack/react-query'
|
||||
import { useHydrateAtoms } from 'jotai/utils'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { localeAtom } from '@/context/i18n'
|
||||
import { setLocaleOnClient } from '@/i18n-config'
|
||||
import { getSystemFeatures } from '@/service/common'
|
||||
import Loading from './base/loading'
|
||||
|
||||
export type II18nProps = {
|
||||
locale: Locale
|
||||
children: React.ReactNode
|
||||
}
|
||||
const I18n: FC<II18nProps> = ({
|
||||
locale,
|
||||
children,
|
||||
}) => {
|
||||
useHydrateAtoms([[localeAtom, locale]])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
usePrefetchQuery({
|
||||
queryKey: ['systemFeatures'],
|
||||
queryFn: getSystemFeatures,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setLocaleOnClient(locale, false).then(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [locale])
|
||||
|
||||
if (loading)
|
||||
return <div className="flex h-screen w-screen items-center justify-center"><Loading type="app" /></div>
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(I18n)
|
||||
@ -1,4 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import { camelCase } from 'es-toolkit/string'
|
||||
import Link from 'next/link'
|
||||
@ -6,14 +7,12 @@ import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMixedTranslation } from '../marketplace/hooks'
|
||||
|
||||
type DeprecationNoticeProps = {
|
||||
status: 'deleted' | 'active'
|
||||
deprecatedReason: string
|
||||
alternativePluginId: string
|
||||
alternativePluginURL: string
|
||||
locale?: string
|
||||
className?: string
|
||||
innerWrapperClassName?: string
|
||||
iconWrapperClassName?: string
|
||||
@ -34,13 +33,12 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
|
||||
deprecatedReason,
|
||||
alternativePluginId,
|
||||
alternativePluginURL,
|
||||
locale,
|
||||
className,
|
||||
innerWrapperClassName,
|
||||
iconWrapperClassName,
|
||||
textClassName,
|
||||
}) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const deprecatedReasonKey = useMemo(() => {
|
||||
if (!deprecatedReason)
|
||||
|
||||
@ -502,31 +502,6 @@ describe('Card', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Locale Tests
|
||||
// ================================
|
||||
describe('Locale', () => {
|
||||
it('should use locale from props when provided', () => {
|
||||
const plugin = createMockPlugin({
|
||||
label: { 'en-US': 'English Title', 'zh-Hans': '中文标题' },
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} locale="zh-Hans" />)
|
||||
|
||||
expect(screen.getByText('中文标题')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to default locale when prop locale not found', () => {
|
||||
const plugin = createMockPlugin({
|
||||
label: { 'en-US': 'English Title' },
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} locale="fr-FR" />)
|
||||
|
||||
expect(screen.getByText('English Title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Memoization Tests
|
||||
// ================================
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
'use client'
|
||||
import type { Plugin } from '../types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import {
|
||||
renderI18nObject,
|
||||
} from '@/i18n-config'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { Theme } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Partner from '../base/badges/partner'
|
||||
@ -33,7 +31,6 @@ export type Props = {
|
||||
footer?: React.ReactNode
|
||||
isLoading?: boolean
|
||||
loadingFileName?: string
|
||||
locale?: Locale
|
||||
limitedInstall?: boolean
|
||||
}
|
||||
|
||||
@ -48,13 +45,11 @@ const Card = ({
|
||||
footer,
|
||||
isLoading = false,
|
||||
loadingFileName,
|
||||
locale: localeFromProps,
|
||||
limitedInstall = false,
|
||||
}: Props) => {
|
||||
const defaultLocale = useGetLanguage()
|
||||
const locale = localeFromProps ? getLanguage(localeFromProps) : defaultLocale
|
||||
const { t } = useMixedTranslation(localeFromProps)
|
||||
const { categoriesMap } = useCategories(t, true)
|
||||
const locale = useGetLanguage()
|
||||
const { t } = useTranslation()
|
||||
const { categoriesMap } = useCategories(true)
|
||||
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload
|
||||
const { theme } = useTheme()
|
||||
const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { CategoryKey, TagKey } from './constants'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -13,9 +12,8 @@ export type Tag = {
|
||||
label: string
|
||||
}
|
||||
|
||||
export const useTags = (translateFromOut?: TFunction) => {
|
||||
const { t: translation } = useTranslation()
|
||||
const t = translateFromOut || translation
|
||||
export const useTags = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const tags = useMemo(() => {
|
||||
return tagKeys.map((tag) => {
|
||||
@ -53,9 +51,8 @@ type Category = {
|
||||
label: string
|
||||
}
|
||||
|
||||
export const useCategories = (translateFromOut?: TFunction, isSingle?: boolean) => {
|
||||
const { t: translation } = useTranslation()
|
||||
const t = translateFromOut || translation
|
||||
export const useCategories = (isSingle?: boolean) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const categories = useMemo(() => {
|
||||
return categoryKeys.map((category) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -7,9 +7,9 @@ import Line from './line'
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
vi.mock('../hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
// Mock i18n translation hook
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
@ -471,36 +471,6 @@ describe('Empty', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Locale Prop Tests
|
||||
// ================================
|
||||
describe('Locale Prop', () => {
|
||||
it('should pass locale to useMixedTranslation', () => {
|
||||
render(<Empty locale="zh-CN" />)
|
||||
|
||||
// Translation should still work
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined locale', () => {
|
||||
render(<Empty locale={undefined} />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle en-US locale', () => {
|
||||
render(<Empty locale="en-US" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ja-JP locale', () => {
|
||||
render(<Empty locale="ja-JP" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Placeholder Cards Layout Tests
|
||||
// ================================
|
||||
@ -634,7 +604,6 @@ describe('Empty', () => {
|
||||
text="Custom message"
|
||||
lightCard
|
||||
className="custom-wrapper"
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -695,12 +664,6 @@ describe('Empty', () => {
|
||||
expect(container.querySelector('.only-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only locale prop', () => {
|
||||
render(<Empty locale="zh-CN" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text with unicode characters', () => {
|
||||
render(<Empty text="没有找到插件 🔍" />)
|
||||
|
||||
@ -813,7 +776,7 @@ describe('Empty and Line Integration', () => {
|
||||
})
|
||||
|
||||
it('should render complete Empty component structure', () => {
|
||||
const { container } = render(<Empty text="Test" lightCard className="test" locale="en-US" />)
|
||||
const { container } = render(<Empty text="Test" lightCard className="test" />)
|
||||
|
||||
// Container
|
||||
expect(container.querySelector('.test')).toBeInTheDocument()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { Group } from '@/app/components/base/icons/src/vender/other'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Line from './line'
|
||||
|
||||
@ -8,16 +8,14 @@ type Props = {
|
||||
text?: string
|
||||
lightCard?: boolean
|
||||
className?: string
|
||||
locale?: string
|
||||
}
|
||||
|
||||
const Empty = ({
|
||||
text,
|
||||
lightCard,
|
||||
className,
|
||||
locale,
|
||||
}: Props) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -18,8 +18,6 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { postMarketplace } from '@/service/base'
|
||||
import { SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
import {
|
||||
@ -218,21 +216,6 @@ export const useMarketplacePlugins = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ! Support zh-Hans, pt-BR, ja-JP and en-US for Marketplace page
|
||||
* ! For other languages, use en-US as fallback
|
||||
*/
|
||||
export const useMixedTranslation = (localeFromOuter?: string) => {
|
||||
let t = useTranslation().t
|
||||
|
||||
if (localeFromOuter)
|
||||
t = i18n.getFixedT(localeFromOuter)
|
||||
|
||||
return {
|
||||
t,
|
||||
}
|
||||
}
|
||||
|
||||
export const useMarketplaceContainerScroll = (
|
||||
callback: () => void,
|
||||
scrollContainerId = 'marketplace-container',
|
||||
|
||||
@ -11,7 +11,6 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
// Note: Import after mocks are set up
|
||||
import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
import { MarketplaceContext, MarketplaceContextProvider, useMarketplaceContext } from './context'
|
||||
import { useMixedTranslation } from './hooks'
|
||||
import PluginTypeSwitch, { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import {
|
||||
@ -602,48 +601,6 @@ describe('utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Hooks Tests
|
||||
// ================================
|
||||
describe('hooks', () => {
|
||||
describe('useMixedTranslation', () => {
|
||||
it('should return translation function', () => {
|
||||
const { result } = renderHook(() => useMixedTranslation())
|
||||
|
||||
expect(result.current.t).toBeDefined()
|
||||
expect(typeof result.current.t).toBe('function')
|
||||
})
|
||||
|
||||
it('should return translation key when no translation found', () => {
|
||||
const { result } = renderHook(() => useMixedTranslation())
|
||||
|
||||
// The global mock returns key with namespace prefix
|
||||
expect(result.current.t('category.all', { ns: 'plugin' })).toBe('plugin.category.all')
|
||||
})
|
||||
|
||||
it('should use locale from outer when provided', () => {
|
||||
const { result } = renderHook(() => useMixedTranslation('zh-Hans'))
|
||||
|
||||
expect(result.current.t).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle different locale values', () => {
|
||||
const locales = ['en-US', 'zh-Hans', 'ja-JP', 'pt-BR']
|
||||
locales.forEach((locale) => {
|
||||
const { result } = renderHook(() => useMixedTranslation(locale))
|
||||
expect(result.current.t).toBeDefined()
|
||||
expect(typeof result.current.t).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should use getFixedT when localeFromOuter is provided', () => {
|
||||
const { result } = renderHook(() => useMixedTranslation('fr-FR'))
|
||||
// The global mock returns key with namespace prefix
|
||||
expect(result.current.t('search', { ns: 'plugin' })).toBe('plugin.search')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// useMarketplaceCollectionsAndPlugins Tests
|
||||
// ================================
|
||||
@ -2088,17 +2045,6 @@ describe('StickySearchAndSwitchWrapper', () => {
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should accept locale prop', () => {
|
||||
render(
|
||||
<MarketplaceContextProvider>
|
||||
<StickySearchAndSwitchWrapper locale="zh-Hans" />
|
||||
</MarketplaceContextProvider>,
|
||||
)
|
||||
|
||||
// Component should render without errors
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept showSearchParams prop', () => {
|
||||
render(
|
||||
<MarketplaceContextProvider>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { MarketplaceCollection, SearchParams } from './types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import { MarketplaceContextProvider } from './context'
|
||||
import Description from './description'
|
||||
@ -9,7 +8,6 @@ import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import { getMarketplaceCollectionsAndPlugins } from './utils'
|
||||
|
||||
type MarketplaceProps = {
|
||||
locale: Locale
|
||||
showInstallButton?: boolean
|
||||
shouldExclude?: boolean
|
||||
searchParams?: SearchParams
|
||||
@ -18,7 +16,6 @@ type MarketplaceProps = {
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
const Marketplace = async ({
|
||||
locale,
|
||||
showInstallButton = true,
|
||||
shouldExclude,
|
||||
searchParams,
|
||||
@ -42,14 +39,12 @@ const Marketplace = async ({
|
||||
scrollContainerId={scrollContainerId}
|
||||
showSearchParams={showSearchParams}
|
||||
>
|
||||
<Description locale={locale} />
|
||||
<Description />
|
||||
<StickySearchAndSwitchWrapper
|
||||
locale={locale}
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
<ListWrapper
|
||||
locale={locale}
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useTheme } from 'next-themes'
|
||||
@ -11,34 +11,30 @@ import Card from '@/app/components/plugins/card'
|
||||
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
|
||||
|
||||
type CardWrapperProps = {
|
||||
plugin: Plugin
|
||||
showInstallButton?: boolean
|
||||
locale?: Locale
|
||||
}
|
||||
const CardWrapperComponent = ({
|
||||
plugin,
|
||||
showInstallButton,
|
||||
locale,
|
||||
}: CardWrapperProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const [isShowInstallFromMarketplace, {
|
||||
setTrue: showInstallFromMarketplace,
|
||||
setFalse: hideInstallFromMarketplace,
|
||||
}] = useBoolean(false)
|
||||
const localeFromLocale = useLocale()
|
||||
const { getTagLabel } = useTags(t)
|
||||
const locale = useLocale()
|
||||
const { getTagLabel } = useTags()
|
||||
|
||||
// Memoize marketplace link params to prevent unnecessary re-renders
|
||||
const marketplaceLinkParams = useMemo(() => ({
|
||||
language: localeFromLocale,
|
||||
language: locale,
|
||||
theme,
|
||||
}), [localeFromLocale, theme])
|
||||
}), [locale, theme])
|
||||
|
||||
// Memoize tag labels to prevent recreating array on every render
|
||||
const tagLabels = useMemo(() =>
|
||||
@ -52,7 +48,6 @@ const CardWrapperComponent = ({
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
locale={locale}
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
@ -99,7 +94,6 @@ const CardWrapperComponent = ({
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
locale={locale}
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { MarketplaceCollection, SearchParamsFromCollection } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
@ -12,9 +11,9 @@ import ListWrapper from './list-wrapper'
|
||||
// Mock External Dependencies Only
|
||||
// ================================
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
vi.mock('../hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
// Mock i18n translation hook
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string, num?: number }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
@ -28,6 +27,7 @@ vi.mock('../hooks', () => ({
|
||||
return translations[fullKey] || key
|
||||
},
|
||||
}),
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
// Mock useMarketplaceContext with controllable values
|
||||
@ -148,15 +148,15 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () =
|
||||
|
||||
// Mock SortDropdown component
|
||||
vi.mock('../sort-dropdown', () => ({
|
||||
default: ({ locale }: { locale: Locale }) => (
|
||||
<div data-testid="sort-dropdown" data-locale={locale}>Sort</div>
|
||||
default: () => (
|
||||
<div data-testid="sort-dropdown">Sort</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Empty component
|
||||
vi.mock('../empty', () => ({
|
||||
default: ({ className, locale }: { className?: string, locale?: string }) => (
|
||||
<div data-testid="empty-component" className={className} data-locale={locale}>
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<div data-testid="empty-component" className={className}>
|
||||
No plugins found
|
||||
</div>
|
||||
),
|
||||
@ -233,7 +233,6 @@ describe('List', () => {
|
||||
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
plugins: undefined,
|
||||
showInstallButton: false,
|
||||
locale: 'en-US' as Locale,
|
||||
cardContainerClassName: '',
|
||||
cardRender: undefined,
|
||||
onMoreClick: undefined,
|
||||
@ -351,18 +350,6 @@ describe('List', () => {
|
||||
expect(screen.getByTestId('empty-component')).toHaveClass('custom-empty-class')
|
||||
})
|
||||
|
||||
it('should pass locale to Empty component', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
plugins={[]}
|
||||
locale={'zh-CN' as Locale}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('empty-component')).toHaveAttribute('data-locale', 'zh-CN')
|
||||
})
|
||||
|
||||
it('should pass showInstallButton to CardWrapper', () => {
|
||||
const plugins = createMockPluginList(1)
|
||||
|
||||
@ -508,7 +495,6 @@ describe('ListWithCollection', () => {
|
||||
marketplaceCollections: [] as MarketplaceCollection[],
|
||||
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
showInstallButton: false,
|
||||
locale: 'en-US' as Locale,
|
||||
cardContainerClassName: '',
|
||||
cardRender: undefined,
|
||||
onMoreClick: undefined,
|
||||
@ -820,7 +806,6 @@ describe('ListWrapper', () => {
|
||||
marketplaceCollections: [] as MarketplaceCollection[],
|
||||
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
showInstallButton: false,
|
||||
locale: 'en-US' as Locale,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -901,14 +886,6 @@ describe('ListWrapper', () => {
|
||||
|
||||
expect(screen.queryByTestId('sort-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass locale to SortDropdown', () => {
|
||||
mockContextValues.plugins = createMockPluginList(1)
|
||||
|
||||
render(<ListWrapper {...defaultProps} locale={'zh-CN' as Locale} />)
|
||||
|
||||
expect(screen.getByTestId('sort-dropdown')).toHaveAttribute('data-locale', 'zh-CN')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
@ -1169,7 +1146,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1188,7 +1164,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1209,7 +1184,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1231,7 +1205,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1252,7 +1225,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1274,7 +1246,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1293,7 +1264,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1310,7 +1280,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1327,7 +1296,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1354,7 +1322,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={false}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1375,7 +1342,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={false}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1390,7 +1356,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1414,7 +1379,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1432,7 +1396,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1450,7 +1413,6 @@ describe('CardWrapper (via List integration)', () => {
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1482,7 +1444,6 @@ describe('Combined Workflows', () => {
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1501,7 +1462,6 @@ describe('Combined Workflows', () => {
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1521,7 +1481,6 @@ describe('Combined Workflows', () => {
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1535,7 +1494,6 @@ describe('Combined Workflows', () => {
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1551,7 +1509,6 @@ describe('Combined Workflows', () => {
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1569,7 +1526,6 @@ describe('Combined Workflows', () => {
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1601,7 +1557,6 @@ describe('Accessibility', () => {
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1625,7 +1580,6 @@ describe('Accessibility', () => {
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1642,7 +1596,6 @@ describe('Accessibility', () => {
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1668,7 +1621,6 @@ describe('Performance', () => {
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
const endTime = performance.now()
|
||||
@ -1689,7 +1641,6 @@ describe('Performance', () => {
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
const endTime = performance.now()
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { Plugin } from '../../types'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Empty from '../empty'
|
||||
import CardWrapper from './card-wrapper'
|
||||
@ -12,7 +11,6 @@ type ListProps = {
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
plugins?: Plugin[]
|
||||
showInstallButton?: boolean
|
||||
locale: Locale
|
||||
cardContainerClassName?: string
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
onMoreClick?: () => void
|
||||
@ -23,7 +21,6 @@ const List = ({
|
||||
marketplaceCollectionPluginsMap,
|
||||
plugins,
|
||||
showInstallButton,
|
||||
locale,
|
||||
cardContainerClassName,
|
||||
cardRender,
|
||||
onMoreClick,
|
||||
@ -37,7 +34,6 @@ const List = ({
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
locale={locale}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
cardRender={cardRender}
|
||||
onMoreClick={onMoreClick}
|
||||
@ -61,7 +57,6 @@ const List = ({
|
||||
key={`${plugin.org}/${plugin.name}`}
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@ -71,7 +66,7 @@ const List = ({
|
||||
}
|
||||
{
|
||||
plugins && !plugins.length && (
|
||||
<Empty className={emptyClassName} locale={locale} />
|
||||
<Empty className={emptyClassName} />
|
||||
)
|
||||
}
|
||||
</>
|
||||
|
||||
@ -3,9 +3,8 @@
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import CardWrapper from './card-wrapper'
|
||||
@ -14,7 +13,6 @@ type ListWithCollectionProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
locale: Locale
|
||||
cardContainerClassName?: string
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
onMoreClick?: (searchParams?: SearchParamsFromCollection) => void
|
||||
@ -23,12 +21,12 @@ const ListWithCollection = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
showInstallButton,
|
||||
locale,
|
||||
cardContainerClassName,
|
||||
cardRender,
|
||||
onMoreClick,
|
||||
}: ListWithCollectionProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -72,7 +70,6 @@ const ListWithCollection = ({
|
||||
key={plugin.plugin_id}
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
import type { Plugin } from '../../types'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
import List from './index'
|
||||
@ -13,15 +12,13 @@ type ListWrapperProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
locale: Locale
|
||||
}
|
||||
const ListWrapper = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
showInstallButton,
|
||||
locale,
|
||||
}: ListWrapperProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const { t } = useTranslation()
|
||||
const plugins = useMarketplaceContext(v => v.plugins)
|
||||
const pluginsTotal = useMarketplaceContext(v => v.pluginsTotal)
|
||||
const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient)
|
||||
@ -55,7 +52,7 @@ const ListWrapper = ({
|
||||
<div className="mb-4 flex items-center pt-3">
|
||||
<div className="title-xl-semi-bold text-text-primary">{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}</div>
|
||||
<div className="mx-3 h-3.5 w-[1px] bg-divider-regular"></div>
|
||||
<SortDropdown locale={locale} />
|
||||
<SortDropdown />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -73,7 +70,6 @@ const ListWrapper = ({
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMapFromClient || marketplaceCollectionPluginsMap}
|
||||
plugins={plugins}
|
||||
showInstallButton={showInstallButton}
|
||||
locale={locale}
|
||||
onMoreClick={handleMoreClick}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import { useTranslation } from '#i18n'
|
||||
import {
|
||||
RiArchive2Line,
|
||||
RiBrain2Line,
|
||||
@ -12,7 +13,6 @@ import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/p
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { useMarketplaceContext } from './context'
|
||||
import { useMixedTranslation } from './hooks'
|
||||
|
||||
export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
all: 'all',
|
||||
@ -25,16 +25,14 @@ export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
bundle: 'bundle',
|
||||
}
|
||||
type PluginTypeSwitchProps = {
|
||||
locale?: string
|
||||
className?: string
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
const PluginTypeSwitch = ({
|
||||
locale,
|
||||
className,
|
||||
showSearchParams,
|
||||
}: PluginTypeSwitchProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const { t } = useTranslation()
|
||||
const activePluginType = useMarketplaceContext(s => s.activePluginType)
|
||||
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
|
||||
|
||||
|
||||
@ -10,9 +10,9 @@ import ToolSelectorTrigger from './trigger/tool-selector'
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
vi.mock('../hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
// Mock i18n translation hook
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
@ -364,13 +364,6 @@ describe('SearchBox', () => {
|
||||
expect(container.querySelector('.custom-input-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass locale to TagsFilter', () => {
|
||||
render(<SearchBox {...defaultProps} locale="zh-CN" />)
|
||||
|
||||
// TagsFilter should be rendered with locale
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty placeholder', () => {
|
||||
render(<SearchBox {...defaultProps} placeholder="" />)
|
||||
|
||||
@ -449,12 +442,6 @@ describe('SearchBoxWrapper', () => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with locale prop', () => {
|
||||
render(<SearchBoxWrapper locale="en-US" />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render in marketplace mode', () => {
|
||||
const { container } = render(<SearchBoxWrapper />)
|
||||
|
||||
@ -500,13 +487,6 @@ describe('SearchBoxWrapper', () => {
|
||||
|
||||
expect(screen.getByPlaceholderText('Search plugins')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass locale to useMixedTranslation', () => {
|
||||
render(<SearchBoxWrapper locale="zh-CN" />)
|
||||
|
||||
// Translation should still work
|
||||
expect(screen.getByPlaceholderText('Search plugins')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -665,12 +645,6 @@ describe('MarketplaceTrigger', () => {
|
||||
})
|
||||
|
||||
describe('Props Variations', () => {
|
||||
it('should handle locale prop', () => {
|
||||
render(<MarketplaceTrigger {...defaultProps} locale="zh-CN" />)
|
||||
|
||||
expect(screen.getByText('All Tags')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty tagsMap', () => {
|
||||
const { container } = render(
|
||||
<MarketplaceTrigger {...defaultProps} tagsMap={{}} tags={[]} />,
|
||||
@ -1251,7 +1225,6 @@ describe('Combined Workflows', () => {
|
||||
supportAddCustomTool
|
||||
onShowAddCustomCollectionModal={vi.fn()}
|
||||
placeholder="Search plugins"
|
||||
locale="en-US"
|
||||
wrapperClassName="custom-wrapper"
|
||||
inputClassName="custom-input"
|
||||
autoFocus={false}
|
||||
|
||||
@ -13,7 +13,6 @@ type SearchBoxProps = {
|
||||
tags: string[]
|
||||
onTagsChange: (tags: string[]) => void
|
||||
placeholder?: string
|
||||
locale?: string
|
||||
supportAddCustomTool?: boolean
|
||||
usedInMarketplace?: boolean
|
||||
onShowAddCustomCollectionModal?: () => void
|
||||
@ -28,7 +27,6 @@ const SearchBox = ({
|
||||
tags,
|
||||
onTagsChange,
|
||||
placeholder = '',
|
||||
locale,
|
||||
usedInMarketplace = false,
|
||||
supportAddCustomTool,
|
||||
onShowAddCustomCollectionModal,
|
||||
@ -49,7 +47,6 @@ const SearchBox = ({
|
||||
tags={tags}
|
||||
onTagsChange={onTagsChange}
|
||||
usedInMarketplace
|
||||
locale={locale}
|
||||
/>
|
||||
<Divider type="vertical" className="mx-1 h-3.5" />
|
||||
<div className="flex grow items-center gap-x-2 p-1">
|
||||
@ -109,7 +106,6 @@ const SearchBox = ({
|
||||
<TagsFilter
|
||||
tags={tags}
|
||||
onTagsChange={onTagsChange}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -1,16 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import { useMixedTranslation } from '../hooks'
|
||||
import SearchBox from './index'
|
||||
|
||||
type SearchBoxWrapperProps = {
|
||||
locale?: string
|
||||
}
|
||||
const SearchBoxWrapper = ({
|
||||
locale,
|
||||
}: SearchBoxWrapperProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const SearchBoxWrapper = () => {
|
||||
const { t } = useTranslation()
|
||||
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
|
||||
const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
|
||||
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
|
||||
@ -24,7 +19,6 @@ const SearchBoxWrapper = ({
|
||||
onSearchChange={handleSearchPluginTextChange}
|
||||
tags={filterPluginTags}
|
||||
onTagsChange={handleFilterPluginTagsChange}
|
||||
locale={locale}
|
||||
placeholder={t('searchPlugins', { ns: 'plugin' })}
|
||||
usedInMarketplace
|
||||
/>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
@ -9,7 +10,6 @@ import {
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import MarketplaceTrigger from './trigger/marketplace'
|
||||
import ToolSelectorTrigger from './trigger/tool-selector'
|
||||
|
||||
@ -17,18 +17,16 @@ type TagsFilterProps = {
|
||||
tags: string[]
|
||||
onTagsChange: (tags: string[]) => void
|
||||
usedInMarketplace?: boolean
|
||||
locale?: string
|
||||
}
|
||||
const TagsFilter = ({
|
||||
tags,
|
||||
onTagsChange,
|
||||
usedInMarketplace = false,
|
||||
locale,
|
||||
}: TagsFilterProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { tags: options, tagsMap } = useTags(t)
|
||||
const { tags: options, tagsMap } = useTags()
|
||||
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
const handleCheck = (id: string) => {
|
||||
if (tags.includes(id))
|
||||
@ -59,7 +57,6 @@ const TagsFilter = ({
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
locale={locale}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import type { Tag } from '../../../hooks'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMixedTranslation } from '../../hooks'
|
||||
|
||||
type MarketplaceTriggerProps = {
|
||||
selectedTagsLength: number
|
||||
open: boolean
|
||||
tags: string[]
|
||||
tagsMap: Record<string, Tag>
|
||||
locale?: string
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
@ -18,10 +17,9 @@ const MarketplaceTrigger = ({
|
||||
open,
|
||||
tags,
|
||||
tagsMap,
|
||||
locale,
|
||||
onTagsChange,
|
||||
}: MarketplaceTriggerProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -8,7 +8,7 @@ import SortDropdown from './index'
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
// Mock i18n translation hook
|
||||
const mockTranslation = vi.fn((key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
@ -22,8 +22,8 @@ const mockTranslation = vi.fn((key: string, options?: { ns?: string }) => {
|
||||
return translations[fullKey] || key
|
||||
})
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mockTranslation,
|
||||
}),
|
||||
}))
|
||||
@ -145,36 +145,6 @@ describe('SortDropdown', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Testing
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should accept locale prop', () => {
|
||||
render(<SortDropdown locale="zh-CN" />)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call useMixedTranslation with provided locale', () => {
|
||||
render(<SortDropdown locale="ja-JP" />)
|
||||
|
||||
// Translation function should be called for labels
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortBy', { ns: 'plugin' })
|
||||
})
|
||||
|
||||
it('should render without locale prop (undefined)', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty string locale', () => {
|
||||
render(<SortDropdown locale="" />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// State Management Tests
|
||||
// ================================
|
||||
@ -618,13 +588,6 @@ describe('SortDropdown', () => {
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.newlyReleased', { ns: 'plugin' })
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.firstReleased', { ns: 'plugin' })
|
||||
})
|
||||
|
||||
it('should pass locale to useMixedTranslation', () => {
|
||||
render(<SortDropdown locale="pt-BR" />)
|
||||
|
||||
// Verify component renders with locale
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import { useTranslation } from '#i18n'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
@ -9,16 +10,10 @@ import {
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
|
||||
type SortDropdownProps = {
|
||||
locale?: string
|
||||
}
|
||||
const SortDropdown = ({
|
||||
locale,
|
||||
}: SortDropdownProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const SortDropdown = () => {
|
||||
const { t } = useTranslation()
|
||||
const options = [
|
||||
{
|
||||
value: 'install_count',
|
||||
|
||||
@ -5,13 +5,11 @@ import PluginTypeSwitch from './plugin-type-switch'
|
||||
import SearchBoxWrapper from './search-box/search-box-wrapper'
|
||||
|
||||
type StickySearchAndSwitchWrapperProps = {
|
||||
locale?: string
|
||||
pluginTypeSwitchClassName?: string
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
|
||||
const StickySearchAndSwitchWrapper = ({
|
||||
locale,
|
||||
pluginTypeSwitchClassName,
|
||||
showSearchParams,
|
||||
}: StickySearchAndSwitchWrapperProps) => {
|
||||
@ -25,9 +23,8 @@ const StickySearchAndSwitchWrapper = ({
|
||||
pluginTypeSwitchClassName,
|
||||
)}
|
||||
>
|
||||
<SearchBoxWrapper locale={locale} />
|
||||
<SearchBoxWrapper />
|
||||
<PluginTypeSwitch
|
||||
locale={locale}
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -44,7 +44,7 @@ const PluginItem: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const { categoriesMap } = useCategories(t, true)
|
||||
const { categoriesMap } = useCategories(true)
|
||||
const currentPluginID = usePluginPageContext(v => v.currentPluginID)
|
||||
const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID)
|
||||
const { refreshPluginList } = useRefreshPluginList()
|
||||
|
||||
21
web/app/components/provider/i18n-server.tsx
Normal file
21
web/app/components/provider/i18n-server.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { getLocaleOnServer, getResources } from '@/i18n-config/server'
|
||||
|
||||
import { I18nClientProvider } from './i18n'
|
||||
|
||||
export async function I18nServerProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const locale = await getLocaleOnServer()
|
||||
const resource = await getResources(locale)
|
||||
|
||||
return (
|
||||
<I18nClientProvider
|
||||
locale={locale}
|
||||
resource={resource}
|
||||
>
|
||||
{children}
|
||||
</I18nClientProvider>
|
||||
)
|
||||
}
|
||||
24
web/app/components/provider/i18n.tsx
Normal file
24
web/app/components/provider/i18n.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import type { Resource } from 'i18next'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { createI18nextInstance } from '@/i18n-config/client'
|
||||
|
||||
export function I18nClientProvider({
|
||||
locale,
|
||||
resource,
|
||||
children,
|
||||
}: {
|
||||
locale: Locale
|
||||
resource: Resource
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const i18n = createI18nextInstance(locale, resource)
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{children}
|
||||
</I18nextProvider>
|
||||
)
|
||||
}
|
||||
@ -32,7 +32,7 @@ import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useAppFavicon } from '@/hooks/use-app-favicon'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { changeLanguage } from '@/i18n-config/i18next-config'
|
||||
import { changeLanguage } from '@/i18n-config/client'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
|
||||
@ -19,7 +19,6 @@ vi.mock('@/app/components/plugins/marketplace/list', () => ({
|
||||
marketplaceCollectionPluginsMap: Record<string, unknown[]>
|
||||
plugins?: unknown[]
|
||||
showInstallButton?: boolean
|
||||
locale: string
|
||||
}) => {
|
||||
listRenderSpy(props)
|
||||
return <div data-testid="marketplace-list" />
|
||||
@ -42,10 +41,6 @@ vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: vi.fn(() => 'https://marketplace.test/market'),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
getLocaleOnClient: () => 'en',
|
||||
}))
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
@ -148,7 +143,6 @@ describe('Marketplace', () => {
|
||||
expect(screen.getByTestId('marketplace-list')).toBeInTheDocument()
|
||||
expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
showInstallButton: true,
|
||||
locale: 'en',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { useMarketplace } from './hooks'
|
||||
import { useLocale } from '#i18n'
|
||||
import {
|
||||
RiArrowRightUpLine,
|
||||
RiArrowUpDoubleLine,
|
||||
@ -7,7 +8,6 @@ import { useTheme } from 'next-themes'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import List from '@/app/components/plugins/marketplace/list'
|
||||
import { getLocaleOnClient } from '@/i18n-config'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type MarketplaceProps = {
|
||||
@ -24,7 +24,7 @@ const Marketplace = ({
|
||||
showMarketplacePanel,
|
||||
marketplaceContext,
|
||||
}: MarketplaceProps) => {
|
||||
const locale = getLocaleOnClient()
|
||||
const locale = useLocale()
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const {
|
||||
@ -104,7 +104,6 @@ const Marketplace = ({
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
|
||||
plugins={plugins}
|
||||
showInstallButton
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user