Merge remote-tracking branch 'myori/main' into feat/collaboration2

This commit is contained in:
hjlarry 2026-01-18 10:28:50 +08:00
commit 682c93f262
23 changed files with 5294 additions and 135 deletions

View File

@ -106,8 +106,9 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web working-directory: ./web
run: | run: |
pnpm run lint:report pnpm run lint:ci
continue-on-error: true # pnpm run lint:report
# continue-on-error: true
# - name: Annotate Code # - name: Annotate Code
# if: steps.changed-files.outputs.any_changed == 'true' && github.event_name == 'pull_request' # if: steps.changed-files.outputs.any_changed == 'true' && github.event_name == 'pull_request'
@ -126,11 +127,6 @@ jobs:
working-directory: ./web working-directory: ./web
run: pnpm run knip run: pnpm run knip
- name: Web build check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run build
superlinter: superlinter:
name: SuperLinter name: SuperLinter
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -366,3 +366,48 @@ jobs:
path: web/coverage path: web/coverage
retention-days: 30 retention-days: 30
if-no-files-found: error if-no-files-found: error
web-build:
name: Web Build
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./web
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Check changed files
id: changed-files
uses: tj-actions/changed-files@v47
with:
files: |
web/**
.github/workflows/web-tests.yml
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: web/package.json
run_install: false
- name: Setup NodeJS
uses: actions/setup-node@v6
if: steps.changed-files.outputs.any_changed == 'true'
with:
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Web dependencies
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm install --frozen-lockfile
- name: Web build check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run build

56
web/__mocks__/zustand.ts Normal file
View File

@ -0,0 +1,56 @@
import type * as ZustandExportedTypes from 'zustand'
import { act } from '@testing-library/react'
export * from 'zustand'
const { create: actualCreate, createStore: actualCreateStore }
// eslint-disable-next-line antfu/no-top-level-await
= await vi.importActual<typeof ZustandExportedTypes>('zustand')
export const storeResetFns = new Set<() => void>()
const createUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
export const create = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
return typeof stateCreator === 'function'
? createUncurried(stateCreator)
: createUncurried
}) as typeof ZustandExportedTypes.create
const createStoreUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
const store = actualCreateStore(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
export const createStore = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried
}) as typeof ZustandExportedTypes.createStore
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn()
})
})
})

View File

@ -3,9 +3,7 @@ import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import useAccessControlStore from '@/context/access-control-store' import useAccessControlStore from '@/context/access-control-store'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode, SubjectType } from '@/models/access-control' import { AccessMode, SubjectType } from '@/models/access-control'
import { defaultSystemFeatures } from '@/types/feature'
import Toast from '../../base/toast' import Toast from '../../base/toast'
import AccessControlDialog from './access-control-dialog' import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item' import AccessControlItem from './access-control-item'
@ -105,22 +103,6 @@ const memberSubject: Subject = {
accountData: baseMember, accountData: baseMember,
} as Subject } as Subject
const resetAccessControlStore = () => {
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
}
const resetGlobalStore = () => {
useGlobalPublicStore.setState({
systemFeatures: defaultSystemFeatures,
})
}
beforeAll(() => { beforeAll(() => {
class MockIntersectionObserver { class MockIntersectionObserver {
observe = vi.fn(() => undefined) observe = vi.fn(() => undefined)
@ -132,9 +114,6 @@ beforeAll(() => {
}) })
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks()
resetAccessControlStore()
resetGlobalStore()
mockMutateAsync.mockResolvedValue(undefined) mockMutateAsync.mockResolvedValue(undefined)
mockUseUpdateAccessMode.mockReturnValue({ mockUseUpdateAccessMode.mockReturnValue({
isPending: false, isPending: false,

View File

@ -189,6 +189,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
} }
</div> </div>
))} ))}
{isFetchingNextPage && <Loading />}
</div> </div>
</> </>
)} )}

View File

@ -21,15 +21,15 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps)
> >
<SkeletonContainer className="h-full"> <SkeletonContainer className="h-full">
<SkeletonRow> <SkeletonRow>
<SkeletonRectangle className="h-10 w-10 rounded-lg" /> <SkeletonRectangle className="h-10 w-10 animate-pulse rounded-lg" />
<div className="flex flex-1 flex-col gap-1"> <div className="flex flex-1 flex-col gap-1">
<SkeletonRectangle className="h-4 w-2/3" /> <SkeletonRectangle className="h-4 w-2/3 animate-pulse" />
<SkeletonRectangle className="h-3 w-1/3" /> <SkeletonRectangle className="h-3 w-1/3 animate-pulse" />
</div> </div>
</SkeletonRow> </SkeletonRow>
<div className="mt-4 flex flex-col gap-2"> <div className="mt-4 flex flex-col gap-2">
<SkeletonRectangle className="h-3 w-full" /> <SkeletonRectangle className="h-3 w-full animate-pulse" />
<SkeletonRectangle className="h-3 w-4/5" /> <SkeletonRectangle className="h-3 w-4/5 animate-pulse" />
</div> </div>
</SkeletonContainer> </SkeletonContainer>
</div> </div>

View File

@ -281,6 +281,9 @@ const List = () => {
// No apps - show empty state // No apps - show empty state
return <Empty /> return <Empty />
})()} })()}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div> </div>
{isCurrentWorkspaceEditor && ( {isCurrentWorkspaceEditor && (

View File

@ -1,21 +1,25 @@
'use client' 'use client'
import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import './style.css' import './style.css'
type ILoadingProps = { type ILoadingProps = {
type?: 'area' | 'app' type?: 'area' | 'app'
className?: string
} }
const Loading = (
{ type = 'area' }: ILoadingProps = { type: 'area' }, const Loading = (props?: ILoadingProps) => {
) => { const { type = 'area', className } = props || {}
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div <div
className={`flex w-full items-center justify-center ${type === 'app' ? 'h-full' : ''}`} className={cn(
'flex w-full items-center justify-center',
type === 'app' && 'h-full',
className,
)}
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-label={t('loading', { ns: 'appApi' })} aria-label={t('loading', { ns: 'appApi' })}
@ -37,4 +41,5 @@ const Loading = (
</div> </div>
) )
} }
export default Loading export default Loading

View File

@ -2,6 +2,7 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetList, useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { useDatasetList, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import DatasetCard from './dataset-card' import DatasetCard from './dataset-card'
@ -25,6 +26,7 @@ const Datasets = ({
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetching, isFetching,
isFetchingNextPage,
} = useDatasetList({ } = useDatasetList({
initialPage: 1, initialPage: 1,
tag_ids: tags, tag_ids: tags,
@ -60,6 +62,7 @@ const Datasets = ({
{datasetList?.pages.map(({ data: datasets }) => datasets.map(dataset => ( {datasetList?.pages.map(({ data: datasets }) => datasets.map(dataset => (
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} />), <DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} />),
))} ))}
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" /> <div ref={anchorRef} className="h-0" />
</nav> </nav>
</> </>

View File

@ -33,6 +33,7 @@ const AppNav = () => {
data: appsData, data: appsData,
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage,
refetch, refetch,
} = useInfiniteAppList({ } = useInfiniteAppList({
page: 1, page: 1,
@ -111,6 +112,7 @@ const AppNav = () => {
createText={t('menus.newApp', { ns: 'common' })} createText={t('menus.newApp', { ns: 'common' })}
onCreate={openModal} onCreate={openModal}
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
isLoadingMore={isFetchingNextPage}
/> />
<CreateAppModal <CreateAppModal
show={showNewAppDialog} show={showNewAppDialog}

View File

@ -23,6 +23,7 @@ const DatasetNav = () => {
data: datasetList, data: datasetList,
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage,
} = useDatasetList({ } = useDatasetList({
initialPage: 1, initialPage: 1,
limit: 30, limit: 30,
@ -93,6 +94,7 @@ const DatasetNav = () => {
createText={t('menus.newDataset', { ns: 'common' })} createText={t('menus.newDataset', { ns: 'common' })}
onCreate={() => router.push(createRoute)} onCreate={() => router.push(createRoute)}
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
isLoadingMore={isFetchingNextPage}
/> />
) )
} }

View File

@ -30,6 +30,7 @@ const Nav = ({
createText, createText,
onCreate, onCreate,
onLoadMore, onLoadMore,
isLoadingMore,
isApp, isApp,
}: INavProps) => { }: INavProps) => {
const setAppDetail = useAppStore(state => state.setAppDetail) const setAppDetail = useAppStore(state => state.setAppDetail)
@ -81,6 +82,7 @@ const Nav = ({
createText={createText} createText={createText}
onCreate={onCreate} onCreate={onCreate}
onLoadMore={onLoadMore} onLoadMore={onLoadMore}
isLoadingMore={isLoadingMore}
/> />
</> </>
) )

View File

@ -14,6 +14,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { AppTypeIcon } from '@/app/components/app/type-selector' import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@ -34,9 +35,10 @@ export type INavSelectorProps = {
isApp?: boolean isApp?: boolean
onCreate: (state: string) => void onCreate: (state: string) => void
onLoadMore?: () => void onLoadMore?: () => void
isLoadingMore?: boolean
} }
const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onLoadMore }: INavSelectorProps) => { const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onLoadMore, isLoadingMore }: INavSelectorProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const { isCurrentWorkspaceEditor } = useAppContext() const { isCurrentWorkspaceEditor } = useAppContext()
@ -106,6 +108,11 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
</MenuItem> </MenuItem>
)) ))
} }
{isLoadingMore && (
<div className="flex justify-center py-2">
<Loading />
</div>
)}
</div> </div>
{!isApp && isCurrentWorkspaceEditor && ( {!isApp && isCurrentWorkspaceEditor && (
<MenuItem as="div" className="w-full p-1"> <MenuItem as="div" className="w-full p-1">

View File

@ -19,6 +19,7 @@ const ListWrapper = ({
marketplaceCollections, marketplaceCollections,
marketplaceCollectionPluginsMap, marketplaceCollectionPluginsMap,
isLoading, isLoading,
isFetchingNextPage,
page, page,
} = useMarketplaceData() } = useMarketplaceData()
@ -53,6 +54,11 @@ const ListWrapper = ({
/> />
) )
} }
{
isFetchingNextPage && (
<Loading className="my-3" />
)
}
</div> </div>
) )
} }

View File

@ -33,7 +33,7 @@ export function useMarketplaceData() {
}, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort])
const pluginsQuery = useMarketplacePlugins(queryParams) const pluginsQuery = useMarketplacePlugins(queryParams)
const { hasNextPage, fetchNextPage, isFetching } = pluginsQuery const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery
const handlePageChange = useCallback(() => { const handlePageChange = useCallback(() => {
if (hasNextPage && !isFetching) if (hasNextPage && !isFetching)
@ -50,5 +50,6 @@ export function useMarketplaceData() {
pluginsTotal: pluginsQuery.data?.pages[0]?.total, pluginsTotal: pluginsQuery.data?.pages[0]?.total,
page: pluginsQuery.data?.pages.length || 1, page: pluginsQuery.data?.pages.length || 1,
isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading,
isFetchingNextPage,
} }
} }

View File

@ -144,17 +144,6 @@ describe('constant.ts - Type Definitions', () => {
// ==================== store.ts Tests ==================== // ==================== store.ts Tests ====================
describe('store.ts - Zustand Store', () => { describe('store.ts - Zustand Store', () => {
beforeEach(() => {
// Reset store to initial state
const { setState } = useStore
setState({
tagList: [],
categoryList: [],
showTagManagementModal: false,
showCategoryManagementModal: false,
})
})
describe('Initial State', () => { describe('Initial State', () => {
it('should have empty tagList initially', () => { it('should have empty tagList initially', () => {
const { result } = renderHook(() => useStore(state => state.tagList)) const { result } = renderHook(() => useStore(state => state.tagList))

View File

@ -5,11 +5,11 @@ import { useDebounceFn } from 'ahooks'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { useGetLanguage } from '@/context/i18n' import { useGetLanguage } from '@/context/i18n'
import { renderI18nObject } from '@/i18n-config' import { renderI18nObject } from '@/i18n-config'
import { useInstalledLatestVersion, useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useInstalledLatestVersion, useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import Loading from '../../base/loading'
import { PluginSource } from '../types' import { PluginSource } from '../types'
import { usePluginPageContext } from './context' import { usePluginPageContext } from './context'
import Empty from './empty' import Empty from './empty'
@ -107,12 +107,17 @@ const PluginsPanel = () => {
<div className="w-full"> <div className="w-full">
<List pluginList={filteredList || []} /> <List pluginList={filteredList || []} />
</div> </div>
{!isLastPage && !isFetching && ( {!isLastPage && (
<Button onClick={loadNextPage}> <div className="flex justify-center py-4">
{t('common.loadMore', { ns: 'workflow' })} {isFetching
</Button> ? <Loading className="size-8" />
: (
<Button onClick={loadNextPage}>
{t('common.loadMore', { ns: 'workflow' })}
</Button>
)}
</div>
)} )}
{isFetching && <div className="system-md-semibold text-text-secondary">{t('detail.loading', { ns: 'appLog' })}</div>}
</div> </div>
) )
: ( : (

View File

@ -134,13 +134,6 @@ describe('BUILTIN_TOOLS_ARRAY', () => {
// Store Tests // Store Tests
// ================================ // ================================
describe('useReadmePanelStore', () => { describe('useReadmePanelStore', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset store state before each test
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail()
})
describe('Initial State', () => { describe('Initial State', () => {
it('should have undefined currentPluginDetail initially', () => { it('should have undefined currentPluginDetail initially', () => {
const { currentPluginDetail } = useReadmePanelStore.getState() const { currentPluginDetail } = useReadmePanelStore.getState()
@ -228,13 +221,6 @@ describe('useReadmePanelStore', () => {
// ReadmeEntrance Component Tests // ReadmeEntrance Component Tests
// ================================ // ================================
describe('ReadmeEntrance', () => { describe('ReadmeEntrance', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset store state
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail()
})
// ================================ // ================================
// Rendering Tests // Rendering Tests
// ================================ // ================================
@ -417,11 +403,6 @@ describe('ReadmeEntrance', () => {
// ================================ // ================================
describe('ReadmePanel', () => { describe('ReadmePanel', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks()
// Reset store state
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail()
// Reset mock
mockUsePluginReadme.mockReturnValue({ mockUsePluginReadme.mockReturnValue({
data: null, data: null,
isLoading: false, isLoading: false,

5107
web/eslint-suppressions.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,30 +9,15 @@ import difyI18n from './eslint-rules/index.js'
export default antfu( export default antfu(
{ {
react: { react: {
reactCompiler: true,
overrides: { overrides: {
'react/no-context-provider': 'off', 'react/no-context-provider': 'off',
'react/no-forward-ref': 'off', 'react/no-forward-ref': 'off',
'react/no-use-context': 'off', 'react/no-use-context': 'off',
'react/prefer-namespace-import': 'error',
// React Compiler rules
// Set to warn for gradual adoption
'react-hooks/config': 'warn',
'react-hooks/error-boundaries': 'warn',
'react-hooks/component-hook-factories': 'warn',
'react-hooks/gating': 'warn',
'react-hooks/globals': 'warn',
'react-hooks/immutability': 'warn',
'react-hooks/preserve-manual-memoization': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/refs': 'warn',
// prefer react-hooks-extra/no-direct-set-state-in-use-effect // prefer react-hooks-extra/no-direct-set-state-in-use-effect
'react-hooks/set-state-in-effect': 'off', 'react-hooks/set-state-in-effect': 'off',
'react-hooks/set-state-in-render': 'warn', 'react-hooks-extra/no-direct-set-state-in-use-effect': 'error',
'react-hooks/static-components': 'warn',
'react-hooks/unsupported-syntax': 'warn',
'react-hooks/use-memo': 'warn',
'react-hooks/incompatible-library': 'warn',
}, },
}, },
nextjs: true, nextjs: true,
@ -40,7 +25,7 @@ export default antfu(
typescript: { typescript: {
overrides: { overrides: {
'ts/consistent-type-definitions': ['error', 'type'], 'ts/consistent-type-definitions': ['error', 'type'],
'ts/no-explicit-any': 'warn', 'ts/no-explicit-any': 'error',
}, },
}, },
test: { test: {
@ -54,6 +39,11 @@ export default antfu(
}, },
}, },
}, },
{
rules: {
'node/prefer-global/process': 'off',
},
},
{ {
files: ['**/*.ts', '**/*.tsx'], files: ['**/*.ts', '**/*.tsx'],
settings: { settings: {
@ -62,32 +52,6 @@ export default antfu(
}, },
}, },
}, },
// downgrade some rules from error to warn for gradual adoption
// we should fix these in following pull requests
{
// @keep-sorted
rules: {
'next/inline-script-id': 'warn',
'no-console': 'warn',
'no-irregular-whitespace': 'warn',
'node/prefer-global/buffer': 'warn',
'node/prefer-global/process': 'warn',
'react/no-create-ref': 'warn',
'react/no-missing-key': 'warn',
'react/no-nested-component-definitions': 'warn',
'regexp/no-dupe-disjunctions': 'warn',
'regexp/no-super-linear-backtracking': 'warn',
'regexp/no-unused-capturing-group': 'warn',
'regexp/no-useless-assertions': 'warn',
'regexp/no-useless-quantifier': 'warn',
'style/multiline-ternary': 'warn',
'test/no-identical-title': 'warn',
'test/prefer-hooks-in-order': 'warn',
'ts/no-empty-object-type': 'warn',
'unicorn/prefer-number-properties': 'warn',
'unused-imports/no-unused-vars': 'warn',
},
},
storybook.configs['flat/recommended'], storybook.configs['flat/recommended'],
...pluginQuery.configs['flat/recommended'], ...pluginQuery.configs['flat/recommended'],
// sonar // sonar
@ -178,19 +142,19 @@ export default antfu(
}, },
}, },
// dify i18n namespace migration // dify i18n namespace migration
{ // {
files: ['**/*.ts', '**/*.tsx'], // files: ['**/*.ts', '**/*.tsx'],
ignores: ['eslint-rules/**', 'i18n/**', 'i18n-config/**'], // ignores: ['eslint-rules/**', 'i18n/**', 'i18n-config/**'],
plugins: { // plugins: {
'dify-i18n': difyI18n, // 'dify-i18n': difyI18n,
}, // },
rules: { // rules: {
// 'dify-i18n/no-as-any-in-t': ['error', { mode: 'all' }], // // 'dify-i18n/no-as-any-in-t': ['error', { mode: 'all' }],
'dify-i18n/no-as-any-in-t': 'error', // 'dify-i18n/no-as-any-in-t': 'error',
// 'dify-i18n/no-legacy-namespace-prefix': 'error', // // 'dify-i18n/no-legacy-namespace-prefix': 'error',
// 'dify-i18n/require-ns-option': 'error', // // 'dify-i18n/require-ns-option': 'error',
}, // },
}, // },
// i18n JSON validation rules // i18n JSON validation rules
{ {
files: ['i18n/**/*.json'], files: ['i18n/**/*.json'],

View File

@ -28,6 +28,7 @@
"build:docker": "next build && node scripts/optimize-standalone.js", "build:docker": "next build && node scripts/optimize-standalone.js",
"start": "node ./scripts/copy-and-start.mjs", "start": "node ./scripts/copy-and-start.mjs",
"lint": "eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache", "lint": "eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache",
"lint:ci": "pnpm lint --concurrency 3",
"lint:fix": "pnpm lint --fix", "lint:fix": "pnpm lint --fix",
"lint:quiet": "pnpm lint --quiet", "lint:quiet": "pnpm lint --quiet",
"lint:complexity": "pnpm lint --rule 'complexity: [error, {max: 15}]' --quiet", "lint:complexity": "pnpm lint --rule 'complexity: [error, {max: 15}]' --quiet",

View File

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"incremental": true, "incremental": true,
"target": "es2015", "target": "es2022",
"jsx": "preserve", "jsx": "preserve",
"lib": [ "lib": [
"dom", "dom",

View File

@ -85,6 +85,10 @@ afterEach(() => {
// mock next/image to avoid width/height requirements for data URLs // mock next/image to avoid width/height requirements for data URLs
vi.mock('next/image') vi.mock('next/image')
// mock zustand - auto-resets all stores after each test
// Based on official Zustand testing guide: https://zustand.docs.pmnd.rs/guides/testing
vi.mock('zustand')
// mock react-i18next // mock react-i18next
vi.mock('react-i18next', async () => { vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next') const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')