fix: add semantic document titles across pages (#40396)

This commit is contained in:
Joel
2026-08-14 03:29:16 +00:00
committed by GitHub
parent 755c154b1f
commit 0ac005eb98
149 changed files with 1574 additions and 299 deletions
+1 -1
View File
@@ -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
View File
@@ -777,7 +777,7 @@ export const lintConfig = {
'error',
{
allowConstantExport: true,
allowExportNames: ['viewport'],
allowExportNames: ['generateMetadata', 'viewport'],
},
],
},
+5
View File
@@ -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 />
}
@@ -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
@@ -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)
+25 -3
View File
@@ -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()
})
+64
View File
@@ -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">
+6
View File
@@ -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 () => {
+5 -1
View File
@@ -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,
@@ -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' })
@@ -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]',
+1 -1
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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()
+13 -2
View File
@@ -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: '' },
-2
View File
@@ -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())
+3 -2
View File
@@ -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
+5
View File
@@ -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')}>
+3 -4
View File
@@ -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>
+10
View File
@@ -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
View File
@@ -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}>
+10
View File
@@ -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
}
+4
View File
@@ -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
}
+4 -3
View File
@@ -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' })}
+7 -33
View File
@@ -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>
}
+3 -4
View File
@@ -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)
+15
View File
@@ -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 }) }
}
+39
View File
@@ -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
+10
View File
@@ -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
}
+4 -3
View File
@@ -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()
})
})
+18 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 />
}
+13
View File
@@ -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
+10
View File
@@ -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
}
+4 -3
View File
@@ -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' })}
+7 -31
View File
@@ -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>
}
+4 -3
View File
@@ -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>)
}
+10
View File
@@ -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
}
+4 -3
View File
@@ -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>
+29
View File
@@ -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', () => {
+12 -2
View File
@@ -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')
})
})
+3 -3
View File
@@ -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(
+63 -27
View File
@@ -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">
+8
View File
@@ -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')
})
})
+7 -5
View File
@@ -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) {
+1
View File
@@ -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