mirror of
https://github.com/langgenius/dify.git
synced 2026-08-29 03:45:08 +08:00
fix: add semantic document titles across pages (#40396)
This commit is contained in:
@@ -7,4 +7,4 @@ Feature: Create app
|
||||
And I enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the app editor
|
||||
And I should see the "Orchestrate" text
|
||||
And I should see the "Orchestrate" link
|
||||
|
||||
@@ -36,6 +36,8 @@ Then('I should not see the {string} button', async function (this: DifyWorld, la
|
||||
await expect(this.getPage().getByRole('button', { name: label })).not.toBeVisible()
|
||||
})
|
||||
|
||||
Then('I should see the {string} text', async function (this: DifyWorld, text: string) {
|
||||
await expect(this.getPage().getByText(text)).toBeVisible({ timeout: 30_000 })
|
||||
Then('I should see the {string} link', async function (this: DifyWorld, label: string) {
|
||||
await expect(this.getPage().getByRole('link', { exact: true, name: label })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
|
||||
+1
-1
@@ -777,7 +777,7 @@ export const lintConfig = {
|
||||
'error',
|
||||
{
|
||||
allowConstantExport: true,
|
||||
allowExportNames: ['viewport'],
|
||||
allowExportNames: ['generateMetadata', 'viewport'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
import RosterPage from '@/features/agent-v2/roster/page'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('agentV2', ($) => $['roster.title'])
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <RosterPage />
|
||||
}
|
||||
|
||||
+55
-5
@@ -1,5 +1,5 @@
|
||||
import type { App } from '@/types/app'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
@@ -44,10 +44,6 @@ vi.mock('@/context/permission-state', async () => {
|
||||
return createPermissionStateModuleMock(() => mockConsoleState)
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUsePathname = mockNavigation.usePathname
|
||||
const mockUseRouter = mockNavigation.useRouter
|
||||
const mockFetchAppDetailDirect = vi.mocked(fetchAppDetailDirect)
|
||||
@@ -70,6 +66,7 @@ const waitForAppContent = async () => {
|
||||
describe('AppDetailLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.title = ''
|
||||
mockPathname = '/app/app-1/workflow'
|
||||
mockIsRbacEnabled = true
|
||||
mockConsoleState.currentWorkspace = { id: 'workspace-1' }
|
||||
@@ -85,6 +82,59 @@ describe('AppDetailLayout', () => {
|
||||
useStore.getState().setAppDetail()
|
||||
})
|
||||
|
||||
describe('Document title', () => {
|
||||
it.each([
|
||||
['/app/app-1/workflow', 'common.appMenus.promptEng', AppModeEnum.WORKFLOW],
|
||||
['/app/app-1/configuration', 'common.appMenus.promptEng', AppModeEnum.CHAT],
|
||||
['/app/app-1/access-point', 'common.appMenus.accessPoint', AppModeEnum.WORKFLOW],
|
||||
['/app/app-1/develop', 'common.appMenus.apiAccess', AppModeEnum.WORKFLOW],
|
||||
['/app/app-1/deploy', 'common.appMenus.deploy', AppModeEnum.WORKFLOW],
|
||||
['/app/app-1/logs', 'common.appMenus.logs', AppModeEnum.WORKFLOW],
|
||||
['/app/app-1/annotations', 'common.appMenus.annotations', AppModeEnum.CHAT],
|
||||
['/app/app-1/overview', 'common.appMenus.overview', AppModeEnum.WORKFLOW],
|
||||
['/app/app-1/access-config', 'common.settings.resourceAccess', AppModeEnum.WORKFLOW],
|
||||
])('identifies the current detail page for %s', async (pathname, pageTitle, mode) => {
|
||||
mockPathname = pathname
|
||||
mockFetchAppDetailDirect.mockResolvedValue(
|
||||
createAppDetail({
|
||||
mode,
|
||||
permission_keys: Object.values(AppACLPermission),
|
||||
}),
|
||||
)
|
||||
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
<div>App page content</div>
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe(`${pageTitle} · Demo App - Dify`)
|
||||
})
|
||||
})
|
||||
|
||||
it('updates after a directly loaded app is renamed in the store', async () => {
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
<div>App page content</div>
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe('common.appMenus.promptEng · Demo App - Dify')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
useStore.getState().setAppDetail(createAppDetail({ name: 'Renamed App' }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe('common.appMenus.promptEng · Renamed App - Dify')
|
||||
})
|
||||
expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep app detail data when navigating between pages in the same app', async () => {
|
||||
const { rerender, unmount } = render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
|
||||
@@ -32,6 +32,23 @@ type IAppDetailLayoutProps = {
|
||||
const isNotFoundError = (error: unknown) =>
|
||||
typeof error === 'object' && error !== null && 'status' in error && error.status === 404
|
||||
|
||||
const appDetailPageTitle = (pathname: string, t: ReturnType<typeof useTranslation>['t']) => {
|
||||
if (pathname.endsWith('/workflow') || pathname.endsWith('/configuration'))
|
||||
return t(($) => $['appMenus.promptEng'], { ns: 'common' })
|
||||
if (pathname.endsWith('/access-point'))
|
||||
return t(($) => $['appMenus.accessPoint'], { ns: 'common' })
|
||||
if (pathname.endsWith('/develop')) return t(($) => $['appMenus.apiAccess'], { ns: 'common' })
|
||||
if (pathname.endsWith('/deploy')) return t(($) => $['appMenus.deploy'], { ns: 'common' })
|
||||
if (pathname.endsWith('/logs')) return t(($) => $['appMenus.logs'], { ns: 'common' })
|
||||
if (pathname.endsWith('/annotations'))
|
||||
return t(($) => $['appMenus.annotations'], { ns: 'common' })
|
||||
if (pathname.endsWith('/overview')) return t(($) => $['appMenus.overview'], { ns: 'common' })
|
||||
if (pathname.endsWith('/access-config'))
|
||||
return t(($) => $['settings.resourceAccess'], { ns: 'common' })
|
||||
|
||||
return t(($) => $['menus.appDetail'], { ns: 'common' })
|
||||
}
|
||||
|
||||
const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
@@ -58,9 +75,12 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
)
|
||||
const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false)
|
||||
const [appDetailRes, setAppDetailRes] = useState<App | null>(null)
|
||||
const routeAppDetail = appDetailRes ?? (appDetail?.id === appId ? appDetail : null)
|
||||
const routeAppDetail =
|
||||
appDetail?.id === appId ? appDetail : appDetailRes?.id === appId ? appDetailRes : null
|
||||
const pageTitle = appDetailPageTitle(pathname, t)
|
||||
const appName = routeAppDetail?.id === appId ? routeAppDetail.name : undefined
|
||||
|
||||
useDocumentTitle(appDetail?.name || t(($) => $['menus.appDetail'], { ns: 'common' }))
|
||||
useDocumentTitle(`${pageTitle} · ${appName || t(($) => $['menus.appDetail'], { ns: 'common' })}`)
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type IAppDetail = {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const AppDetail: FC<IAppDetail> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t(($) => $['menus.appDetail'], { ns: 'common' }))
|
||||
const AppDetail = ({ children }: IAppDetail) => children
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default React.memo(AppDetail)
|
||||
export default AppDetail
|
||||
|
||||
+125
-4
@@ -2,6 +2,7 @@ import { screen, waitFor } from '@testing-library/react'
|
||||
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { DatasetACLPermission } from '@/utils/permission'
|
||||
import CreateDocumentsPage from '../documents/create/page'
|
||||
import DatasetDetailLayout from '../layout-main'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
@@ -24,6 +25,39 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('nuqs')>()),
|
||||
useQueryState: () => [null, vi.fn()],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useDefaultModel: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-datasource', () => ({
|
||||
useGetDefaultDataSourceListAuth: () => ({
|
||||
data: { result: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/create/step-one', () => ({
|
||||
default: () => <div>Create knowledge content</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/create/step-two', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/create/step-three', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/create/top-bar', () => ({
|
||||
TopBar: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
@@ -42,10 +76,6 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseRouter = mockNavigation.useRouter
|
||||
const mockUsePathname = mockNavigation.usePathname
|
||||
const mockUseDatasetDetail = vi.mocked(useDatasetDetail)
|
||||
@@ -53,6 +83,7 @@ const mockUseDatasetDetail = vi.mocked(useDatasetDetail)
|
||||
describe('DatasetDetailLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.title = ''
|
||||
mockIsRbacEnabled = true
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents')
|
||||
mockUseRouter.mockReturnValue({
|
||||
@@ -60,6 +91,96 @@ describe('DatasetDetailLayout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document title', () => {
|
||||
it.each([
|
||||
['/datasets/dataset-1/documents', 'common.datasetMenus.documents'],
|
||||
['/datasets/dataset-1/documents/create', 'datasetPipeline.addDocuments.title'],
|
||||
['/datasets/dataset-1/documents/create-from-pipeline', 'datasetPipeline.addDocuments.title'],
|
||||
['/datasets/dataset-1/pipeline', 'common.datasetMenus.pipeline'],
|
||||
['/datasets/dataset-1/hitTesting', 'common.datasetMenus.hitTesting'],
|
||||
['/datasets/dataset-1/settings', 'common.datasetMenus.settings'],
|
||||
['/datasets/dataset-1/access-config', 'common.settings.resourceAccess'],
|
||||
['/datasets/dataset-1/api', 'common.appMenus.apiAccess'],
|
||||
])('identifies the current detail page for %s', async (pathname, pageTitle) => {
|
||||
mockUsePathname.mockReturnValue(pathname)
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
permission_keys: Object.values(DatasetACLPermission),
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Knowledge page content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe(`${pageTitle} · Dataset 1 - Dify`)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/datasets/dataset-1/documents/document-1',
|
||||
'/datasets/dataset-1/documents/document-1/settings',
|
||||
])('delegates the document title for %s to the document page', (pathname) => {
|
||||
mockUsePathname.mockReturnValue(pathname)
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
permission_keys: Object.values(DatasetACLPermission),
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Document page content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
expect(document.title).toBe('')
|
||||
})
|
||||
|
||||
it('keeps the dataset title when the document creation route is composed', async () => {
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents/create')
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
permission_keys: Object.values(DatasetACLPermission),
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
const page = await CreateDocumentsPage({
|
||||
params: Promise.resolve({ datasetId: 'dataset-1' }),
|
||||
})
|
||||
|
||||
render(<DatasetDetailLayout datasetId="dataset-1">{page}</DatasetDetailLayout>)
|
||||
|
||||
expect(screen.getByText('Create knowledge content')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe('datasetPipeline.addDocuments.title · Dataset 1 - Dify')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Access Errors', () => {
|
||||
it.each([403, 404])(
|
||||
'should redirect to datasets page when dataset detail returns %s',
|
||||
|
||||
@@ -38,6 +38,36 @@ const shouldRedirectToDatasetList = (error: unknown) => {
|
||||
return status === 403 || status === 404
|
||||
}
|
||||
|
||||
const datasetDetailPageTitle = (pathname: string, t: ReturnType<typeof useTranslation>['t']) => {
|
||||
if (
|
||||
pathname.endsWith('/documents/create') ||
|
||||
pathname.endsWith('/documents/create-from-pipeline')
|
||||
)
|
||||
return t(($) => $['addDocuments.title'], { ns: 'datasetPipeline' })
|
||||
if (pathname.includes('/documents'))
|
||||
return t(($) => $['datasetMenus.documents'], { ns: 'common' })
|
||||
if (pathname.endsWith('/pipeline')) return t(($) => $['datasetMenus.pipeline'], { ns: 'common' })
|
||||
if (pathname.endsWith('/hitTesting'))
|
||||
return t(($) => $['datasetMenus.hitTesting'], { ns: 'common' })
|
||||
if (pathname.endsWith('/settings')) return t(($) => $['datasetMenus.settings'], { ns: 'common' })
|
||||
if (pathname.endsWith('/access-config'))
|
||||
return t(($) => $['settings.resourceAccess'], { ns: 'common' })
|
||||
if (pathname.endsWith('/api')) return t(($) => $['appMenus.apiAccess'], { ns: 'common' })
|
||||
|
||||
return t(($) => $['menus.datasets'], { ns: 'common' })
|
||||
}
|
||||
|
||||
const isDocumentDetailPath = (pathname: string) =>
|
||||
/^\/datasets\/[^/]+\/documents\/(?!create(?:-from-pipeline)?\/?$)[^/]+(?:\/settings)?\/?$/.test(
|
||||
pathname,
|
||||
)
|
||||
|
||||
const DatasetDetailPageTitle = ({ title }: { title: string }) => {
|
||||
useDocumentTitle(title)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getDatasetRedirectionPath = (
|
||||
dataset: DataSet,
|
||||
datasetACLCapabilities: ReturnType<typeof getDatasetACLCapabilities>,
|
||||
@@ -101,8 +131,8 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
!isCheckingRouteAccess &&
|
||||
((isAccessConfigPath && !datasetACLCapabilities.canAccessConfig) ||
|
||||
(isHitTestingPath && !datasetACLCapabilities.canRetrievalRecall))
|
||||
|
||||
useDocumentTitle(datasetRes?.name || t(($) => $['menus.datasets'], { ns: 'common' }))
|
||||
const pageTitle = datasetDetailPageTitle(pathname, t)
|
||||
const documentTitle = `${pageTitle} · ${datasetRes?.name || t(($) => $['menus.datasets'], { ns: 'common' })}`
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirect) router.replace('/datasets')
|
||||
@@ -121,6 +151,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
shouldRedirect ||
|
||||
isCheckingRouteAccess ||
|
||||
shouldRedirectUnauthorizedRoute
|
||||
const documentTitleOwnedByChild = isDocumentDetailPath(pathname) && !shouldShowLoading
|
||||
const content = shouldShowLoading ? (
|
||||
<Loading type="app" />
|
||||
) : (
|
||||
@@ -151,6 +182,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background-body">
|
||||
{!documentTitleOwnedByChild && <DatasetDetailPageTitle title={documentTitle} />}
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { generateMetadata as generateConnectMetadata } from '../connect/page'
|
||||
import { generateMetadata as generatePipelineMetadata } from '../create-from-pipeline/page'
|
||||
import { generateMetadata as generateCreateMetadata } from '../create/page'
|
||||
import { generateMetadata as generateNewKnowledgeMetadata } from '../new/create/page'
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
vi.mock('@/i18n-config/server', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/i18n-config/server')>()),
|
||||
getLocaleOnServer: async () => 'en-US',
|
||||
}))
|
||||
|
||||
describe('dataset creation document titles', () => {
|
||||
it.each([
|
||||
[generateConnectMetadata, 'Connect to an external knowledge base'],
|
||||
[generateCreateMetadata, 'Create a ready-to-use knowledge base'],
|
||||
[generatePipelineMetadata, 'Build a custom knowledge base'],
|
||||
[generateNewKnowledgeMetadata, 'Create Knowledge'],
|
||||
])('provides localized route metadata', async (generateMetadata, expectedTitle) => {
|
||||
await expect(generateMetadata()).resolves.toMatchObject({ title: expectedTitle })
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as React from 'react'
|
||||
import ExternalKnowledgeBaseConnector from '@/app/components/datasets/external-knowledge-base/connector'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('common', ($) => $['stepByStepTour.guides.knowledge.empty.connect.title'])
|
||||
}
|
||||
|
||||
const ExternalKnowledgeBaseCreation = () => {
|
||||
return <ExternalKnowledgeBaseConnector />
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import * as React from 'react'
|
||||
import CreateFromPipeline from '@/app/components/datasets/create-from-pipeline'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
const DatasetCreation = async () => {
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata(
|
||||
'common',
|
||||
($) => $['stepByStepTour.guides.knowledge.empty.pipeline.title'],
|
||||
)
|
||||
}
|
||||
|
||||
const DatasetCreation = () => {
|
||||
return <CreateFromPipeline />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import DatasetUpdateForm from '@/app/components/datasets/create'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
const DatasetCreation = async () => {
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('common', ($) => $['stepByStepTour.guides.knowledge.empty.create.title'])
|
||||
}
|
||||
|
||||
const DatasetCreation = () => {
|
||||
return <DatasetUpdateForm />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
import { CreateKnowledgePage } from '@/features/new-rag/create-knowledge-page'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('dataset', ($) => $['newKnowledge.createTitle'])
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <CreateKnowledgePage />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { generateMetadata } from '../layout'
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
vi.mock('@/app/components/integrations', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/server', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/i18n-config/server')>()),
|
||||
getLocaleOnServer: async () => 'en-US',
|
||||
}))
|
||||
|
||||
describe('integrations route metadata', () => {
|
||||
it.each([
|
||||
[['model-provider'], 'Model Provider · Integrations'],
|
||||
[['tools', 'built-in'], 'Tool Plugin · Integrations'],
|
||||
[['tools', 'api'], 'Swagger API as Tool · Integrations'],
|
||||
[['tools', 'workflow'], 'Workflow as Tool · Integrations'],
|
||||
[['tools', 'mcp'], 'MCP · Integrations'],
|
||||
[['data-source'], 'Data Source · Integrations'],
|
||||
[['custom-endpoint'], 'Custom Endpoint · Integrations'],
|
||||
[['trigger'], 'Trigger · Integrations'],
|
||||
[['agent-strategy'], 'Agent Strategy · Integrations'],
|
||||
[['extension'], 'Extension · Integrations'],
|
||||
])('provides metadata for /integrations/%s', async (slug, expectedTitle) => {
|
||||
await expect(generateMetadata({ params: Promise.resolve({ slug }) })).resolves.toMatchObject({
|
||||
title: expectedTitle,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { IntegrationSection } from '@/app/components/integrations/routes'
|
||||
import type { Metadata } from '@/next'
|
||||
import { getIntegrationRouteTargetBySlug } from '@/app/components/integrations/routes'
|
||||
import { getLocaleOnServer, getTranslation } from '@/i18n-config/server'
|
||||
|
||||
type IntegrationsRouteLayoutProps = PropsWithChildren<{
|
||||
params: Promise<{
|
||||
slug?: string[]
|
||||
}>
|
||||
}>
|
||||
|
||||
const getIntegrationSectionTitle = async (section: IntegrationSection) => {
|
||||
const locale = await getLocaleOnServer()
|
||||
|
||||
if (section === 'mcp') return 'MCP'
|
||||
if (section === 'workflow-tool') {
|
||||
const { t } = await getTranslation(locale, 'workflow')
|
||||
return t(($) => $['common.workflowAsTool'], { ns: 'workflow' })
|
||||
}
|
||||
if (section === 'trigger' || section === 'agent-strategy' || section === 'extension') {
|
||||
const { t } = await getTranslation(locale, 'plugin')
|
||||
if (section === 'trigger') return t(($) => $['categorySingle.trigger'], { ns: 'plugin' })
|
||||
if (section === 'agent-strategy') return t(($) => $['categorySingle.agent'], { ns: 'plugin' })
|
||||
return t(($) => $['categorySingle.extension'], { ns: 'plugin' })
|
||||
}
|
||||
|
||||
const { t } = await getTranslation(locale, 'common')
|
||||
if (section === 'provider') return t(($) => $['settings.provider'], { ns: 'common' })
|
||||
if (section === 'builtin') return t(($) => $['toolsPage.toolPlugin'], { ns: 'common' })
|
||||
if (section === 'custom-tool') return t(($) => $['settings.swaggerAPIAsTool'], { ns: 'common' })
|
||||
if (section === 'data-source') return t(($) => $['settings.dataSource'], { ns: 'common' })
|
||||
return t(($) => $['settings.customEndpoint'], { ns: 'common' })
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: IntegrationsRouteLayoutProps): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const target = getIntegrationRouteTargetBySlug(slug)
|
||||
const locale = await getLocaleOnServer()
|
||||
const { t } = await getTranslation(locale, 'common')
|
||||
const integrationsTitle = t(($) => $['mainNav.integrations'], { ns: 'common' })
|
||||
|
||||
if (target.type !== 'section') return { title: integrationsTitle }
|
||||
|
||||
return { title: `${await getIntegrationSectionTitle(target.section)} · ${integrationsTitle}` }
|
||||
}
|
||||
|
||||
export default function IntegrationsRouteLayout({ children }: IntegrationsRouteLayoutProps) {
|
||||
return children
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationRouteSearchParams } from '@/app/components/integrations/routes'
|
||||
import IntegrationsPage from '@/app/components/integrations/page'
|
||||
import IntegrationsPage from '@/app/components/integrations'
|
||||
import { getIntegrationRouteTargetBySlug } from '@/app/components/integrations/routes'
|
||||
import { notFound, redirect } from '@/next/navigation'
|
||||
|
||||
@@ -18,7 +18,7 @@ const IntegrationsRoutePage = async ({ params, searchParams }: IntegrationsRoute
|
||||
|
||||
if (target.type === 'not-found') notFound()
|
||||
|
||||
return <IntegrationsPage section={target.section} />
|
||||
return <IntegrationsPage section={target.section} syncDocumentTitle />
|
||||
}
|
||||
|
||||
export default IntegrationsRoutePage
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
export default function IntegrationsLayout({ children }: PropsWithChildren) {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t(($) => $['mainNav.integrations'], { ns: 'common' }))
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { act, render, renderHook, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import FormContent from '../form'
|
||||
import { useFormSubmit } from '../use-form-submit'
|
||||
@@ -51,6 +52,8 @@ vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseDocumentTitle = vi.mocked(useDocumentTitle)
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
@@ -197,6 +200,13 @@ describe('Human input share form', () => {
|
||||
render(<FormContent />)
|
||||
|
||||
expect(screen.getByText('loading')).toBeInTheDocument()
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('common.loading')
|
||||
})
|
||||
|
||||
it('should use the app name as the loaded form document title', () => {
|
||||
render(<FormContent />)
|
||||
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('Review App')
|
||||
})
|
||||
|
||||
it('should render status cards for terminal fetch states', () => {
|
||||
@@ -205,29 +215,33 @@ describe('Human input share form', () => {
|
||||
error: { code: 'human_input_form_expired' },
|
||||
title: 'share.humanInput.sorry',
|
||||
subtitle: 'share.humanInput.expired',
|
||||
documentTitle: 'share.humanInput.expired',
|
||||
submissionID: true,
|
||||
},
|
||||
{
|
||||
error: { code: 'human_input_form_submitted' },
|
||||
title: 'share.humanInput.sorry',
|
||||
subtitle: 'share.humanInput.completed',
|
||||
documentTitle: 'share.humanInput.completed',
|
||||
submissionID: true,
|
||||
},
|
||||
{
|
||||
error: { code: 'web_form_rate_limit_exceeded' },
|
||||
title: 'share.humanInput.rateLimitExceeded',
|
||||
subtitle: undefined,
|
||||
documentTitle: 'share.humanInput.rateLimitExceeded',
|
||||
submissionID: false,
|
||||
},
|
||||
{
|
||||
error: null,
|
||||
title: 'share.humanInput.formNotFound',
|
||||
subtitle: undefined,
|
||||
documentTitle: 'share.humanInput.formNotFound',
|
||||
submissionID: false,
|
||||
},
|
||||
]
|
||||
|
||||
cases.forEach(({ error, title, subtitle, submissionID }) => {
|
||||
cases.forEach(({ error, title, subtitle, documentTitle, submissionID }) => {
|
||||
mockUseGetHumanInputForm.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
@@ -236,6 +250,7 @@ describe('Human input share form', () => {
|
||||
const { unmount } = render(<FormContent />)
|
||||
|
||||
expect(screen.getByText(title)).toBeInTheDocument()
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith(documentTitle)
|
||||
if (subtitle) expect(screen.getByText(subtitle)).toBeInTheDocument()
|
||||
else expect(screen.queryByText('share.humanInput.expired')).not.toBeInTheDocument()
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ const FormContent = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { token } = useParams<{ token: string }>()
|
||||
useDocumentTitle('')
|
||||
|
||||
const { data: formData, isLoading, error } = useGetHumanInputForm(token)
|
||||
const { isSubmitting, submit, success } = useFormSubmit(token)
|
||||
@@ -44,6 +43,18 @@ const FormContent = () => {
|
||||
const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted'
|
||||
const rateLimitExceeded =
|
||||
(error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded'
|
||||
const documentTitle = isLoading
|
||||
? t(($) => $.loading, { ns: 'common' })
|
||||
: success
|
||||
? t(($) => $['humanInput.thanks'], { ns: 'share' })
|
||||
: expired
|
||||
? t(($) => $['humanInput.expired'], { ns: 'share' })
|
||||
: submitted
|
||||
? t(($) => $['humanInput.completed'], { ns: 'share' })
|
||||
: rateLimitExceeded
|
||||
? t(($) => $['humanInput.rateLimitExceeded'], { ns: 'share' })
|
||||
: formData?.site.site.title || t(($) => $['humanInput.formNotFound'], { ns: 'share' })
|
||||
useDocumentTitle(documentTitle)
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading type="app" />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $['checkCode.checkYourEmail'])
|
||||
}
|
||||
|
||||
export default function CheckCodeLayout({ children }: PropsWithChildren) {
|
||||
return children
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Countdown from '@/app/components/signin/countdown'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common'
|
||||
|
||||
@@ -19,6 +20,8 @@ export default function CheckCode() {
|
||||
const [code, setVerifyCode] = useState('')
|
||||
const [loading, setIsLoading] = useState(false)
|
||||
const locale = useLocale()
|
||||
const pageTitle = t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
@@ -63,9 +66,7 @@ export default function CheckCode() {
|
||||
<RiMailSendFill className="size-6 text-2xl" />
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">
|
||||
{t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })}
|
||||
</h1>
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">{pageTitle}</h1>
|
||||
<p className="mt-2 body-md-regular text-text-secondary">
|
||||
<span>
|
||||
{t(($) => $['checkCode.tipsPrefix'], { ns: 'login' })}
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import Header from '@/app/signin/_header'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
import ResetPasswordLayout from './reset-password-layout'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $.resetPassword)
|
||||
}
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle',
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full grow flex-col items-center justify-center',
|
||||
'px-6',
|
||||
'md:px-27',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-100 flex-col">{children}</div>
|
||||
</div>
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
|
||||
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
return <ResetPasswordLayout>{children}</ResetPasswordLayout>
|
||||
}
|
||||
|
||||
@@ -16,13 +16,14 @@ import { sendResetPasswordCode } from '@/service/common'
|
||||
|
||||
export default function CheckCode() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle('')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setIsLoading] = useState(false)
|
||||
const locale = useLocale()
|
||||
const setCountdownLeftTime = useSetCountdownLeftTime()
|
||||
const pageTitle = t(($) => $.resetPassword, { ns: 'login' })
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
const handleGetEMailVerificationCode = async () => {
|
||||
try {
|
||||
@@ -61,9 +62,7 @@ export default function CheckCode() {
|
||||
<RiLockPasswordLine className="size-6 text-2xl text-text-accent-light-mode-only" />
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">
|
||||
{t(($) => $.resetPassword, { ns: 'login' })}
|
||||
</h1>
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">{pageTitle}</h1>
|
||||
<p className="mt-2 body-md-regular text-text-secondary">
|
||||
{t(($) => $.resetPasswordDesc, { ns: 'login' })}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import Header from '@/app/signin/_header'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
|
||||
export default function ResetPasswordLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle',
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full grow flex-col items-center justify-center',
|
||||
'px-6',
|
||||
'md:px-27',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-100 flex-col">{children}</div>
|
||||
</div>
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
|
||||
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { changeWebAppPasswordWithToken } from '@/service/common'
|
||||
import ChangePasswordForm from '../page'
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useCountDown: () => [5000],
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
changeWebAppPasswordWithToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseDocumentTitle = vi.mocked(useDocumentTitle)
|
||||
|
||||
describe('Webapp Reset Password Set Password Page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useRouter).mockReturnValue({ replace: vi.fn() } as unknown as ReturnType<
|
||||
typeof useRouter
|
||||
>)
|
||||
vi.mocked(useSearchParams).mockReturnValue(
|
||||
new URLSearchParams({ token: 'reset-token' }) as unknown as ReturnType<
|
||||
typeof useSearchParams
|
||||
>,
|
||||
)
|
||||
vi.mocked(changeWebAppPasswordWithToken).mockResolvedValue({ result: 'success' })
|
||||
})
|
||||
|
||||
it('reconciles the route title initially and updates it after a successful reset', async () => {
|
||||
render(<ChangePasswordForm />)
|
||||
|
||||
expect(mockUseDocumentTitle).toHaveBeenCalledWith('login.changePassword')
|
||||
|
||||
fireEvent.change(screen.getByLabelText('common.account.newPassword'), {
|
||||
target: { value: 'ValidPass123!' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('common.account.confirmPassword'), {
|
||||
target: { value: 'ValidPass123!' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.changePasswordBtn' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
|
||||
'login.passwordChangedTip',
|
||||
)
|
||||
})
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('login.passwordChangedTip')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $.changePassword)
|
||||
}
|
||||
|
||||
export default function SetPasswordLayout({ children }: PropsWithChildren) {
|
||||
return children
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { validPassword } from '@/config'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { changeWebAppPasswordWithToken } from '@/service/common'
|
||||
|
||||
@@ -22,6 +23,11 @@ const ChangePasswordForm = () => {
|
||||
const [showSuccess, setShowSuccess] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
useDocumentTitle(
|
||||
showSuccess
|
||||
? t(($) => $.passwordChangedTip, { ns: 'login' })
|
||||
: t(($) => $.changePassword, { ns: 'login' }),
|
||||
)
|
||||
|
||||
const showErrorMessage = useCallback((message: string) => {
|
||||
toast.error(message)
|
||||
|
||||
@@ -40,22 +40,44 @@ describe('Root layout System Features bootstrap', () => {
|
||||
})
|
||||
|
||||
it('caches the resolved System Features for dehydration', async () => {
|
||||
mocks.getSystemFeatures.mockResolvedValue({ deployment_edition: 'CLOUD' })
|
||||
const { default: RootLayout } = await import('../layout')
|
||||
mocks.getSystemFeatures.mockResolvedValue({
|
||||
branding: {
|
||||
application_title: 'Acme AI',
|
||||
enabled: true,
|
||||
},
|
||||
deployment_edition: 'CLOUD',
|
||||
})
|
||||
const { default: RootLayout, generateMetadata } = await import('../layout')
|
||||
|
||||
await expect(RootLayout({ children: <div>App</div> })).resolves.toBeDefined()
|
||||
await expect(generateMetadata()).resolves.toMatchObject({
|
||||
title: {
|
||||
default: 'Acme AI',
|
||||
template: '%s - Acme AI',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.getSystemFeatures).toHaveBeenCalledTimes(1)
|
||||
expect(queryClient.getQueryData(['console', 'system-features'])).toEqual({
|
||||
branding: {
|
||||
application_title: 'Acme AI',
|
||||
enabled: true,
|
||||
},
|
||||
deployment_edition: 'CLOUD',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the client recovery path when the server prefetch fails', async () => {
|
||||
mocks.getSystemFeatures.mockRejectedValue(new Error('system features unavailable'))
|
||||
const { default: RootLayout } = await import('../layout')
|
||||
const { default: RootLayout, generateMetadata } = await import('../layout')
|
||||
|
||||
await expect(RootLayout({ children: <div>App</div> })).resolves.toBeDefined()
|
||||
await expect(generateMetadata()).resolves.toMatchObject({
|
||||
title: {
|
||||
default: 'Dify',
|
||||
template: '%s - Dify',
|
||||
},
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData(['console', 'system-features'])).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { generateMetadata as generateAgentsMetadata } from '../(commonLayout)/agents/page'
|
||||
import { generateMetadata as generateWebappCheckCodeMetadata } from '../(shareLayout)/webapp-reset-password/check-code/layout'
|
||||
import { generateMetadata as generateWebappResetPasswordMetadata } from '../(shareLayout)/webapp-reset-password/layout'
|
||||
import { generateMetadata as generateWebappSetPasswordMetadata } from '../(shareLayout)/webapp-reset-password/set-password/layout'
|
||||
import { generateMetadata as generateInitMetadata } from '../init/page'
|
||||
import { generateMetadata as generateInstallMetadata } from '../install/layout'
|
||||
import { generateMetadata as generateOAuthCallbackMetadata } from '../oauth-callback/layout'
|
||||
import { generateMetadata as generateCheckCodeMetadata } from '../reset-password/check-code/layout'
|
||||
import { generateMetadata as generateResetPasswordMetadata } from '../reset-password/layout'
|
||||
import { generateMetadata as generateSetPasswordMetadata } from '../reset-password/set-password/layout'
|
||||
import { generateMetadata as generateSignInCheckCodeMetadata } from '../signin/check-code/layout'
|
||||
import { generateMetadata as generateSignInMetadata } from '../signin/page'
|
||||
import { generateMetadata as generateSignupCheckCodeMetadata } from '../signup/check-code/layout'
|
||||
import { generateMetadata as generateSignupMetadata } from '../signup/layout'
|
||||
import { generateMetadata as generateSignupSetPasswordMetadata } from '../signup/set-password/layout'
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
vi.mock('@/i18n-config/server', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/i18n-config/server')>()),
|
||||
getLocaleOnServer: async () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('../reset-password/reset-password-layout', () => ({ default: () => null }))
|
||||
vi.mock('../signin/sign-in-page', () => ({ default: () => null }))
|
||||
vi.mock('../signup/signup-layout', () => ({ default: () => null }))
|
||||
vi.mock('../(shareLayout)/webapp-reset-password/reset-password-layout', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
vi.mock('../init/InitPasswordPopup', () => ({ default: () => null }))
|
||||
vi.mock('@/features/agent-v2/roster/page', () => ({ default: () => null }))
|
||||
|
||||
describe('fixed authentication route metadata', () => {
|
||||
it.each([
|
||||
[generateResetPasswordMetadata, 'Reset Password'],
|
||||
[generateCheckCodeMetadata, 'Check your email'],
|
||||
[generateSetPasswordMetadata, 'Set a password'],
|
||||
[generateWebappResetPasswordMetadata, 'Reset Password'],
|
||||
[generateWebappCheckCodeMetadata, 'Check your email'],
|
||||
[generateWebappSetPasswordMetadata, 'Set a password'],
|
||||
[generateSignupMetadata, 'Create your account'],
|
||||
[generateSignupCheckCodeMetadata, 'Check your email'],
|
||||
[generateSignupSetPasswordMetadata, 'Set a password'],
|
||||
[generateSignInCheckCodeMetadata, 'Check your email'],
|
||||
[generateInstallMetadata, 'Setting up an admin account'],
|
||||
[generateInitMetadata, 'Admin initialization password'],
|
||||
[generateOAuthCallbackMetadata, 'Sign in'],
|
||||
[generateAgentsMetadata, 'Agents'],
|
||||
])('provides the localized title %s', async (generateMetadata, expectedTitle) => {
|
||||
await expect(generateMetadata()).resolves.toMatchObject({ title: expectedTitle })
|
||||
})
|
||||
})
|
||||
|
||||
describe('sign-in route metadata', () => {
|
||||
it.each([
|
||||
[undefined, 'Sign in'],
|
||||
['next', 'One more step'],
|
||||
[['next', 'ignored'], 'One more step'],
|
||||
])('uses the request-visible step %s', async (step, expectedTitle) => {
|
||||
await expect(
|
||||
generateSignInMetadata({ searchParams: Promise.resolve({ step }) }),
|
||||
).resolves.toMatchObject({ title: expectedTitle })
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { seedSystemFeatures } from '@/test/console/query-data'
|
||||
import OAuthAuthorize from '../page'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -39,6 +40,7 @@ function renderPage() {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
seedSystemFeatures(queryClient)
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -92,6 +94,7 @@ describe('OAuthAuthorize', () => {
|
||||
renderPage()
|
||||
|
||||
expect((await screen.findAllByText('Test OAuth App')).length).toBeGreaterThan(0)
|
||||
expect(document.title).toBe('oauth.connect Test OAuth App - Dify')
|
||||
const providerRequest = findRequest('/oauth/provider')
|
||||
const providerTransportRequest = providerRequest?.[2]?.request as Request
|
||||
await expect(providerTransportRequest.clone().json()).resolves.toEqual({
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import Header from '@/app/signin/_header'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
@@ -13,7 +12,6 @@ const copyrightYear = new Date().getFullYear()
|
||||
|
||||
export default function OAuthAuthorizeLayout({ children }: Props) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
useDocumentTitle('')
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full justify-center bg-background-default-burn p-6">
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
@@ -100,6 +101,11 @@ export default function OAuthAuthorize() {
|
||||
(typeof localizedAppLabel === 'string' && localizedAppLabel) ||
|
||||
(typeof englishAppLabel === 'string' && englishAppLabel) ||
|
||||
t(($) => $.unknownApp, { ns: 'oauth' })
|
||||
useDocumentTitle(
|
||||
authAppInfo
|
||||
? `${t(($) => $.connect, { ns: 'oauth' })} ${appLabel}`
|
||||
: t(($) => $.connect, { ns: 'oauth' }),
|
||||
)
|
||||
|
||||
const isLoading = isOAuthLoading || isProfileLoading
|
||||
const onLoginSwitchClick = async () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useInvitationCheck } from '@/service/use-common'
|
||||
|
||||
const ActivateForm = () => {
|
||||
useDocumentTitle('')
|
||||
const router = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -32,6 +31,11 @@ const ActivateForm = () => {
|
||||
},
|
||||
true,
|
||||
)
|
||||
useDocumentTitle(
|
||||
checkRes?.is_valid === false
|
||||
? t(($) => $.invalid, { ns: 'login' })
|
||||
: t(($) => $.setYourAccount, { ns: 'login' }),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (checkRes?.is_valid) {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import Effect from '../../base/effect'
|
||||
import Footer from './footer'
|
||||
import Header from './header'
|
||||
import List from './list'
|
||||
|
||||
const CreateFromPipeline = () => {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(
|
||||
t(($) => $['stepByStepTour.guides.knowledge.empty.pipeline.title'], { ns: 'common' }),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex h-[calc(100vh-56px)] flex-col overflow-hidden rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle">
|
||||
<Effect className="-top-8.5 left-8 opacity-20" />
|
||||
|
||||
@@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => {
|
||||
invalidDocumentList: vi.fn(),
|
||||
invalidSegmentList: vi.fn(),
|
||||
invalidChildSegmentList: vi.fn(),
|
||||
useDocumentTitle: vi.fn(),
|
||||
toastNotify: vi.fn(),
|
||||
}
|
||||
})
|
||||
@@ -47,6 +48,10 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
MediaType: { mobile: 'mobile', tablet: 'tablet', pc: 'desktop' },
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: mocks.useDocumentTitle,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ dataset: mocks.state.dataset }),
|
||||
@@ -282,6 +287,7 @@ describe('DocumentDetail', () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.state.dataset = {
|
||||
embedding_available: true,
|
||||
name: 'Dataset 1',
|
||||
permission_keys: [DatasetACLPermission.Edit],
|
||||
}
|
||||
mocks.state.documentDetail = createDocumentDetail()
|
||||
@@ -313,6 +319,12 @@ describe('DocumentDetail', () => {
|
||||
})
|
||||
|
||||
describe('Content Rendering', () => {
|
||||
it('uses the document and knowledge names in the document title', () => {
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
|
||||
expect(mocks.useDocumentTitle).toHaveBeenLastCalledWith('test-doc.txt · Dataset 1')
|
||||
})
|
||||
|
||||
it('should render Completed when status is available', () => {
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
expect(screen.getByTestId('completed')).toBeInTheDocument()
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { ChunkingMode, DisplayStatusList } from '@/models/datasets'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import {
|
||||
@@ -148,6 +149,10 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
return false
|
||||
},
|
||||
})
|
||||
const documentTitle =
|
||||
documentDetail?.name || t(($) => $['datasetMenus.documents'], { ns: 'common' })
|
||||
const datasetTitle = dataset?.name || t(($) => $['menus.datasets'], { ns: 'common' })
|
||||
useDocumentTitle(`${documentTitle} · ${datasetTitle}`)
|
||||
|
||||
const { data: documentMetadata } = useDocumentMetadata({
|
||||
datasetId,
|
||||
|
||||
+14
-1
@@ -5,6 +5,7 @@ import DocumentSettings from '../document-settings'
|
||||
const mockPush = vi.fn()
|
||||
const mockBack = vi.fn()
|
||||
const mockSetSettingsDestination = vi.fn()
|
||||
const mockUseDocumentTitle = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
@@ -19,11 +20,15 @@ vi.mock('use-context-selector', async (importOriginal) => {
|
||||
...actual,
|
||||
useContext: () => ({
|
||||
indexingTechnique: 'qualified',
|
||||
dataset: { id: 'dataset-1' },
|
||||
dataset: { id: 'dataset-1', name: 'Dataset 1' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: mockUseDocumentTitle,
|
||||
}))
|
||||
|
||||
const mockInvalidDocumentList = vi.fn()
|
||||
const mockInvalidDocumentDetail = vi.fn()
|
||||
let mockDocumentDetail: Record<string, unknown> | null = {
|
||||
@@ -131,6 +136,14 @@ describe('DocumentSettings', () => {
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('uses the settings, document, and knowledge names in the document title', () => {
|
||||
render(<DocumentSettings {...defaultProps} />)
|
||||
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith(
|
||||
'datasetPipeline.documentSettings.title · test-document · Dataset 1',
|
||||
)
|
||||
})
|
||||
|
||||
it('should render StepTwo component when data is loaded', () => {
|
||||
render(<DocumentSettings {...defaultProps} />)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
settingsQueryParser,
|
||||
} from '@/app/components/header/account-setting/query-params'
|
||||
import DatasetDetailContext from '@/context/dataset-detail'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import {
|
||||
useDocumentDetail,
|
||||
@@ -61,6 +62,11 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
|
||||
documentId,
|
||||
params: { metadata: 'without' },
|
||||
})
|
||||
const settingsTitle = t(($) => $['documentSettings.title'], { ns: 'datasetPipeline' })
|
||||
const documentTitle =
|
||||
documentDetail?.name || t(($) => $['datasetMenus.documents'], { ns: 'common' })
|
||||
const datasetTitle = dataset?.name || t(($) => $['menus.datasets'], { ns: 'common' })
|
||||
useDocumentTitle(`${settingsTitle} · ${documentTitle} · ${datasetTitle}`)
|
||||
|
||||
const dataSourceInfo = documentDetail?.data_source_info
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ vi.mock('@/next/navigation', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock useDocLink hook
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import ExternalKnowledgeBaseCreate from '@/app/components/datasets/external-knowledge-base/create'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { createExternalKnowledgeBase } from '@/service/datasets'
|
||||
|
||||
@@ -14,6 +15,9 @@ const ExternalKnowledgeBaseConnector = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(
|
||||
t(($) => $['stepByStepTour.guides.knowledge.empty.connect.title'], { ns: 'common' }),
|
||||
)
|
||||
|
||||
const handleConnect = async (formValue: CreateKnowledgeBaseReq) => {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import TryApp from '../index'
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn() }))
|
||||
vi.mock('../chat', () => ({
|
||||
default: () => <section aria-label="Chat preview" />,
|
||||
}))
|
||||
@@ -40,9 +38,11 @@ describe('TryApp', () => {
|
||||
expect(screen.getByRole('region', { name })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sets the document title from the shared app metadata', () => {
|
||||
it('preserves document title ownership for the underlying route', () => {
|
||||
document.title = 'Apps - Dify'
|
||||
|
||||
render(<TryApp appId="app-id" appDetail={createApp('chat')} />)
|
||||
|
||||
expect(useDocumentTitle).toHaveBeenCalledWith('Try App')
|
||||
expect(document.title).toBe('Apps - Dify')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AppData } from '@/models/share'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { memo } from 'react'
|
||||
import { FileUploadContext } from '@/app/components/base/file-uploader/upload-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import Chat from './chat'
|
||||
import TextGeneration from './text-generation'
|
||||
|
||||
@@ -17,7 +16,6 @@ function TryApp({ appId, appDetail }: Props) {
|
||||
const isChat = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!)
|
||||
const isCompletion = !isChat
|
||||
|
||||
useDocumentTitle(appDetail?.site?.title || '')
|
||||
return (
|
||||
<FileUploadContext
|
||||
value={{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/ta
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import IntegrationsPage from '../page'
|
||||
import IntegrationsPage from '../index'
|
||||
|
||||
const renderWithNuqs = (
|
||||
ui: React.ReactElement,
|
||||
@@ -338,6 +338,7 @@ const renderIntegrationsPage = (
|
||||
describe('IntegrationsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.title = ''
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
mockCanManagement.mockReturnValue(true)
|
||||
mockCanDebugger.mockReturnValue(true)
|
||||
@@ -368,6 +369,20 @@ describe('IntegrationsPage', () => {
|
||||
expect(container.querySelector('aside')).toHaveClass('bg-components-panel-bg')
|
||||
})
|
||||
|
||||
it('does not replace the document title when embedded in a modal', () => {
|
||||
document.title = 'Workspace settings - Dify'
|
||||
|
||||
renderIntegrationsPage(undefined, 'provider')
|
||||
|
||||
expect(document.title).toBe('Workspace settings - Dify')
|
||||
})
|
||||
|
||||
it('reconciles the route title with client branding', () => {
|
||||
renderIntegrationsPage(undefined, { section: 'provider', syncDocumentTitle: true })
|
||||
|
||||
expect(document.title).toBe('common.settings.provider · common.mainNav.integrations - Dify')
|
||||
})
|
||||
|
||||
it('renders the model provider section from the section query', () => {
|
||||
renderIntegrationsPage({ section: 'provider' })
|
||||
|
||||
|
||||
+16
-2
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import type { IntegrationSection } from '@/app/components/integrations/routes'
|
||||
import type { IntegrationSection } from './routes'
|
||||
import type { DocPathWithoutLang } from '@/types/doc-paths'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
toolCategoryBySection,
|
||||
} from '@/app/components/integrations/routes'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
@@ -40,6 +41,13 @@ type IntegrationsPageProps = {
|
||||
onSectionChange?: (section: IntegrationSection) => void
|
||||
onSwitchToMarketplace?: (path: string) => void
|
||||
section?: IntegrationSection
|
||||
syncDocumentTitle?: boolean
|
||||
}
|
||||
|
||||
const IntegrationsDocumentTitle = ({ title }: { title: string }) => {
|
||||
useDocumentTitle(title)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const headerDescriptionDocPaths = {
|
||||
@@ -115,6 +123,7 @@ export default function IntegrationsPage({
|
||||
onSectionChange,
|
||||
onSwitchToMarketplace,
|
||||
section: routeSection,
|
||||
syncDocumentTitle = false,
|
||||
}: IntegrationsPageProps) {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
@@ -145,6 +154,8 @@ export default function IntegrationsPage({
|
||||
secondaryItems,
|
||||
toolItems,
|
||||
} = useIntegrationNav(section)
|
||||
const integrationsTitle = t(($) => $['mainNav.integrations'], { ns: 'common' })
|
||||
const sectionTitle = integrationHeader?.title ?? activeItem?.label ?? integrationsTitle
|
||||
const isToolSection = Boolean(toolCategoryBySection[section])
|
||||
const [isToolsExpanded, setIsToolsExpanded] = useState(isToolSection)
|
||||
useEffect(() => {
|
||||
@@ -160,7 +171,7 @@ export default function IntegrationsPage({
|
||||
section === 'custom-endpoint' ||
|
||||
isToolSection ||
|
||||
isPluginCategory
|
||||
const scrollAreaLabel = integrationHeader?.title ?? activeItem?.label
|
||||
const scrollAreaLabel = sectionTitle
|
||||
const sidebarWidthStyle = {
|
||||
'--integrations-sidebar-width': '200px',
|
||||
'--model-provider-warning-left': 'calc(240px + 200px)',
|
||||
@@ -239,6 +250,9 @@ export default function IntegrationsPage({
|
||||
className="flex h-full min-h-0 w-full flex-1 bg-components-panel-bg"
|
||||
style={sidebarWidthStyle}
|
||||
>
|
||||
{syncDocumentTitle && (
|
||||
<IntegrationsDocumentTitle title={`${sectionTitle} · ${integrationsTitle}`} />
|
||||
)}
|
||||
<aside
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col border-r border-divider-burn bg-components-panel-bg px-2 py-2 transition-[width]',
|
||||
@@ -6,7 +6,7 @@ import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import IntegrationsPage from './page'
|
||||
import IntegrationsPage from './index'
|
||||
|
||||
type IntegrationsSettingModalProps = {
|
||||
section: IntegrationSection
|
||||
|
||||
@@ -54,6 +54,7 @@ let MockDeviceFlowError: MockDeviceFlowErrorCtor
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
document.title = ''
|
||||
mockSearchParams = {}
|
||||
// router.replace(pathname) in the real app drops the query string; mirror
|
||||
// that so useSearchParams reflects the cleared URL on the next render.
|
||||
@@ -79,6 +80,7 @@ describe('error_expired terminal state', () => {
|
||||
it('shows "errorExpired.title" heading', async () => {
|
||||
await reachTerminal(new Error('expired'))
|
||||
await screen.findByText('deviceFlow.errorExpired.title')
|
||||
expect(document.title).toBe('deviceFlow.errorExpired.title - Dify')
|
||||
})
|
||||
|
||||
it('ghost button resets to code_entry', async () => {
|
||||
@@ -170,5 +172,6 @@ describe('error_sso dedicated view', () => {
|
||||
render(<DevicePage />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.queryByText(TITLE)).not.toBeInTheDocument()
|
||||
expect(document.title).toBe('deviceFlow.codeEntry.title - Dify')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import Header from './_header'
|
||||
|
||||
export default function DeviceLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
useDocumentTitle('')
|
||||
return (
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Trans, useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deviceLookup } from '@/service/device-flow'
|
||||
@@ -40,6 +41,18 @@ export default function DevicePage() {
|
||||
const [typed, setTyped] = useState('')
|
||||
const [view, setView] = useState<View>({ kind: 'code_entry' })
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
const documentTitle = {
|
||||
authorize_account: t(($) => $['authorize.title']),
|
||||
authorize_sso: t(($) => $['authorize.title']),
|
||||
chooser: t(($) => $['chooser.title']),
|
||||
code_entry: t(($) => $['codeEntry.title']),
|
||||
error_expired: t(($) => $['errorExpired.title']),
|
||||
error_lookup_failed: t(($) => $['errorLookupFailed.title']),
|
||||
error_rate_limited: t(($) => $['errorRateLimited.title']),
|
||||
error_sso: t(($) => $['errorSso.title']),
|
||||
success: t(($) => $['success.title']),
|
||||
}[view.kind]
|
||||
useDocumentTitle(documentTitle)
|
||||
|
||||
// Account subject + workspace identity (for the authorize-account screen).
|
||||
// Logged-out is a valid landing state on /device — disable refetch storms
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { changePasswordWithToken } from '@/service/common'
|
||||
import { useVerifyForgotPasswordToken } from '@/service/use-common'
|
||||
import ChangePasswordForm from './ChangePasswordForm'
|
||||
@@ -17,11 +18,17 @@ vi.mock('@/service/common', () => ({
|
||||
changePasswordWithToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({ basePath: '' }))
|
||||
|
||||
type UseVerifyResult = ReturnType<typeof useVerifyForgotPasswordToken>
|
||||
const mockUseVerify = vi.mocked(useVerifyForgotPasswordToken)
|
||||
const mockChangePassword = vi.mocked(changePasswordWithToken)
|
||||
const mockUseDocumentTitle = vi.mocked(useDocumentTitle)
|
||||
|
||||
const VALID_PASSWORD = 'ValidPass123!'
|
||||
|
||||
@@ -30,6 +37,17 @@ describe('ChangePasswordForm', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses the loading title while the token is being verified', () => {
|
||||
mockUseVerify.mockReturnValue({
|
||||
data: undefined,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as UseVerifyResult)
|
||||
|
||||
render(<ChangePasswordForm />)
|
||||
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('common.loading')
|
||||
})
|
||||
|
||||
describe('when token is valid', () => {
|
||||
const T2 = 'verified-token-t2'
|
||||
|
||||
@@ -43,6 +61,7 @@ describe('ChangePasswordForm', () => {
|
||||
it('renders the password form', () => {
|
||||
render(<ChangePasswordForm />)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('login.changePassword')
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('login.changePassword')
|
||||
})
|
||||
|
||||
it('submits with T2 (from validity response), NOT T1 (from URL)', async () => {
|
||||
@@ -69,6 +88,24 @@ describe('ChangePasswordForm', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the success title after the password is changed', async () => {
|
||||
mockChangePassword.mockResolvedValue({ result: 'success' })
|
||||
|
||||
render(<ChangePasswordForm />)
|
||||
|
||||
const inputs = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>('input[type="password"]'),
|
||||
) as [HTMLInputElement, HTMLInputElement]
|
||||
fireEvent.change(inputs[0], { target: { value: VALID_PASSWORD } })
|
||||
fireEvent.change(inputs[1], { target: { value: VALID_PASSWORD } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.reset/ }))
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { level: 1, name: 'login.passwordChangedTip' }),
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('login.passwordChangedTip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when token is invalid', () => {
|
||||
@@ -82,6 +119,7 @@ describe('ChangePasswordForm', () => {
|
||||
it('shows invalid token state and no form', () => {
|
||||
render(<ChangePasswordForm />)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('login.invalid')
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('login.invalid')
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /common\.operation\.reset/ }),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { validPassword } from '@/config'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { changePasswordWithToken } from '@/service/common'
|
||||
import { useVerifyForgotPasswordToken } from '@/service/use-common'
|
||||
@@ -24,6 +25,16 @@ const ChangePasswordForm = () => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showSuccess, setShowSuccess] = useState(false)
|
||||
const isVerifyingToken = !isTokenMissing && !verifyTokenRes
|
||||
const isTokenInvalid = isTokenMissing || (verifyTokenRes && !verifyTokenRes.is_valid)
|
||||
const documentTitle = isVerifyingToken
|
||||
? t(($) => $.loading, { ns: 'common' })
|
||||
: isTokenInvalid
|
||||
? t(($) => $.invalid, { ns: 'login' })
|
||||
: showSuccess
|
||||
? t(($) => $.passwordChangedTip, { ns: 'login' })
|
||||
: t(($) => $.changePassword, { ns: 'login' })
|
||||
useDocumentTitle(documentTitle)
|
||||
|
||||
const showErrorMessage = useCallback((message: string) => {
|
||||
toast.error(message)
|
||||
@@ -68,8 +79,8 @@ const ChangePasswordForm = () => {
|
||||
<div
|
||||
className={cn('flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-27')}
|
||||
>
|
||||
{!isTokenMissing && !verifyTokenRes && <Loading />}
|
||||
{(isTokenMissing || (verifyTokenRes && !verifyTokenRes.is_valid)) && (
|
||||
{isVerifyingToken && <Loading />}
|
||||
{isTokenInvalid && (
|
||||
<div className="flex flex-col md:w-100">
|
||||
<div className="mx-auto w-full">
|
||||
<div className="mb-3 flex h-20 w-20 items-center justify-center rounded-[20px] border border-divider-regular bg-components-option-card-option-bg p-5 text-[40px] font-bold shadow-lg">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import {
|
||||
fetchInitValidateStatus,
|
||||
fetchSetupStatus,
|
||||
@@ -19,9 +20,15 @@ vi.mock('@/service/common', () => ({
|
||||
sendForgotPasswordEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
|
||||
const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus)
|
||||
const mockSendForgotPasswordEmail = vi.mocked(sendForgotPasswordEmail)
|
||||
const mockUseDocumentTitle = vi.mocked(useDocumentTitle)
|
||||
|
||||
const prepareLoadedState = () => {
|
||||
mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' } as SetupStatusResponse)
|
||||
@@ -39,8 +46,10 @@ describe('ForgotPasswordForm', () => {
|
||||
it('should render form after loading', async () => {
|
||||
render(<ForgotPasswordForm />)
|
||||
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('common.loading')
|
||||
expect(await screen.findByLabelText('login.email')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('login.forgotPassword')
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('login.forgotPassword')
|
||||
})
|
||||
|
||||
it('should show validation error when email is empty', async () => {
|
||||
@@ -76,6 +85,7 @@ describe('ForgotPasswordForm', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /login\.backToSignIn/ })).toBeInTheDocument()
|
||||
})
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('login.resetLinkSent')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /login\.backToSignIn/ }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/signin')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import * as z from 'zod'
|
||||
import { formContext, useAppForm } from '@/app/components/base/form'
|
||||
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import {
|
||||
fetchInitValidateStatus,
|
||||
@@ -29,6 +30,12 @@ const ForgotPasswordForm = () => {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isEmailSent, setIsEmailSent] = useState(false)
|
||||
const documentTitle = loading
|
||||
? t(($) => $.loading, { ns: 'common' })
|
||||
: isEmailSent
|
||||
? t(($) => $.resetLinkSent, { ns: 'login' })
|
||||
: t(($) => $.forgotPassword, { ns: 'login' })
|
||||
useDocumentTitle(documentTitle)
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { email: '' },
|
||||
|
||||
@@ -4,13 +4,11 @@ import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import ChangePasswordForm from '@/app/forgot-password/ChangePasswordForm'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import Header from '../signin/_header'
|
||||
import ForgotPasswordForm from './ForgotPasswordForm'
|
||||
|
||||
const ForgotPassword = () => {
|
||||
useDocumentTitle('')
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
@@ -11,13 +11,14 @@ import { basePath } from '@/utils/var'
|
||||
import Loading from '../components/base/loading'
|
||||
|
||||
const InitPasswordPopup = () => {
|
||||
useDocumentTitle('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [validated, setValidated] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const { t } = useTranslation()
|
||||
const pageTitle = t(($) => $.adminInitPassword, { ns: 'login' })
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
const handleValidation = async () => {
|
||||
setLoading(true)
|
||||
@@ -50,7 +51,7 @@ const InitPasswordPopup = () => {
|
||||
<div className="mx-12 block min-w-28">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
{t(($) => $.adminInitPassword, { ns: 'login' })}
|
||||
{pageTitle}
|
||||
</label>
|
||||
<div className="relative mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
import InitPasswordPopup from './InitPasswordPopup'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $.adminInitPassword)
|
||||
}
|
||||
|
||||
const Install = () => {
|
||||
return (
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
|
||||
@@ -37,8 +37,9 @@ const accountFormSchema = z.object({
|
||||
})
|
||||
|
||||
const InstallForm = () => {
|
||||
useDocumentTitle('')
|
||||
const { t, i18n } = useTranslation()
|
||||
const pageTitle = t(($) => $.setAdminAccount, { ns: 'login' })
|
||||
useDocumentTitle(pageTitle)
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const [showPassword, setShowPassword] = React.useState(false)
|
||||
@@ -105,9 +106,7 @@ const InstallForm = () => {
|
||||
) : (
|
||||
<>
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h1 className="text-[32px] font-bold text-text-primary">
|
||||
{t(($) => $.setAdminAccount, { ns: 'login' })}
|
||||
</h1>
|
||||
<h1 className="text-[32px] font-bold text-text-primary">{pageTitle}</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{t(($) => $.setAdminAccountDesc, { ns: 'login' })}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $.setAdminAccount)
|
||||
}
|
||||
|
||||
export default function InstallLayout({ children }: PropsWithChildren) {
|
||||
return children
|
||||
}
|
||||
+36
-12
@@ -1,4 +1,5 @@
|
||||
import type { Viewport } from '@/next'
|
||||
import type { ThemeProviderProps } from 'next-themes'
|
||||
import type { Metadata, Viewport } from '@/next'
|
||||
import { ToastHost } from '@langgenius/dify-ui/toast'
|
||||
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from '@/features/system-features/server'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { headers } from '@/next/headers'
|
||||
import { getApplicationTitle } from '@/utils/document-title'
|
||||
import { CloudAnalytics } from './components/base/analytics-consent/cloud-analytics'
|
||||
import { PartnerStackCookieRecorder } from './components/billing/partner-stack/cookie-recorder'
|
||||
import { AgentationLoader } from './components/devtools/agentation-loader'
|
||||
@@ -29,17 +31,45 @@ export const viewport: Viewport = {
|
||||
viewportFit: 'cover',
|
||||
}
|
||||
|
||||
const ensureSystemFeatures = async () => {
|
||||
const queryClient = getSystemFeaturesQueryClient()
|
||||
const queryOptions = systemFeaturesServerQueryOptions()
|
||||
const queryState = queryClient.getQueryState(queryOptions.queryKey)
|
||||
|
||||
if (!queryState || queryState.status === 'pending') await queryClient.prefetchQuery(queryOptions)
|
||||
|
||||
return { queryClient, queryOptions }
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { queryClient, queryOptions } = await ensureSystemFeatures()
|
||||
const systemFeatures = queryClient.getQueryData(queryOptions.queryKey)
|
||||
const applicationTitle = getApplicationTitle(systemFeatures?.branding)
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: applicationTitle,
|
||||
template: `%s - ${applicationTitle}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const datasetMap = getDatasetMap()
|
||||
const queryClient = getSystemFeaturesQueryClient()
|
||||
const systemFeaturesQuery = systemFeaturesServerQueryOptions()
|
||||
const [locale, requestHeaders] = await Promise.all([
|
||||
const [locale, requestHeaders, { queryClient }] = await Promise.all([
|
||||
getLocaleOnServer(),
|
||||
headers(),
|
||||
queryClient.prefetchQuery(systemFeaturesQuery),
|
||||
ensureSystemFeatures(),
|
||||
])
|
||||
const dehydratedState = dehydrate(queryClient)
|
||||
const nonce = IS_PROD ? (requestHeaders.get('x-nonce') ?? undefined) : undefined
|
||||
const themeProviderProps: Omit<ThemeProviderProps, 'children'> = {
|
||||
attribute: 'data-theme',
|
||||
defaultTheme: 'system',
|
||||
enableSystem: true,
|
||||
disableTransitionOnChange: true,
|
||||
}
|
||||
if (nonce !== undefined) themeProviderProps.nonce = nonce
|
||||
|
||||
return (
|
||||
<html lang={locale ?? 'en'} className="h-full" suppressHydrationWarning>
|
||||
@@ -50,13 +80,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<CloudAnalytics />
|
||||
<div className="isolate h-full">
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
nonce={nonce}
|
||||
>
|
||||
<ThemeProvider {...themeProviderProps}>
|
||||
<NuqsAdapter>
|
||||
<TanStackQueryProvider>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $.signBtn)
|
||||
}
|
||||
|
||||
export default function OAuthCallbackLayout({ children }: PropsWithChildren) {
|
||||
return children
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useOAuthCallback } from '@/hooks/use-oauth'
|
||||
|
||||
const OAuthCallback = () => {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t(($) => $.signBtn, { ns: 'login' }))
|
||||
useOAuthCallback()
|
||||
|
||||
return <div />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $['checkCode.checkYourEmail'])
|
||||
}
|
||||
|
||||
export default function CheckCodeLayout({ children }: PropsWithChildren) {
|
||||
return children
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Countdown from '@/app/components/signin/countdown'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { sendResetPasswordCode, verifyResetPasswordCode } from '@/service/common'
|
||||
|
||||
@@ -19,6 +20,8 @@ export default function CheckCode() {
|
||||
const [code, setVerifyCode] = useState('')
|
||||
const [loading, setIsLoading] = useState(false)
|
||||
const locale = useLocale()
|
||||
const pageTitle = t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
@@ -63,9 +66,7 @@ export default function CheckCode() {
|
||||
<RiMailSendFill className="size-6 text-2xl" />
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">
|
||||
{t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })}
|
||||
</h1>
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">{pageTitle}</h1>
|
||||
<p className="mt-2 body-md-regular text-text-secondary">
|
||||
<span>
|
||||
{t(($) => $['checkCode.tipsPrefix'], { ns: 'login' })}
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import Header from '../signin/_header'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
import ResetPasswordLayout from './reset-password-layout'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $.resetPassword)
|
||||
}
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle',
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full grow flex-col items-center justify-center',
|
||||
'px-6',
|
||||
'md:px-27',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col md:w-100">{children}</div>
|
||||
</div>
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
|
||||
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
return <ResetPasswordLayout>{children}</ResetPasswordLayout>
|
||||
}
|
||||
|
||||
@@ -16,13 +16,14 @@ import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '../components/signi
|
||||
|
||||
export default function CheckCode() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle('')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setIsLoading] = useState(false)
|
||||
const locale = useLocale()
|
||||
const setCountdownLeftTime = useSetCountdownLeftTime()
|
||||
const pageTitle = t(($) => $.resetPassword, { ns: 'login' })
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
const handleGetEMailVerificationCode = async () => {
|
||||
try {
|
||||
@@ -59,9 +60,7 @@ export default function CheckCode() {
|
||||
<RiLockPasswordLine className="size-6 text-2xl text-text-accent-light-mode-only" />
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">
|
||||
{t(($) => $.resetPassword, { ns: 'login' })}
|
||||
</h1>
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">{pageTitle}</h1>
|
||||
<p className="mt-2 body-md-regular text-text-secondary">
|
||||
{t(($) => $.resetPasswordDesc, { ns: 'login' })}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import Header from '../signin/_header'
|
||||
|
||||
export default function ResetPasswordLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle',
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full grow flex-col items-center justify-center',
|
||||
'px-6',
|
||||
'md:px-27',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col md:w-100">{children}</div>
|
||||
</div>
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
|
||||
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { changePasswordWithToken } from '@/service/common'
|
||||
import ChangePasswordForm from '../page'
|
||||
@@ -23,10 +24,15 @@ vi.mock('@/service/common', () => ({
|
||||
changePasswordWithToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockUseRouter = vi.mocked(useRouter)
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
const mockChangePasswordWithToken = vi.mocked(changePasswordWithToken)
|
||||
const mockUseDocumentTitle = vi.mocked(useDocumentTitle)
|
||||
|
||||
const redirectUrl = '/apps?template-id=template-1&utm_source=dify_blog'
|
||||
const encodedSigninUrl =
|
||||
@@ -55,6 +61,7 @@ const completePasswordChange = async () => {
|
||||
expect(screen.getByRole('button', { name: /login\.passwordChanged/ })).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('login.passwordChangedTip')
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('login.passwordChangedTip')
|
||||
}
|
||||
|
||||
describe('Reset Password Set Password Page', () => {
|
||||
@@ -68,6 +75,12 @@ describe('Reset Password Set Password Page', () => {
|
||||
setSearchParams({ token: 'reset-token' })
|
||||
})
|
||||
|
||||
it('reconciles the initial route title with client branding', () => {
|
||||
render(<ChangePasswordForm />)
|
||||
|
||||
expect(mockUseDocumentTitle).toHaveBeenCalledWith('login.changePassword')
|
||||
})
|
||||
|
||||
describe('Post-reset navigation', () => {
|
||||
it('should preserve redirect_url when the user returns to sign in manually', async () => {
|
||||
setSearchParams({ token: 'reset-token', redirect_url: redirectUrl })
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $.changePassword)
|
||||
}
|
||||
|
||||
export default function SetPasswordLayout({ children }: PropsWithChildren) {
|
||||
return children
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { validPassword } from '@/config'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { changePasswordWithToken } from '@/service/common'
|
||||
|
||||
@@ -22,6 +23,11 @@ const ChangePasswordForm = () => {
|
||||
const [showSuccess, setShowSuccess] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
useDocumentTitle(
|
||||
showSuccess
|
||||
? t(($) => $.passwordChangedTip, { ns: 'login' })
|
||||
: t(($) => $.changePassword, { ns: 'login' }),
|
||||
)
|
||||
|
||||
const showErrorMessage = useCallback((message: string) => {
|
||||
toast.error(message)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SelectorParam } from 'i18next'
|
||||
import type { Namespace } from '@/i18n-config/resources'
|
||||
import type { Metadata } from '@/next'
|
||||
import { getLocaleOnServer, getTranslation } from '@/i18n-config/server'
|
||||
import 'server-only'
|
||||
|
||||
export async function getRouteMetadata<T extends Namespace>(
|
||||
namespace: T,
|
||||
selector: SelectorParam<T>,
|
||||
): Promise<Metadata> {
|
||||
const locale = await getLocaleOnServer()
|
||||
const { t } = await getTranslation(locale, namespace)
|
||||
|
||||
return { title: t(selector, { ns: namespace }) }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import SignInPage from '../sign-in-page'
|
||||
|
||||
const navigationMocks = vi.hoisted(() => ({
|
||||
searchParams: new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: () => navigationMocks.searchParams,
|
||||
}))
|
||||
|
||||
vi.mock('../normal-form', () => ({
|
||||
default: () => <div>Sign-in form</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../one-more-step', () => ({
|
||||
default: () => <div>One more step</div>,
|
||||
}))
|
||||
|
||||
describe('SignIn', () => {
|
||||
beforeEach(() => {
|
||||
navigationMocks.searchParams = new URLSearchParams()
|
||||
})
|
||||
|
||||
it('renders the sign-in form by default', () => {
|
||||
render(<SignInPage />)
|
||||
|
||||
expect(screen.getByText('Sign-in form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the additional setup step when requested', () => {
|
||||
navigationMocks.searchParams = new URLSearchParams({ step: 'next' })
|
||||
|
||||
render(<SignInPage />)
|
||||
|
||||
expect(screen.getByText('One more step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
|
||||
import { seedSystemFeatures } from '@/test/console/query-data'
|
||||
import CheckCode from '../page'
|
||||
|
||||
const navigationMocks = vi.hoisted(() => ({
|
||||
@@ -28,7 +30,7 @@ type TurnstileOptions = {
|
||||
}
|
||||
|
||||
const turnstileMocks = vi.hoisted(() => ({
|
||||
deploymentEdition: 'COMMUNITY',
|
||||
deploymentEdition: 'COMMUNITY' as DeploymentEdition,
|
||||
remove: vi.fn(),
|
||||
render: vi.fn(),
|
||||
scriptProps: undefined as ScriptProps | undefined,
|
||||
@@ -39,13 +41,6 @@ vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/client', () => ({
|
||||
systemFeaturesQueryOptions: () => ({
|
||||
queryKey: ['system-features'],
|
||||
queryFn: () => Promise.resolve({ deployment_edition: turnstileMocks.deploymentEdition }),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/config')>()),
|
||||
get TURNSTILE_SITE_KEY() {
|
||||
@@ -99,7 +94,7 @@ function createQueryClient() {
|
||||
},
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(['system-features'], {
|
||||
seedSystemFeatures(queryClient, {
|
||||
deployment_edition: turnstileMocks.deploymentEdition,
|
||||
})
|
||||
return queryClient
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $['checkCode.checkYourEmail'])
|
||||
}
|
||||
|
||||
export default function CheckCodeLayout({ children }: PropsWithChildren) {
|
||||
return children
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { TURNSTILE_SITE_KEY } from '@/config'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
|
||||
import { encryptVerificationCode } from '@/utils/encryption'
|
||||
@@ -45,6 +46,8 @@ export default function CheckCode() {
|
||||
const isTurnstileRequired = systemFeatures.deployment_edition === 'CLOUD'
|
||||
const shouldRenderResendTurnstile =
|
||||
isTurnstileRequired && Boolean(turnstileSiteKey) && showResendTurnstile
|
||||
const pageTitle = t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
@@ -133,9 +136,7 @@ export default function CheckCode() {
|
||||
<RiMailSendFill className="size-6 text-2xl text-text-accent-light-mode-only" />
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">
|
||||
{t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })}
|
||||
</h1>
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">{pageTitle}</h1>
|
||||
<p className="mt-2 body-md-regular text-text-secondary">
|
||||
<span>
|
||||
{t(($) => $['checkCode.tipsPrefix'], { ns: 'login' })}
|
||||
|
||||
@@ -9,6 +9,18 @@ import { useInvitationCheck } from '@/service/use-common'
|
||||
import { getBrowserTimezone } from '@/utils/timezone'
|
||||
import InviteSettingsPage from '../page'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
...createReactI18nextMock({
|
||||
'login.joinWorkspace': 'Rejoindre {{workspaceName}}',
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
|
||||
@@ -21,6 +33,7 @@ vi.mock('@tanstack/react-query', async () => {
|
||||
useSuspenseQuery: vi.fn(() => ({
|
||||
data: {
|
||||
branding: {
|
||||
application_title: 'Acme AI',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
@@ -78,6 +91,7 @@ const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction<
|
||||
describe('InviteSettingsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.title = ''
|
||||
mockUseLocale.mockReturnValue('zh-Hans')
|
||||
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<
|
||||
typeof useRouter
|
||||
@@ -117,6 +131,27 @@ describe('InviteSettingsPage', () => {
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
|
||||
expect(document.title).toBe('login.setYourAccount - Acme AI')
|
||||
})
|
||||
|
||||
it('uses the workspace invitation as the page title for an active account', () => {
|
||||
mockUseInvitationCheck.mockReturnValue({
|
||||
data: {
|
||||
is_valid: true,
|
||||
data: {
|
||||
workspace_name: 'Acme',
|
||||
workspace_id: 'workspace-id',
|
||||
email: 'invitee@example.com',
|
||||
account_status: 'active',
|
||||
requires_setup: false,
|
||||
},
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useInvitationCheck>)
|
||||
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
expect(document.title).toBe('Rejoindre Acme - Acme AI')
|
||||
})
|
||||
|
||||
describe('Activation payload', () => {
|
||||
@@ -126,7 +161,7 @@ describe('InviteSettingsPage', () => {
|
||||
fireEvent.change(screen.getByLabelText('login.name'), {
|
||||
target: { value: 'Invitee' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rejoindre Acme' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateMember).toHaveBeenCalledWith({
|
||||
@@ -149,7 +184,7 @@ describe('InviteSettingsPage', () => {
|
||||
fireEvent.change(screen.getByLabelText('login.name'), {
|
||||
target: { value: 'Invitee' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rejoindre Acme' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateMember).toHaveBeenCalledWith({
|
||||
@@ -182,7 +217,7 @@ describe('InviteSettingsPage', () => {
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
expect(screen.queryByLabelText('login.name')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rejoindre Acme' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateMember).toHaveBeenCalledWith({
|
||||
@@ -211,7 +246,7 @@ describe('InviteSettingsPage', () => {
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
expect(screen.queryByLabelText('login.name')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rejoindre Acme' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateMember).toHaveBeenCalledWith({
|
||||
@@ -243,7 +278,7 @@ describe('InviteSettingsPage', () => {
|
||||
fireEvent.change(screen.getByLabelText('login.name'), {
|
||||
target: { value: 'Invitee' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rejoindre Acme' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateMember).toHaveBeenCalledWith({
|
||||
@@ -272,7 +307,7 @@ describe('InviteSettingsPage', () => {
|
||||
fireEvent.change(screen.getByLabelText('login.name'), {
|
||||
target: { value: 'Invitee' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rejoindre Acme' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/')
|
||||
@@ -298,7 +333,7 @@ describe('InviteSettingsPage', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/signin?invite_token=invite-token')
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: 'login.join Acme' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Rejoindre Acme' })).not.toBeInTheDocument()
|
||||
expect(mockActivateMember).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -316,7 +351,7 @@ describe('InviteSettingsPage', () => {
|
||||
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'login.join Acme' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Rejoindre Acme' })).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import { LICENSE_LINK } from '@/constants/link'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { i18n, setLocaleOnClient } from '@/i18n-config'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import Link from '@/next/link'
|
||||
@@ -115,6 +116,21 @@ export default function InviteSettingsPage() {
|
||||
)
|
||||
const requiresAccountSetup =
|
||||
checkRes?.data?.requires_setup ?? checkRes?.data?.account_status === 'pending'
|
||||
const setupAccountTitle = t(($) => $.setYourAccount, { ns: 'login' })
|
||||
const workspaceInvitationTitle = checkRes?.data?.workspace_name
|
||||
? t(($) => $.joinWorkspace, {
|
||||
ns: 'login',
|
||||
workspaceName: checkRes.data.workspace_name,
|
||||
})
|
||||
: setupAccountTitle
|
||||
const documentTitle = !checkRes
|
||||
? setupAccountTitle
|
||||
: !checkRes.is_valid
|
||||
? t(($) => $.invalid, { ns: 'login' })
|
||||
: requiresAccountSetup || !checkRes.data?.workspace_name
|
||||
? setupAccountTitle
|
||||
: workspaceInvitationTitle
|
||||
useDocumentTitle(documentTitle)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldReturnToSignIn) return
|
||||
@@ -198,7 +214,7 @@ export default function InviteSettingsPage() {
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">
|
||||
{requiresAccountSetup
|
||||
? t(($) => $.setYourAccount, { ns: 'login' })
|
||||
: `${t(($) => $.join, { ns: 'login' })}${checkRes?.data?.workspace_name}`}
|
||||
: workspaceInvitationTitle}
|
||||
</h1>
|
||||
</div>
|
||||
<form onSubmit={noop}>
|
||||
@@ -284,7 +300,7 @@ export default function InviteSettingsPage() {
|
||||
loading={isActivating}
|
||||
disabled={isActivating}
|
||||
>
|
||||
{`${t(($) => $.join, { ns: 'login' })} ${checkRes?.data?.workspace_name}`}
|
||||
{workspaceInvitationTitle}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import Header from './_header'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
useDocumentTitle('')
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
|
||||
+16
-11
@@ -1,14 +1,19 @@
|
||||
'use client'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import NormalForm from './normal-form'
|
||||
import OneMoreStep from './one-more-step'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
import SignInPage from './sign-in-page'
|
||||
|
||||
const SignIn = () => {
|
||||
const searchParams = useSearchParams()
|
||||
const step = searchParams.get('step')
|
||||
|
||||
if (step === 'next') return <OneMoreStep />
|
||||
return <NormalForm />
|
||||
type SignInPageProps = {
|
||||
searchParams: Promise<{ step?: string | string[] }>
|
||||
}
|
||||
|
||||
export default SignIn
|
||||
export async function generateMetadata({ searchParams }: SignInPageProps) {
|
||||
const { step: stepParam } = await searchParams
|
||||
const step = Array.isArray(stepParam) ? stepParam[0] : stepParam
|
||||
|
||||
return step === 'next'
|
||||
? getRouteMetadata('login', ($) => $.oneMoreStep)
|
||||
: getRouteMetadata('login', ($) => $.signBtn)
|
||||
}
|
||||
|
||||
export default function SignIn() {
|
||||
return <SignInPage />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import NormalForm from './normal-form'
|
||||
import OneMoreStep from './one-more-step'
|
||||
|
||||
const SignInPage = () => {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
if (searchParams.get('step') === 'next') return <OneMoreStep />
|
||||
return <NormalForm />
|
||||
}
|
||||
|
||||
export default SignInPage
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $['checkCode.checkYourEmail'])
|
||||
}
|
||||
|
||||
export default function CheckCodeLayout({ children }: PropsWithChildren) {
|
||||
return children
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Countdown from '@/app/components/signin/countdown'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useMailValidity, useSendMail } from '@/service/use-common'
|
||||
|
||||
@@ -22,6 +23,8 @@ export default function CheckCode() {
|
||||
const locale = useLocale()
|
||||
const { mutateAsync: submitMail } = useSendMail()
|
||||
const { mutateAsync: verifyCode } = useMailValidity()
|
||||
const pageTitle = t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
@@ -70,9 +73,7 @@ export default function CheckCode() {
|
||||
<RiMailSendFill className="size-6 text-2xl text-text-accent-light-mode-only" />
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">
|
||||
{t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })}
|
||||
</h1>
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">{pageTitle}</h1>
|
||||
<p className="mt-2 body-md-regular text-text-secondary">
|
||||
<span>
|
||||
{t(($) => $['checkCode.tipsPrefix'], { ns: 'login' })}
|
||||
|
||||
@@ -1,34 +1,10 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import Header from '@/app/signin/_header'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
import SignupLayout from './signup-layout'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $['signup.createAccount'])
|
||||
}
|
||||
|
||||
export default function RegisterLayout({ children }: any) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
useDocumentTitle('')
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle',
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<div
|
||||
className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-27')}
|
||||
>
|
||||
<div className="flex flex-col md:w-100">{children}</div>
|
||||
</div>
|
||||
{systemFeatures.branding.enabled === false && (
|
||||
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
|
||||
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
return <SignupLayout>{children}</SignupLayout>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import MailForm from './components/input-mail'
|
||||
|
||||
@@ -8,6 +9,8 @@ const Signup = () => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { t } = useTranslation()
|
||||
const pageTitle = t(($) => $['signup.createAccount'], { ns: 'login' })
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
const handleInputMailSubmitted = useCallback(
|
||||
(email: string, result: string) => {
|
||||
@@ -22,9 +25,7 @@ const Signup = () => {
|
||||
return (
|
||||
<div className="mx-auto mt-8 w-full">
|
||||
<div className="mx-auto mb-10 w-full">
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">
|
||||
{t(($) => $['signup.createAccount'], { ns: 'login' })}
|
||||
</h1>
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">{pageTitle}</h1>
|
||||
<p className="mt-2 body-md-regular text-text-tertiary">
|
||||
{t(($) => $['signup.welcome'], { ns: 'login' })}
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useMailRegister } from '@/service/use-common'
|
||||
import { seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { getBrowserTimezone } from '@/utils/timezone'
|
||||
import ChangePasswordForm from '../page'
|
||||
|
||||
@@ -68,6 +69,7 @@ const renderWithQueryClient = (ui: ReactElement) => {
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
seedSystemFeatures(queryClient)
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { getRouteMetadata } from '@/app/route-metadata'
|
||||
|
||||
export function generateMetadata() {
|
||||
return getRouteMetadata('login', ($) => $.changePassword)
|
||||
}
|
||||
|
||||
export default function SetPasswordLayout({ children }: PropsWithChildren) {
|
||||
return children
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import Input from '@/app/components/base/input'
|
||||
import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
|
||||
import { validPassword } from '@/config'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useMailRegister } from '@/service/use-common'
|
||||
@@ -43,6 +44,8 @@ const ChangePasswordForm = () => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const { mutateAsync: register, isPending } = useMailRegister()
|
||||
const pageTitle = t(($) => $.changePassword, { ns: 'login' })
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
const showErrorMessage = useCallback((message: string) => {
|
||||
toast.error(message)
|
||||
@@ -115,9 +118,7 @@ const ChangePasswordForm = () => {
|
||||
>
|
||||
<div className="flex flex-col md:w-100">
|
||||
<div className="mx-auto w-full">
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">
|
||||
{t(($) => $.changePassword, { ns: 'login' })}
|
||||
</h1>
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">{pageTitle}</h1>
|
||||
<p className="mt-2 body-md-regular text-text-secondary">
|
||||
{t(($) => $.changePasswordTip, { ns: 'login' })}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import Header from '@/app/signin/_header'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
|
||||
export default function SignupLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle',
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-27')}>
|
||||
<div className="flex flex-col md:w-100">{children}</div>
|
||||
</div>
|
||||
{systemFeatures.branding.enabled === false && (
|
||||
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
|
||||
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { AgentDetailLayout } from '../layout'
|
||||
|
||||
const mockReplace = vi.hoisted(() => vi.fn())
|
||||
const mockPathname = vi.hoisted(() => ({ value: '/agents/agent-1/configure' }))
|
||||
const mockAgentQuery = vi.hoisted(() => ({
|
||||
data: {
|
||||
name: 'Agent',
|
||||
@@ -18,6 +20,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => mockPathname.value,
|
||||
useRouter: () => ({
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
@@ -32,6 +35,8 @@ vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseDocumentTitle = vi.mocked(useDocumentTitle)
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
@@ -51,6 +56,24 @@ describe('AgentDetailLayout', () => {
|
||||
name: 'Agent',
|
||||
}
|
||||
mockAgentQuery.error = null
|
||||
mockPathname.value = '/agents/agent-1/configure'
|
||||
})
|
||||
|
||||
it.each([
|
||||
['configure', 'agentV2.agentDetail.sections.configure'],
|
||||
['access', 'agentV2.agentDetail.sections.access'],
|
||||
['logs', 'agentV2.agentDetail.sections.logs'],
|
||||
['monitoring', 'agentV2.agentDetail.sections.monitoring'],
|
||||
])('identifies the %s section in the document title', (section, sectionTitle) => {
|
||||
mockPathname.value = `/agents/agent-1/${section}`
|
||||
|
||||
render(
|
||||
<AgentDetailLayout agentId="agent-1">
|
||||
<div>Agent detail content</div>
|
||||
</AgentDetailLayout>,
|
||||
)
|
||||
|
||||
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith(`${sectionTitle} · Agent`)
|
||||
})
|
||||
|
||||
it('should render detail content without owning navigation landmarks', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type AgentDetailLayoutProps = {
|
||||
@@ -17,6 +17,7 @@ const isNotFoundResponse = (error: unknown) => error instanceof Response && erro
|
||||
|
||||
export function AgentDetailLayout({ agentId, children }: AgentDetailLayoutProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const agentQuery = useQuery(
|
||||
consoleQuery.agent.byAgentId.get.queryOptions({
|
||||
@@ -28,8 +29,17 @@ export function AgentDetailLayout({ agentId, children }: AgentDetailLayoutProps)
|
||||
}),
|
||||
)
|
||||
const shouldRedirectToRoster = isNotFoundResponse(agentQuery.error)
|
||||
const section = pathname.endsWith('/access')
|
||||
? 'access'
|
||||
: pathname.endsWith('/logs')
|
||||
? 'logs'
|
||||
: pathname.endsWith('/monitoring')
|
||||
? 'monitoring'
|
||||
: 'configure'
|
||||
const sectionTitle = t(($) => $[`agentDetail.sections.${section}`])
|
||||
const agentTitle = agentQuery.data?.name ?? t(($) => $['agentDetail.documentTitle'])
|
||||
|
||||
useDocumentTitle(agentQuery.data?.name ?? t(($) => $['agentDetail.documentTitle']))
|
||||
useDocumentTitle(`${sectionTitle} · ${agentTitle}`)
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirectToRoster) router.replace('/agents')
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import RosterPage from '../page'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => path,
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('nuqs')>()
|
||||
return {
|
||||
...actual,
|
||||
useQueryState: (name: string) => {
|
||||
if (name === 'keyword') return ['', vi.fn()]
|
||||
if (name === 'filter') return ['all', vi.fn()]
|
||||
if (name === 'created_by_me') return [false, vi.fn()]
|
||||
return ['updated_at', vi.fn()]
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useInfiniteQuery: () => ({
|
||||
data: { pages: [{ data: [], has_more: false, page: 1 }] },
|
||||
error: null,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isPending: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../components/agent-roster-list', () => ({
|
||||
AgentRosterList: () => <div>Agent roster</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../components/roster-toolbar', () => ({
|
||||
RosterToolbar: () => <div>Roster toolbar</div>,
|
||||
}))
|
||||
|
||||
describe('RosterPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses the localized roster title for the page heading', () => {
|
||||
render(<RosterPage />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'agentV2.roster.title' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reconciles the route title with client branding', () => {
|
||||
render(<RosterPage />, {
|
||||
systemFeatures: {
|
||||
branding: {
|
||||
enabled: true,
|
||||
application_title: 'Acme',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(document.title).toBe('agentV2.roster.title - Acme')
|
||||
})
|
||||
})
|
||||
@@ -82,15 +82,15 @@ export default function RosterPage() {
|
||||
const publishedAgents = rosterItems.filter(isAgentPublished).length
|
||||
const draftAgents = Math.max(rosterItems.length - publishedAgents, 0)
|
||||
const filteredRosterItems = getFilteredRosterItems(rosterItems, rosterFilter)
|
||||
|
||||
useDocumentTitle('Agents')
|
||||
const pageTitle = t(($) => $['roster.title'])
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
return (
|
||||
<div className="flex h-0 min-w-0 grow flex-col overflow-hidden bg-background-body">
|
||||
<div className="shrink-0 bg-background-body px-8 pt-4 pb-2">
|
||||
<div className="flex h-6 min-w-0 items-center justify-between gap-4">
|
||||
<h1 className="min-w-0 flex-1 truncate text-[18px]/[21.6px] font-semibold text-text-primary">
|
||||
Agents
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<a
|
||||
href={docLink('/use-dify/build/new-agent/overview')}
|
||||
|
||||
@@ -35,6 +35,10 @@ vi.mock('@/next/navigation', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/permission-state', () => ({
|
||||
workspacePermissionKeysAtom: permissionStateMock.atom,
|
||||
}))
|
||||
|
||||
@@ -31,6 +31,14 @@ const documentQuery = vi.hoisted(() => ({
|
||||
refetch: vi.fn(),
|
||||
}))
|
||||
|
||||
const knowledgeSpaceQuery = vi.hoisted(() => ({
|
||||
data: { id: 'space-1', name: 'Support knowledge' } as { id: string; name: string } | undefined,
|
||||
error: null as unknown,
|
||||
isPending: false,
|
||||
}))
|
||||
|
||||
const useDocumentTitleMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
const taskSnapshotQuery = vi.hoisted(() => ({
|
||||
data: undefined as DocumentProcessingTask | undefined,
|
||||
error: null as unknown,
|
||||
@@ -103,6 +111,9 @@ const documentOptions = vi.hoisted(() =>
|
||||
queryKind: 'document',
|
||||
})),
|
||||
)
|
||||
const knowledgeSpaceOptions = vi.hoisted(() =>
|
||||
vi.fn((options: object) => ({ ...options, queryKind: 'knowledge-space' })),
|
||||
)
|
||||
const taskSnapshotOptions = vi.hoisted(() =>
|
||||
vi.fn((options: object) => ({ ...options, queryKind: 'task-snapshot' })),
|
||||
)
|
||||
@@ -209,6 +220,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
},
|
||||
useMutation: () => reindexMutation,
|
||||
useQuery: (options: { queryKind?: string }) => {
|
||||
if (options.queryKind === 'knowledge-space') return knowledgeSpaceQuery
|
||||
if (options.queryKind === 'task-snapshot') return taskSnapshotQuery
|
||||
if (options.queryKind === 'submission-tasks') return submissionTasksQuery
|
||||
return documentQuery
|
||||
@@ -220,6 +232,9 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
knowledgeFs: {
|
||||
getKnowledgeSpacesById: {
|
||||
queryOptions: knowledgeSpaceOptions,
|
||||
},
|
||||
getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions: {
|
||||
infiniteOptions: revisionsOptions,
|
||||
key: () => ['knowledge-fs', 'revisions'],
|
||||
@@ -252,6 +267,10 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: useDocumentTitleMock,
|
||||
}))
|
||||
|
||||
const activeRevision = (overrides: Partial<Exclude<LogicalDocumentRevision, null>> = {}) => ({
|
||||
contentHash: 'hash-3',
|
||||
createdAt: '2026-07-21T10:00:00Z',
|
||||
@@ -345,6 +364,9 @@ describe('DocumentDetailPage', () => {
|
||||
documentQuery.data = logicalDocument()
|
||||
documentQuery.error = null
|
||||
documentQuery.isPending = false
|
||||
knowledgeSpaceQuery.data = { id: 'space-1', name: 'Support knowledge' }
|
||||
knowledgeSpaceQuery.error = null
|
||||
knowledgeSpaceQuery.isPending = false
|
||||
revisionsQuery.data = { pages: [{ items: [activeRevision()] }] }
|
||||
revisionsQuery.error = null
|
||||
revisionsQuery.hasNextPage = false
|
||||
@@ -375,6 +397,12 @@ describe('DocumentDetailPage', () => {
|
||||
queryClient.invalidateQueries.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('uses the document and knowledge names in the document title', () => {
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(useDocumentTitleMock).toHaveBeenLastCalledWith('sso-enterprise.pdf · Support knowledge')
|
||||
})
|
||||
|
||||
it('loads the document, revisions, chunks, and task status through generated contracts', () => {
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '@/test/console/render'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { KnowledgeSpaceShell } from '../knowledge-space-shell'
|
||||
|
||||
const queryMock = vi.hoisted(() => ({
|
||||
@@ -34,31 +34,68 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
knowledgeFs: {
|
||||
getKnowledgeSpacesById: {
|
||||
queryOptions: queryOptionsMock,
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/service/client')>()
|
||||
return {
|
||||
...original,
|
||||
consoleQuery: {
|
||||
...original.consoleQuery,
|
||||
systemFeatures: original.consoleQuery.systemFeatures,
|
||||
knowledgeFs: {
|
||||
...original.consoleQuery.knowledgeFs,
|
||||
getKnowledgeSpacesById: {
|
||||
...original.consoleQuery.knowledgeFs.getKnowledgeSpacesById,
|
||||
queryOptions: queryOptionsMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn() }))
|
||||
}
|
||||
})
|
||||
|
||||
describe('KnowledgeSpaceShell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.title = ''
|
||||
queryMock.data = undefined
|
||||
queryMock.error = null
|
||||
queryMock.isPending = false
|
||||
pathnameMock.value = '/datasets/new/space-1/sources'
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/datasets/new/space-1/sources', 'dataset.newKnowledge.sources'],
|
||||
['/datasets/new/space-1/sources/new', 'dataset.newKnowledge.addSource'],
|
||||
['/datasets/new/space-1/documents', 'dataset.newKnowledge.documents'],
|
||||
])('identifies the current detail page for %s', async (pathname, pageTitle) => {
|
||||
pathnameMock.value = pathname
|
||||
queryMock.data = { id: 'space-1', name: 'Support knowledge' }
|
||||
|
||||
renderWithConsoleQuery(
|
||||
<KnowledgeSpaceShell knowledgeSpaceId="space-1">content</KnowledgeSpaceShell>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe(`${pageTitle} · Support knowledge - Dify`)
|
||||
})
|
||||
})
|
||||
|
||||
it('delegates a document detail title to the document page', () => {
|
||||
pathnameMock.value = '/datasets/new/space-1/documents/document-1'
|
||||
queryMock.data = { id: 'space-1', name: 'Support knowledge' }
|
||||
|
||||
renderWithConsoleQuery(
|
||||
<KnowledgeSpaceShell knowledgeSpaceId="space-1">document content</KnowledgeSpaceShell>,
|
||||
)
|
||||
|
||||
expect(document.title).toBe('')
|
||||
})
|
||||
|
||||
it('loads the real knowledge space contract by route id', () => {
|
||||
queryMock.isPending = true
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">content</KnowledgeSpaceShell>)
|
||||
renderWithConsoleQuery(
|
||||
<KnowledgeSpaceShell knowledgeSpaceId="space-1">content</KnowledgeSpaceShell>,
|
||||
)
|
||||
|
||||
expect(queryOptionsMock).toHaveBeenCalledWith({ input: { params: { id: 'space-1' } } })
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
@@ -67,7 +104,9 @@ describe('KnowledgeSpaceShell', () => {
|
||||
it('renders a refresh-safe header and route navigation when loaded', () => {
|
||||
queryMock.data = { id: 'space-1', name: 'Support knowledge' }
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>)
|
||||
renderWithConsoleQuery(
|
||||
<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Support knowledge' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.sources' })).toHaveAttribute(
|
||||
@@ -88,7 +127,9 @@ describe('KnowledgeSpaceShell', () => {
|
||||
it('shows a not-found state without rendering children', () => {
|
||||
queryMock.error = { status: 404 }
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="missing">source content</KnowledgeSpaceShell>)
|
||||
renderWithConsoleQuery(
|
||||
<KnowledgeSpaceShell knowledgeSpaceId="missing">source content</KnowledgeSpaceShell>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('dataset.newKnowledge.notFoundTitle')).toBeInTheDocument()
|
||||
expect(screen.queryByText('source content')).not.toBeInTheDocument()
|
||||
@@ -97,7 +138,9 @@ describe('KnowledgeSpaceShell', () => {
|
||||
it('recognizes the nested status shape returned by the ORPC client', () => {
|
||||
queryMock.error = { data: { status: 404 } }
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="missing">source content</KnowledgeSpaceShell>)
|
||||
renderWithConsoleQuery(
|
||||
<KnowledgeSpaceShell knowledgeSpaceId="missing">source content</KnowledgeSpaceShell>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('dataset.newKnowledge.notFoundTitle')).toBeInTheDocument()
|
||||
})
|
||||
@@ -105,7 +148,9 @@ describe('KnowledgeSpaceShell', () => {
|
||||
it('treats forbidden detail responses as a terminal non-disclosing state', () => {
|
||||
queryMock.error = { data: { status: 403 } }
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="private">source content</KnowledgeSpaceShell>)
|
||||
renderWithConsoleQuery(
|
||||
<KnowledgeSpaceShell knowledgeSpaceId="private">source content</KnowledgeSpaceShell>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('dataset.newKnowledge.notFoundTitle')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.retry' })).not.toBeInTheDocument()
|
||||
@@ -116,7 +161,9 @@ describe('KnowledgeSpaceShell', () => {
|
||||
(error) => {
|
||||
queryMock.error = error
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="private">source content</KnowledgeSpaceShell>)
|
||||
renderWithConsoleQuery(
|
||||
<KnowledgeSpaceShell knowledgeSpaceId="private">source content</KnowledgeSpaceShell>,
|
||||
)
|
||||
|
||||
const options = useQueryOptionsMock.mock.lastCall?.[0] as {
|
||||
retry: (failureCount: number, queryError: unknown) => boolean
|
||||
@@ -131,7 +178,9 @@ describe('KnowledgeSpaceShell', () => {
|
||||
pathnameMock.value = '/datasets/new/space-1/documents'
|
||||
queryMock.data = { id: 'space-1', name: 'Support knowledge' }
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">document content</KnowledgeSpaceShell>)
|
||||
renderWithConsoleQuery(
|
||||
<KnowledgeSpaceShell knowledgeSpaceId="space-1">document content</KnowledgeSpaceShell>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.sources' })).not.toHaveAttribute(
|
||||
'aria-current',
|
||||
@@ -146,7 +195,9 @@ describe('KnowledgeSpaceShell', () => {
|
||||
const user = userEvent.setup()
|
||||
queryMock.error = new Error('temporary failure')
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>)
|
||||
renderWithConsoleQuery(
|
||||
<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
|
||||
expect(queryMock.refetch).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
|
||||
@@ -88,6 +89,7 @@ async function uploadCreatedDocuments(knowledgeSpaceId: string, files: File[]) {
|
||||
export function CreateKnowledgePage() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
useDocumentTitle(t(($) => $['newKnowledge.createTitle']))
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { datasetDefaultPermissionKeysAtom } from '@/context/permission-state'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
|
||||
import { DocumentDetailHeader } from './document-detail-header'
|
||||
@@ -76,6 +77,14 @@ export function DocumentDetailPage({
|
||||
[documentId, knowledgeSpaceId],
|
||||
)
|
||||
const documentQuery = useQuery(documentQueryOptions)
|
||||
const knowledgeSpaceQuery = useQuery(
|
||||
consoleQuery.knowledgeFs.getKnowledgeSpacesById.queryOptions({
|
||||
input: { params: { id: knowledgeSpaceId } },
|
||||
}),
|
||||
)
|
||||
const documentTitle = documentQuery.data?.title ?? t(($) => $['newKnowledge.documents'])
|
||||
const knowledgeSpaceTitle = knowledgeSpaceQuery.data?.name ?? t(($) => $.knowledge)
|
||||
useDocumentTitle(`${documentTitle} · ${knowledgeSpaceTitle}`)
|
||||
const revisionsQueryOptions = useMemo(
|
||||
() =>
|
||||
consoleQuery.knowledgeFs.getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions.infiniteOptions(
|
||||
|
||||
@@ -22,6 +22,26 @@ function responseStatus(error: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
const knowledgeSpacePageTitle = (
|
||||
pathname: string,
|
||||
t: ReturnType<typeof useTranslation<'dataset'>>['t'],
|
||||
) => {
|
||||
if (pathname.includes('/sources/new')) return t(($) => $['newKnowledge.addSource'])
|
||||
if (pathname.includes('/sources')) return t(($) => $['newKnowledge.sources'])
|
||||
if (pathname.includes('/documents')) return t(($) => $['newKnowledge.documents'])
|
||||
|
||||
return t(($) => $.knowledge)
|
||||
}
|
||||
|
||||
const isDocumentDetailPath = (pathname: string) =>
|
||||
/^\/datasets\/new\/[^/]+\/documents\/[^/]+\/?$/.test(pathname)
|
||||
|
||||
function KnowledgeSpacePageTitle({ title }: { title: string }) {
|
||||
useDocumentTitle(title)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function KnowledgeSpaceShell({
|
||||
children,
|
||||
knowledgeSpaceId,
|
||||
@@ -43,44 +63,59 @@ export function KnowledgeSpaceShell({
|
||||
return failureCount < 3
|
||||
},
|
||||
})
|
||||
useDocumentTitle(knowledgeSpaceQuery.data?.name ?? t(($) => $.knowledge))
|
||||
const pageTitle = knowledgeSpacePageTitle(pathname, t)
|
||||
const documentTitle = `${pageTitle} · ${knowledgeSpaceQuery.data?.name ?? t(($) => $.knowledge)}`
|
||||
const documentTitleOwnedByChild =
|
||||
isDocumentDetailPath(pathname) &&
|
||||
!knowledgeSpaceQuery.isPending &&
|
||||
!knowledgeSpaceQuery.error &&
|
||||
!!knowledgeSpaceQuery.data
|
||||
const pageTitleElement = !documentTitleOwnedByChild ? (
|
||||
<KnowledgeSpacePageTitle title={documentTitle} />
|
||||
) : null
|
||||
|
||||
if (knowledgeSpaceQuery.isPending)
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
<>
|
||||
{pageTitleElement}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (knowledgeSpaceQuery.error || !knowledgeSpaceQuery.data) {
|
||||
const status = responseStatus(knowledgeSpaceQuery.error)
|
||||
const notFound = status === 403 || status === 404
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<span aria-hidden className="i-ri-book-open-line size-8 text-text-tertiary" />
|
||||
<h1 className="mt-4 title-2xl-semi-bold text-text-primary">
|
||||
{t(($) =>
|
||||
notFound ? $['newKnowledge.notFoundTitle'] : $['newKnowledge.detailErrorTitle'],
|
||||
)}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-md body-sm-regular text-text-tertiary">
|
||||
{t(($) =>
|
||||
notFound
|
||||
? $['newKnowledge.notFoundDescription']
|
||||
: $['newKnowledge.detailErrorDescription'],
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-5 flex gap-2">
|
||||
<Button render={<Link href={newKnowledgeListPath} />}>
|
||||
{t(($) => $['newKnowledge.backToList'])}
|
||||
</Button>
|
||||
{!notFound && (
|
||||
<Button variant="primary" onClick={() => void knowledgeSpaceQuery.refetch()}>
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
<>
|
||||
{pageTitleElement}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<span aria-hidden className="i-ri-book-open-line size-8 text-text-tertiary" />
|
||||
<h1 className="mt-4 title-2xl-semi-bold text-text-primary">
|
||||
{t(($) =>
|
||||
notFound ? $['newKnowledge.notFoundTitle'] : $['newKnowledge.detailErrorTitle'],
|
||||
)}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-md body-sm-regular text-text-tertiary">
|
||||
{t(($) =>
|
||||
notFound
|
||||
? $['newKnowledge.notFoundDescription']
|
||||
: $['newKnowledge.detailErrorDescription'],
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-5 flex gap-2">
|
||||
<Button render={<Link href={newKnowledgeListPath} />}>
|
||||
{t(($) => $['newKnowledge.backToList'])}
|
||||
</Button>
|
||||
)}
|
||||
{!notFound && (
|
||||
<Button variant="primary" onClick={() => void knowledgeSpaceQuery.refetch()}>
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,6 +129,7 @@ export function KnowledgeSpaceShell({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background-body p-1">
|
||||
{pageTitleElement}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-1 overflow-hidden sm:flex-row">
|
||||
<aside className="flex shrink-0 flex-col overflow-hidden rounded-lg bg-components-panel-bg shadow-xs sm:w-60">
|
||||
<div className="flex h-12 min-w-0 items-center px-1 pr-2">
|
||||
|
||||
@@ -24,4 +24,12 @@ describe('useDocumentTitle', () => {
|
||||
rerender({ title: '' })
|
||||
expect(document.title).toBe('Acme')
|
||||
})
|
||||
|
||||
it('preserves route metadata when no client title is provided', () => {
|
||||
document.title = 'Route title - Dify'
|
||||
|
||||
renderHookWithConsoleQuery(() => useDocumentTitle(null))
|
||||
|
||||
expect(document.title).toBe('Route title - Dify')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
'use client'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useFavicon, useTitle } from 'ahooks'
|
||||
import { useFavicon } from 'ahooks'
|
||||
import { useEffect } from 'react'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { formatDocumentTitle, getApplicationTitle } from '@/utils/document-title'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
export default function useDocumentTitle(title: string) {
|
||||
export default function useDocumentTitle(title: string | null) {
|
||||
const { data } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const branding = data.branding
|
||||
const prefix = title ? `${title} - ` : ''
|
||||
const titleStr = branding.enabled ? `${prefix}${branding.application_title}` : `${prefix}Dify`
|
||||
const titleStr = title === null ? null : formatDocumentTitle(title, getApplicationTitle(branding))
|
||||
const favicon = branding.enabled ? branding.favicon : `${basePath}/favicon.ico`
|
||||
useTitle(titleStr)
|
||||
useEffect(() => {
|
||||
if (titleStr !== null) document.title = titleStr
|
||||
}, [titleStr])
|
||||
useEffect(() => {
|
||||
let apple: HTMLLinkElement | null = null
|
||||
if (branding.favicon) {
|
||||
|
||||
@@ -425,6 +425,7 @@
|
||||
"roster.sort.lastModified": "آخر تعديل",
|
||||
"roster.sort.optionsLabel": "خيارات الفرز",
|
||||
"roster.sort.recentlyCreated": "الأحدث إنشاءً",
|
||||
"roster.title": "الوكلاء",
|
||||
"roster.updateSuccess": "تم تحديث الوكيل.",
|
||||
"roster.usageStatus.draft": "مسودة"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user