From 8f705f7fa15e3fb1e5ef7407484c2ad642a825cb Mon Sep 17 00:00:00 2001 From: Junyi Date: Fri, 14 Aug 2026 20:57:31 +0800 Subject: [PATCH 1/3] fix(plugin-file-manager): preserve sub-app local file routing (#10358) --- .../core/server/src/__tests__/gateway.test.ts | 15 ++++++++++ .../src/server/__tests__/server.test.ts | 28 ++++++++++++++++--- .../src/server/actions/get-file.ts | 12 +++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/core/server/src/__tests__/gateway.test.ts b/packages/core/server/src/__tests__/gateway.test.ts index 6f5586dc5a9..f615fcba5b3 100644 --- a/packages/core/server/src/__tests__/gateway.test.ts +++ b/packages/core/server/src/__tests__/gateway.test.ts @@ -147,6 +147,21 @@ describe('gateway', () => { expect((req as any).originalUrl).toBe('/api/__app/demo/.well-known/oauth-authorization-server'); }); + it('should proxy local storage requests to the selected sub app', async () => { + const req = { + url: '/storage/uploads/logo.png?__appName=demo', + headers: {}, + } as any; + const res = {} as any; + + const supervisor = AppSupervisor.getInstance(); + const proxyWeb = vi.spyOn(supervisor, 'proxyWeb').mockResolvedValue(true); + + await gateway.requestHandler(req, res); + + expect(proxyWeb).toHaveBeenCalledWith('demo', req, res); + }); + it('should add same middleware into app selector once', async () => { const fn = async (ctx, next) => { ctx.resolvedAppName = 'test'; diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/server.test.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/server.test.ts index 4abee9fc249..d4a349b7143 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/server.test.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/server.test.ts @@ -869,6 +869,22 @@ describe('file manager > server', () => { expect(response.headers.location).toBe(storageUrl); }); + it('preserves the sub-application when redirecting to local storage', async () => { + await app.destroy(); + app = await getApp({ name: 'sub-app' }); + agent = app.agent(); + db = app.db; + plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer; + + const { body } = await agent.resource('attachments').create({ + [FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'), + }); + const response = await agent.get(body.data.url); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe(`${await plugin.getFileURL(body.data)}?__appName=sub-app`); + }); + it('keeps permanent URLs without extname compatible', async () => { const admin = await db.getRepository('users').findOne(); const loggedAgent = await app.agent().login(admin); @@ -1133,7 +1149,7 @@ describe('file manager > server', () => { const response = await loggedAgent.get(body.data.url); expect(response.status).toBe(302); - expect(response.headers.location).toBe(await plugin.getFileURL(body.data)); + expect(response.headers.location).toBe(`${await plugin.getFileURL(body.data)}?__appName=subapp`); const wrongAppResponse = await loggedAgent.get(`/files/main/main/attachments/${body.data.id}`); expect(wrongAppResponse.status).toBe(404); @@ -1476,7 +1492,7 @@ describe('file manager > server', () => { .get(file.get('url') as string) .set('Cookie', cookieHeader); expect(response.status).toBe(302); - expect(response.headers.location).toBe(await plugin.getFileURL(file)); + expect(response.headers.location).toBe(`${await plugin.getFileURL(file)}?__appName=subapp`); const fileTemplateRecord = await plugin.createFileRecord({ collectionName: 'files', @@ -1491,7 +1507,9 @@ describe('file manager > server', () => { .get(fileTemplateRecord.get('url') as string) .set('Cookie', cookieHeader); expect(fileTemplateResponse.status).toBe(302); - expect(fileTemplateResponse.headers.location).toBe(await plugin.getFileURL(fileTemplateRecord)); + expect(fileTemplateResponse.headers.location).toBe( + `${await plugin.getFileURL(fileTemplateRecord)}?__appName=subapp`, + ); const dynamicCollectionName = 't_8jbl7u3wx4j'; await createFileTemplateCollection(db, dynamicCollectionName); @@ -1508,7 +1526,9 @@ describe('file manager > server', () => { .get(dynamicFileTemplateRecord.get('url') as string) .set('Cookie', cookieHeader); expect(dynamicFileTemplateResponse.status).toBe(302); - expect(dynamicFileTemplateResponse.headers.location).toBe(await plugin.getFileURL(dynamicFileTemplateRecord)); + expect(dynamicFileTemplateResponse.headers.location).toBe( + `${await plugin.getFileURL(dynamicFileTemplateRecord)}?__appName=subapp`, + ); } finally { app.options.name = originalName; restoreEnv('APP_PUBLIC_PATH', originalPath); diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/get-file.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/get-file.ts index a48ea261f60..b1626a1f613 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/get-file.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/get-file.ts @@ -27,6 +27,15 @@ type StorageFileURLResolver = { }) => Promise; }; +function preserveSubAppInLocalURL(url: string, appName?: string) { + if (!appName || appName === 'main' || !url.startsWith('/') || url.startsWith('//')) { + return url; + } + const [urlWithoutHash, hash] = url.split('#', 2); + const separator = urlWithoutHash.includes('?') ? '&' : '?'; + return `${urlWithoutHash}${separator}__appName=${encodeURIComponent(appName)}${hash ? `#${hash}` : ''}`; +} + export async function getFile(ctx: Context, next: Next) { const collection = ctx.dataSource.collectionManager.getCollection(ctx.action.resourceName); if (!collection || (collection.name !== 'attachments' && collection.options?.template !== 'file')) { @@ -125,13 +134,14 @@ export async function getFile(ctx: Context, next: Next) { const download = !temporaryAccess && ctx.method === 'GET' && ctx.query.download === '1'; const preview = download ? false : temporaryAccess ? false : Boolean(ctx.state.fileAccess?.preview); const dataSource = ctx.dataSource as DataSource & StorageFileURLResolver; - const finalUrl = + const storageUrl = (await dataSource.resolveStorageFileURL?.({ collectionName: collection.name, file: getFilePlainObject(file as AttachmentModel) as Record, preview, download, })) || (await plugin.getFileURL(file, preview, { download })); + const finalUrl = preserveSubAppInLocalURL(storageUrl, ctx.state.fileAccess?.appName); ctx.status = 302; ctx.redirect(finalUrl); await next(); From c9da57de1ef5717827a0286c90b9ea91e37f0c31 Mon Sep 17 00:00:00 2001 From: Junyi Date: Fri, 14 Aug 2026 20:58:35 +0800 Subject: [PATCH 2/3] fix(plugin-workflow): handle workflow page loading states (#10360) * fix(plugin-workflow): handle workflow page loading states * fix(plugin-workflow): link missing workflows to list * fix(plugin-workflow): support v2 workflow compatibility routes * fix(plugin-workflow): clarify workflow list return action --- .../src/client-v2/ExecutionCanvas.tsx | 14 +++--- .../__tests__/ExecutionCanvas.test.tsx | 48 +++++++++++++++++++ .../__tests__/plugin.taskTypes.test.ts | 44 +++++++++++++++++ .../src/client-v2/constants.ts | 4 ++ .../src/client-v2/pages/ExecutionViewPage.tsx | 13 ++++- .../client-v2/pages/WorkflowCanvasPage.tsx | 22 +++++++-- .../__tests__/ExecutionViewPage.test.tsx | 27 +++++++++++ .../__tests__/WorkflowCanvasPage.test.tsx | 24 ++++++++++ .../plugin-workflow/src/client-v2/plugin.tsx | 12 +++++ .../plugin-workflow/src/locale/de-DE.json | 2 + .../plugin-workflow/src/locale/en-US.json | 2 + .../plugin-workflow/src/locale/es-ES.json | 2 + .../plugin-workflow/src/locale/fr-FR.json | 2 + .../plugin-workflow/src/locale/hu-HU.json | 2 + .../plugin-workflow/src/locale/id-ID.json | 2 + .../plugin-workflow/src/locale/it-IT.json | 2 + .../plugin-workflow/src/locale/ja-JP.json | 2 + .../plugin-workflow/src/locale/ko-KR.json | 2 + .../plugin-workflow/src/locale/nl-NL.json | 2 + .../plugin-workflow/src/locale/pt-BR.json | 2 + .../plugin-workflow/src/locale/ru-RU.json | 2 + .../plugin-workflow/src/locale/tr-TR.json | 2 + .../plugin-workflow/src/locale/uk-UA.json | 2 + .../plugin-workflow/src/locale/vi-VN.json | 2 + .../plugin-workflow/src/locale/zh-CN.json | 2 + .../plugin-workflow/src/locale/zh-TW.json | 2 + 26 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-workflow/src/client-v2/__tests__/ExecutionCanvas.test.tsx diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/ExecutionCanvas.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/ExecutionCanvas.tsx index 239d6f077f5..58c70e1ab7e 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/ExecutionCanvas.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/ExecutionCanvas.tsx @@ -7,9 +7,9 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { useApp } from '@nocobase/client-v2'; import { Button, Result } from 'antd'; import React, { useEffect, useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import { CanvasContent } from './canvas/CanvasContent'; import { FlowContext } from './canvas/contexts'; import { linkNodes } from './canvas/nodeTree'; @@ -43,7 +43,7 @@ function attachJobs(nodes: any[], jobs: any[] = []) { export function ExecutionCanvas({ record, resource, refresh }: { record: any; resource: any; refresh: () => void }) { const { t } = useWorkflowTranslation(); - const navigate = useNavigate(); + const app = useApp(); const [viewJob, setViewJob] = useState(null); const { jobs = [], workflow, ...execution } = record ?? {}; @@ -66,17 +66,17 @@ export function ExecutionCanvas({ record, resource, refresh }: { record: any; re } }, [jobs, viewJob?.id]); - const onBack = () => { - navigate(-1); - }; - if (!workflow) { return ( {t('Go back')}} + extra={ + + } /> ); } diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/__tests__/ExecutionCanvas.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/__tests__/ExecutionCanvas.test.tsx new file mode 100644 index 00000000000..3662d999bdb --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/__tests__/ExecutionCanvas.test.tsx @@ -0,0 +1,48 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@nocobase/client-v2', () => ({ + useApp: () => ({ + getHref: (path: string) => `/v${path}`, + pluginSettingsManager: { getRoutePath: () => '/admin/settings/workflow' }, + }), +})); + +vi.mock('../locale', () => ({ + useWorkflowTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('../canvas/CanvasContent', () => ({ + CanvasContent: () => null, +})); + +vi.mock('../components/ExecutionViewHeader', () => ({ + ExecutionViewHeader: () => null, +})); + +vi.mock('../components/JobResultModal', () => ({ + JobResultModal: () => null, +})); + +import { ExecutionCanvas } from '../ExecutionCanvas'; + +describe('ExecutionCanvas', () => { + it('links to the workflow list when the execution workflow does not exist', () => { + render(); + + expect(screen.getByRole('link', { name: 'Back to Workflow List' })).toHaveAttribute( + 'href', + '/v/admin/settings/workflow', + ); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/__tests__/plugin.taskTypes.test.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/__tests__/plugin.taskTypes.test.ts index 925aca1e246..f37bd11af5f 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/__tests__/plugin.taskTypes.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/__tests__/plugin.taskTypes.test.ts @@ -7,9 +7,19 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { createMockClient } from '@nocobase/client-v2'; +import { Outlet } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; import PluginWorkflowClientV2 from '../plugin'; import { + WORKFLOW_CANVAS_ROUTE_NAME, + WORKFLOW_CANVAS_ROUTE_PATH, + WORKFLOW_CANVAS_SETTINGS_ROUTE_NAME, + WORKFLOW_CANVAS_SETTINGS_ROUTE_PATH, + WORKFLOW_EXECUTION_ROUTE_NAME, + WORKFLOW_EXECUTION_ROUTE_PATH, + WORKFLOW_EXECUTION_SETTINGS_ROUTE_NAME, + WORKFLOW_EXECUTION_SETTINGS_ROUTE_PATH, WORKFLOW_TASKS_MOBILE_ROUTE_NAME, WORKFLOW_TASKS_MOBILE_ROUTE_PATH, WORKFLOW_TASKS_ROUTE_NAME, @@ -84,4 +94,38 @@ describe('PluginWorkflowClientV2 task type registry', () => { expect.any(Function), ); }); + + it('registers standalone and settings-compatible workflow detail routes', async () => { + const { app, plugin } = createPlugin(); + + await plugin.load(); + + [ + [WORKFLOW_CANVAS_ROUTE_NAME, WORKFLOW_CANVAS_ROUTE_PATH], + [WORKFLOW_CANVAS_SETTINGS_ROUTE_NAME, WORKFLOW_CANVAS_SETTINGS_ROUTE_PATH], + [WORKFLOW_EXECUTION_ROUTE_NAME, WORKFLOW_EXECUTION_ROUTE_PATH], + [WORKFLOW_EXECUTION_SETTINGS_ROUTE_NAME, WORKFLOW_EXECUTION_SETTINGS_ROUTE_PATH], + ].forEach(([name, path]) => { + expect(app.router.add).toHaveBeenCalledWith( + name, + expect.objectContaining({ path, componentLoader: expect.any(Function) }), + ); + }); + }); + + it('matches a settings-compatible workflow URL inside the v2 basename to the canvas route', () => { + const app = createMockClient({ publicPath: '/v/' }); + app.router.add('admin', { path: '/admin', Component: Outlet }); + app.router.add('admin.settings', { path: '/admin/settings', Component: Outlet }); + app.pluginSettingsManager.addMenuItem({ key: 'workflow', title: 'Workflow' }); + app.pluginSettingsManager.addPageTabItem({ menuKey: 'workflow', key: 'index', title: 'Workflow' }); + app.router.add(WORKFLOW_CANVAS_SETTINGS_ROUTE_NAME, { + path: WORKFLOW_CANVAS_SETTINGS_ROUTE_PATH, + Component: () => null, + }); + + const matches = app.router.matchRoutes('/v/admin/settings/workflow/workflows/342944512737281') || []; + + expect(matches.at(-1)?.route.id).toBe(WORKFLOW_CANVAS_SETTINGS_ROUTE_NAME); + }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/constants.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/constants.ts index 34880011d51..1c68f1ac6f5 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/constants.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/constants.ts @@ -13,6 +13,8 @@ // route. export const WORKFLOW_CANVAS_ROUTE_NAME = 'admin.workflow.workflows.id'; export const WORKFLOW_CANVAS_ROUTE_PATH = '/admin/workflow/workflows/:id'; +export const WORKFLOW_CANVAS_SETTINGS_ROUTE_NAME = 'admin.workflow.settings.workflows.id'; +export const WORKFLOW_CANVAS_SETTINGS_ROUTE_PATH = '/admin/settings/workflow/workflows/:id'; export function getWorkflowCanvasPath(id: string | number) { return `/admin/workflow/workflows/${id}`; @@ -22,6 +24,8 @@ export function getWorkflowCanvasPath(id: string | number) { // `admin.workflow.executions.id` route. export const WORKFLOW_EXECUTION_ROUTE_NAME = 'admin.workflow.executions.id'; export const WORKFLOW_EXECUTION_ROUTE_PATH = '/admin/workflow/executions/:id'; +export const WORKFLOW_EXECUTION_SETTINGS_ROUTE_NAME = 'admin.workflow.settings.executions.id'; +export const WORKFLOW_EXECUTION_SETTINGS_ROUTE_PATH = '/admin/settings/workflow/executions/:id'; export function getWorkflowExecutionPath(id: string | number) { return `/admin/workflow/executions/${id}`; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/ExecutionViewPage.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/ExecutionViewPage.tsx index c84d8789153..737fc8ec3a3 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/ExecutionViewPage.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/ExecutionViewPage.tsx @@ -10,14 +10,14 @@ import { useFlowContext } from '@nocobase/flow-engine'; import { useRequest } from 'ahooks'; import { Spin, theme } from 'antd'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import ExecutionCanvas from '../ExecutionCanvas'; import { normalizeRecordResponse } from '../components/workflowCanvas'; import { useWorkflowTranslation } from '../locale'; export default function ExecutionViewPage() { - useWorkflowTranslation(); + const { t } = useWorkflowTranslation(); const ctx = useFlowContext(); const { token } = theme.useToken(); const params = useParams<{ id?: string }>(); @@ -41,6 +41,15 @@ export default function ExecutionViewPage() { const record = data?.id != null && String(data.id) === String(executionId) ? data : null; + useEffect(() => { + if (!record) { + return; + } + + const workflowTitle = record.workflow?.title; + document.title = `${workflowTitle ? `${workflowTitle} - ` : ''}${t('Execution history')} - NocoBase`; + }, [record, t]); + if (!executionId) { return null; } diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowCanvasPage.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowCanvasPage.tsx index f46d51aeadb..cd2e5339938 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowCanvasPage.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowCanvasPage.tsx @@ -7,9 +7,10 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { useApp } from '@nocobase/client-v2'; import { useFlowContext as useFlowEngineContext } from '@nocobase/flow-engine'; import { useRequest } from 'ahooks'; -import { Spin, theme } from 'antd'; +import { Button, Result, Spin, theme } from 'antd'; import React, { useEffect, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { WorkflowCanvasHeader } from '../components/WorkflowCanvasHeader'; @@ -25,13 +26,14 @@ import { useT } from '../locale'; export default function WorkflowCanvasPage() { const ctx = useFlowEngineContext(); + const app = useApp(); const t = useT(); const { token } = theme.useToken(); const params = useParams<{ id?: string }>(); const workflowId = params.id; const resource = ctx.api.resource('workflows'); - const { data, refresh } = useRequest( + const { data, loading, refresh } = useRequest( async () => { if (!workflowId) { return null; @@ -86,7 +88,21 @@ export default function WorkflowCanvasPage() { } if (!record) { - return ; + if (loading) { + return ; + } + + return ( + + {t('Back to Workflow List')} + + } + /> + ); } return ( diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/ExecutionViewPage.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/ExecutionViewPage.test.tsx index 66070f7af38..4ca188a4257 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/ExecutionViewPage.test.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/ExecutionViewPage.test.tsx @@ -52,6 +52,7 @@ describe('ExecutionViewPage', () => { beforeEach(() => { vi.clearAllMocks(); holder.executionCanvasProps = null; + document.title = ''; }); it('loads execution canvas data and renders ExecutionCanvas instead of the empty placeholder', async () => { @@ -94,4 +95,30 @@ describe('ExecutionViewPage', () => { expect(holder.executionCanvasProps?.record?.id).toBe(369411612409856); expect(screen.queryByText('Workflow canvas editor is being migrated to the new UI.')).toBeNull(); }); + + it('updates the browser title after the execution record is loaded', async () => { + document.title = 'Loading...'; + holder.ctx = { + api: { + resource: () => ({ + get: vi.fn().mockResolvedValue({ + data: { + data: { + id: 369411612409856, + jobs: [], + workflow: { title: 'Approval workflow', nodes: [] }, + }, + }, + }), + }), + }, + }; + + renderWithApp(); + + await screen.findByTestId('execution-canvas'); + await waitFor(() => { + expect(document.title).toBe('Approval workflow - Execution history - NocoBase'); + }); + }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowCanvasPage.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowCanvasPage.test.tsx index e8a3102ccc8..c6a8f96d2e3 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowCanvasPage.test.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowCanvasPage.test.tsx @@ -46,6 +46,17 @@ vi.mock('@nocobase/flow-engine', async (importOriginal) => { }; }); +vi.mock('@nocobase/client-v2', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useApp: () => ({ + getHref: (path: string) => `/v${path}`, + pluginSettingsManager: { getRoutePath: () => '/admin/settings/workflow' }, + }), + }; +}); + vi.mock('../../components/WorkflowCanvasHeader', () => ({ WorkflowCanvasHeader: ({ record }: { record: { title?: string } }) => (
{record.title}
@@ -103,4 +114,17 @@ describe('WorkflowCanvasPage', () => { expect(document.title).toBe('Workflow: 审批 - NocoBase'); }); }); + + it('shows a not-found result with a link back to the workflow list', async () => { + holder.getWorkflow.mockResolvedValue({ data: { data: null } }); + + render(); + + expect(await screen.findByText('Workflow does not exist')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Back to Workflow List' })).toHaveAttribute( + 'href', + '/v/admin/settings/workflow', + ); + expect(holder.listRevisions).not.toHaveBeenCalled(); + }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx index a93874350a9..1eed9f04a2e 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx @@ -14,8 +14,12 @@ import { NAMESPACE } from './locale'; import { WORKFLOW_CANVAS_ROUTE_NAME, WORKFLOW_CANVAS_ROUTE_PATH, + WORKFLOW_CANVAS_SETTINGS_ROUTE_NAME, + WORKFLOW_CANVAS_SETTINGS_ROUTE_PATH, WORKFLOW_EXECUTION_ROUTE_NAME, WORKFLOW_EXECUTION_ROUTE_PATH, + WORKFLOW_EXECUTION_SETTINGS_ROUTE_NAME, + WORKFLOW_EXECUTION_SETTINGS_ROUTE_PATH, WORKFLOW_TASKS_MOBILE_ROUTE_NAME, WORKFLOW_TASKS_MOBILE_ROUTE_PATH, WORKFLOW_TASKS_ROUTE_NAME, @@ -333,6 +337,10 @@ export class PluginWorkflowClientV2 extends Plugin { path: WORKFLOW_CANVAS_ROUTE_PATH, componentLoader: () => import('./pages/WorkflowCanvasPage'), }); + this.app.router.add(WORKFLOW_CANVAS_SETTINGS_ROUTE_NAME, { + path: WORKFLOW_CANVAS_SETTINGS_ROUTE_PATH, + componentLoader: () => import('./pages/WorkflowCanvasPage'), + }); } // The execution detail page, a sibling of the canvas under the same `admin.workflow` namespace — mirrors v1's @@ -342,6 +350,10 @@ export class PluginWorkflowClientV2 extends Plugin { path: WORKFLOW_EXECUTION_ROUTE_PATH, componentLoader: () => import('./pages/ExecutionViewPage'), }); + this.app.router.add(WORKFLOW_EXECUTION_SETTINGS_ROUTE_NAME, { + path: WORKFLOW_EXECUTION_SETTINGS_ROUTE_PATH, + componentLoader: () => import('./pages/ExecutionViewPage'), + }); } private registerTaskCenterRoutes() { diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/de-DE.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/de-DE.json index 5a814b76beb..3e054ff45de 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/de-DE.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/de-DE.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Wird im Hintergrund als Aufgabe in der Warteschlange ausgeführt.", "Workflow": "Workflow", + "Back to Workflow List": "Zurück zur Workflow-Liste", + "Workflow does not exist": "Workflow ist nicht vorhanden", "Workflow not executed": "Workflow nicht ausgeführt", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow ausgeführt, der Ergebnisstatus ist <1>{{statusText}}<2>Ausführung anzeigen", "Workflow was not triggered because the current request did not meet the trigger requirements.": "Der Workflow wurde nicht ausgelöst, weil die aktuelle Anfrage die Auslöseanforderungen nicht erfüllt.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/en-US.json index 7663fd4887b..0e646004109 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/en-US.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/en-US.json @@ -283,6 +283,7 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Will be executed in the background as a queued task.", "Workflow": "Workflow", + "Back to Workflow List": "Back to Workflow List", "This workflow has configuration issues and may not work properly.": "This workflow has configuration issues and may not work properly.", "Workflow title": "Workflow title", "Workflow canvas editor is being migrated to the new UI.": "Workflow canvas editor is being migrated to the new UI.", @@ -290,6 +291,7 @@ "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow executed, the result status is <1>{{statusText}}<2>View the execution", "Workflow was not triggered because the current request did not meet the trigger requirements.": "Workflow was not triggered because the current request did not meet the trigger requirements.", "Workflow of execution is not existed": "Workflow of execution is not existed", + "Workflow does not exist": "Workflow does not exist", "Workflow tasks": "Workflow tasks", "Workflow todos": "Workflow todos", "Workflow will be triggered before deleting succeeded (only supports pre-action event in local mode).": "Workflow will be triggered before deleting succeeded (only supports pre-action event in local mode).", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/es-ES.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/es-ES.json index 8939cb38d78..e9bd347eeee 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/es-ES.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/es-ES.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Will be executed in the background as a queued task.", "Workflow": "Flujo de trabajo", + "Back to Workflow List": "Volver a la lista de flujos de trabajo", + "Workflow does not exist": "El flujo de trabajo no existe", "Workflow not executed": "Flujo de trabajo no ejecutado", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow executed, the result status is <1>{{statusText}}<2>View the execution", "Workflow was not triggered because the current request did not meet the trigger requirements.": "El flujo de trabajo no se activó porque la solicitud actual no cumplió los requisitos del desencadenador.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/fr-FR.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/fr-FR.json index 9321abfcf50..eba65849765 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/fr-FR.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/fr-FR.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Will be executed in the background as a queued task.", "Workflow": "Workflow", + "Back to Workflow List": "Retour à la liste des workflows", + "Workflow does not exist": "Le workflow n’existe pas", "Workflow not executed": "Workflow non exécuté", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow executed, the result status is <1>{{statusText}}<2>View the execution", "Workflow was not triggered because the current request did not meet the trigger requirements.": "Le workflow n’a pas été déclenché, car la requête actuelle ne répond pas aux exigences du déclencheur.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/hu-HU.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/hu-HU.json index d72601c9ddd..b242ffe0a87 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/hu-HU.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/hu-HU.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "A háttérben sorba állított feladatként kerül végrehajtásra.", "Workflow": "Munkafolyamat", + "Back to Workflow List": "Vissza a munkafolyamatok listájához", + "Workflow does not exist": "A munkafolyamat nem létezik", "Workflow not executed": "A munkafolyamat nem lett végrehajtva", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Munkafolyamat végrehajtva, az eredmény állapota: <1>{{statusText}}<2>Végrehajtás megtekintése", "Workflow was not triggered because the current request did not meet the trigger requirements.": "A munkafolyamat nem indult el, mert az aktuális kérés nem felelt meg az indítási feltételeknek.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/id-ID.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/id-ID.json index 97635e78a18..8a47c9e9788 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/id-ID.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/id-ID.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Akan dieksekusi di latar belakang sebagai tugas antrean.", "Workflow": "Alur kerja", + "Back to Workflow List": "Kembali ke daftar alur kerja", + "Workflow does not exist": "Alur kerja tidak ada", "Workflow not executed": "Alur kerja tidak dieksekusi", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Alur kerja dieksekusi, status hasilnya adalah <1>{{statusText}}<2>Lihat eksekusi", "Workflow was not triggered because the current request did not meet the trigger requirements.": "Alur kerja tidak dipicu karena permintaan saat ini tidak memenuhi persyaratan pemicu.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/it-IT.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/it-IT.json index 333b88d5d56..512fa43baab 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/it-IT.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/it-IT.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Verrà eseguito in background come attività in coda.", "Workflow": "Workflow", + "Back to Workflow List": "Torna all'elenco dei workflow", + "Workflow does not exist": "Il workflow non esiste", "Workflow not executed": "Workflow non eseguito", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow eseguito, lo stato del risultato è <1>{{statusText}}<2>Visualizza l'esecuzione", "Workflow was not triggered because the current request did not meet the trigger requirements.": "Il workflow non è stato attivato perché la richiesta corrente non soddisfa i requisiti del trigger.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/ja-JP.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/ja-JP.json index b963971f464..37f99ebac19 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/ja-JP.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/ja-JP.json @@ -256,6 +256,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "キュータスクとしてバックグラウンドで実行されます。", "Workflow": "ワークフロー", + "Back to Workflow List": "ワークフロー一覧に戻る", + "Workflow does not exist": "ワークフローが存在しません", "Workflow not executed": "ワークフローは実行されませんでした", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "ワークフローが実行されました。結果ステータス: <1>{{statusText}}<2>実行詳細を表示", "Workflow was not triggered because the current request did not meet the trigger requirements.": "現在のリクエストがトリガー要件を満たしていないため、ワークフローはトリガーされませんでした。", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/ko-KR.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/ko-KR.json index 94c8c3b9b65..c92a3205117 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/ko-KR.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/ko-KR.json @@ -272,6 +272,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "백그라운드에서 대기 작업으로 실행됩니다.", "Workflow": "워크플로우", + "Back to Workflow List": "워크플로 목록으로 돌아가기", + "Workflow does not exist": "워크플로가 존재하지 않습니다", "Workflow not executed": "워크플로우가 실행되지 않았습니다", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "워크플로우가 실행되었습니다. 결과 상태는 <1>{{statusText}}<2>실행 보기", "Workflow was not triggered because the current request did not meet the trigger requirements.": "현재 요청이 트리거 요구 사항을 충족하지 않아 워크플로우가 트리거되지 않았습니다.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/nl-NL.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/nl-NL.json index 72285d3e607..56e46e5dc8b 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/nl-NL.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/nl-NL.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Will be executed in the background as a queued task.", "Workflow": "Workflow", + "Back to Workflow List": "Terug naar de workflowlijst", + "Workflow does not exist": "Workflow bestaat niet", "Workflow not executed": "Workflow niet uitgevoerd", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow executed, the result status is <1>{{statusText}}<2>View the execution", "Workflow was not triggered because the current request did not meet the trigger requirements.": "De workflow is niet geactiveerd omdat de huidige aanvraag niet aan de triggervereisten voldoet.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/pt-BR.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/pt-BR.json index 19f2282eeb3..bf8a199df4c 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/pt-BR.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/pt-BR.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Will be executed in the background as a queued task.", "Workflow": "Fluxo de trabalho", + "Back to Workflow List": "Voltar para a lista de fluxos de trabalho", + "Workflow does not exist": "O fluxo de trabalho não existe", "Workflow not executed": "Fluxo de trabalho não executado", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow executed, the result status is <1>{{statusText}}<2>View the execution", "Workflow was not triggered because the current request did not meet the trigger requirements.": "O workflow não foi acionado porque a solicitação atual não atende aos requisitos do gatilho.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/ru-RU.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/ru-RU.json index c3e611a47fd..0dc04376a61 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/ru-RU.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/ru-RU.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Будет выполнен в фоновом режиме как задача в очереди.", "Workflow": "Рабочий процесс", + "Back to Workflow List": "Вернуться к списку рабочих процессов", + "Workflow does not exist": "Рабочий процесс не существует", "Workflow not executed": "Рабочий процесс не выполнен", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Рабочий процесс выполнен, статус результата: <1>{{statusText}}<2>Посмотреть выполнение", "Workflow was not triggered because the current request did not meet the trigger requirements.": "Рабочий процесс не был запущен, так как текущий запрос не соответствует требованиям триггера.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/tr-TR.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/tr-TR.json index dffcb77ed0b..cbb2969c0c5 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/tr-TR.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/tr-TR.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Will be executed in the background as a queued task.", "Workflow": "İş Akışı", + "Back to Workflow List": "İş akışı listesine dön", + "Workflow does not exist": "İş akışı mevcut değil", "Workflow not executed": "İş akışı yürütülmedi", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow executed, the result status is <1>{{statusText}}<2>View the execution", "Workflow was not triggered because the current request did not meet the trigger requirements.": "Geçerli istek tetikleme gereksinimlerini karşılamadığı için iş akışı tetiklenmedi.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/uk-UA.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/uk-UA.json index fffb7972958..65a62364b1e 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/uk-UA.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/uk-UA.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Will be executed in the background as a queued task.", "Workflow": "Workflow", + "Back to Workflow List": "Повернутися до списку робочих процесів", + "Workflow does not exist": "Робочий процес не існує", "Workflow not executed": "Робочий процес не виконано", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow executed, the result status is <1>{{statusText}}<2>View the execution", "Workflow was not triggered because the current request did not meet the trigger requirements.": "Робочий процес не було запущено, оскільки поточний запит не відповідає вимогам тригера.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/vi-VN.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/vi-VN.json index e69cba5be51..cc43620c041 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/vi-VN.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/vi-VN.json @@ -254,6 +254,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Will be executed in the background as a queued task.", "Workflow": "Workflow", + "Back to Workflow List": "Quay lại danh sách quy trình làm việc", + "Workflow does not exist": "Quy trình làm việc không tồn tại", "Workflow not executed": "Quy trình làm việc chưa được thực thi", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow executed, the result status is <1>{{statusText}}<2>View the execution", "Workflow was not triggered because the current request did not meet the trigger requirements.": "Quy trình làm việc không được kích hoạt vì yêu cầu hiện tại không đáp ứng các điều kiện kích hoạt.", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json index ebb5d2a4717..c506769fe83 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json @@ -291,6 +291,7 @@ "When no condition matches": "未满足任何条件时", "Will be executed in the background as a queued task.": "将作为队列任务在后台执行。", "Workflow": "工作流", + "Back to Workflow List": "返回工作流列表", "This workflow has configuration issues and may not work properly.": "该工作流配置存在问题,可能无法正常使用", "Workflow title": "工作流名称", "Workflow canvas editor is being migrated to the new UI.": "工作流画布编辑器正在迁移到新版界面。", @@ -298,6 +299,7 @@ "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "工作流已执行,结果状态为 <1>{{statusText}}<2>查看执行详情", "Workflow was not triggered because the current request did not meet the trigger requirements.": "当前请求未满足触发要求,工作流未触发。", "Workflow of execution is not existed": "执行计划对应的工作流不存在", + "Workflow does not exist": "工作流不存在", "Workflow tasks": "流程待办", "Workflow todos": "流程待办", "Workflow will be triggered before deleting succeeded (only supports pre-action event in local mode).": "删除成功之前触发工作流(支持操作前事件)。", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-TW.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-TW.json index c02f737020b..84dc3072575 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-TW.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-TW.json @@ -256,6 +256,8 @@ "When no condition matches": "When no condition matches", "Will be executed in the background as a queued task.": "Will be executed in the background as a queued task.", "Workflow": "Workflow", + "Back to Workflow List": "返回工作流程列表", + "Workflow does not exist": "工作流程不存在", "Workflow title": "工作流名稱", "Workflow not executed": "工作流未執行", "Workflow executed, the result status is <1>{{statusText}}<2>View the execution": "Workflow executed, the result status is <1>{{statusText}}<2>View the execution", From 6c8f08eb5507fe938805fd7e5580169bbd5b65ce Mon Sep 17 00:00:00 2001 From: Zeke Zhang <958414905@qq.com> Date: Sat, 15 Aug 2026 08:45:29 +0800 Subject: [PATCH 3/3] fix(file-manager): fix single record picker (#10372) --- .../src/client-v2/models/UploadFieldModel.tsx | 22 +- .../__tests__/UploadFieldModel.test.tsx | 236 ++++++++++++++++++ 2 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-file-manager/src/client-v2/models/__tests__/UploadFieldModel.test.tsx diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/client-v2/models/UploadFieldModel.tsx b/packages/plugins/@nocobase/plugin-file-manager/src/client-v2/models/UploadFieldModel.tsx index b36e2366fe5..1732a42e1e3 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/client-v2/models/UploadFieldModel.tsx +++ b/packages/plugins/@nocobase/plugin-file-manager/src/client-v2/models/UploadFieldModel.tsx @@ -229,6 +229,10 @@ export const CardUpload = (props) => { ); }; +type SelectExistingRecordHandler = (event?: unknown) => void; + +const defaultSelectExistingRecordHandlers = new WeakSet(); + @largeField() export class UploadFieldModel extends FieldModel { selectedRows = observable.ref([]); @@ -240,11 +244,13 @@ export class UploadFieldModel extends FieldModel { onInit(options: any): void { super.onInit(options); - this.onSelectExitRecordClick = (e) => { + const onSelectExitRecordClick: SelectExistingRecordHandler = (e) => { this.dispatchEvent('openView', { event: e, }); }; + defaultSelectExistingRecordHandlers.add(onSelectExitRecordClick); + this.onSelectExitRecordClick = onSelectExitRecordClick; } set onSelectExitRecordClick(fn) { this.setProps({ onSelectExitRecordClick: fn }); @@ -253,7 +259,17 @@ export class UploadFieldModel extends FieldModel { this.props.onChange(this.selectedRows.value); } render() { - return ; + const configuredHandler = this.props.onSelectExitRecordClick as SelectExistingRecordHandler | undefined; + const onSelectExitRecordClick = + configuredHandler && defaultSelectExistingRecordHandlers.has(configuredHandler) + ? (e?: unknown) => { + this.dispatchEvent('openView', { + event: e, + }); + } + : configuredHandler; + + return ; } } @@ -549,7 +565,7 @@ UploadFieldModel.registerFlow({ }, }, }, - content: () => , + content: () => , styles: { content: { padding: 0, diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/client-v2/models/__tests__/UploadFieldModel.test.tsx b/packages/plugins/@nocobase/plugin-file-manager/src/client-v2/models/__tests__/UploadFieldModel.test.tsx new file mode 100644 index 00000000000..014cf5c5fe7 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-file-manager/src/client-v2/models/__tests__/UploadFieldModel.test.tsx @@ -0,0 +1,236 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { ReactElement } from 'react'; +import { FlowEngine } from '@nocobase/flow-engine'; +import { describe, expect, it, vi } from 'vitest'; +import { UploadFieldModel } from '../UploadFieldModel'; + +type FileRecord = { + id: number; + filename: string; +}; + +type PickerValue = FileRecord | FileRecord[] | undefined; + +type PickerViewOptions = { + inputArgs: { + rowSelectionProps: { + type: 'radio' | 'checkbox'; + onChange: (selectedRowKeys: unknown, selectedRows: FileRecord[]) => void; + }; + }; + content: () => ReactElement<{ toOne?: boolean }>; +}; + +type OpenViewContext = { + inputArgs: { + mode?: string; + size?: string; + }; + collectionField: { + type: string; + target: string; + }; + collection: { + dataSourceKey: string; + filterTargetKey: keyof FileRecord; + }; + model: { + uid: string; + props: { + sourceFieldModelUid?: string; + value: FileRecord[]; + }; + parent?: { + use?: string; + }; + selectedRows: { + value: PickerValue; + }; + change: () => void; + _closeView?: () => void; + flowEngine: { + context: { + themeToken: { + colorBgLayout: string; + }; + }; + }; + }; + viewer: { + open: (options: PickerViewOptions) => void; + }; + isMobileLayout: boolean; + layoutContentElement: null; +}; + +type OpenViewHandler = (ctx: OpenViewContext, params: { mode: string; size: string }) => void; + +function getOpenViewHandler(): OpenViewHandler { + const flow = UploadFieldModel.globalFlowRegistry.getFlow('selectExitRecordSettings'); + const handler = flow?.getStep('openView')?.serialize().handler; + + if (!handler) { + throw new Error('selectExitRecordSettings.openView handler is not registered'); + } + + return handler as unknown as OpenViewHandler; +} + +function createContext(fieldType: string, value: FileRecord[] = []) { + const open = vi.fn<(options: PickerViewOptions) => void>(); + const closeView = vi.fn(); + const change = vi.fn(); + const selectedRows = { value: undefined as PickerValue }; + + const context: OpenViewContext = { + inputArgs: {}, + collectionField: { + type: fieldType, + target: 'attachments', + }, + collection: { + dataSourceKey: 'main', + filterTargetKey: 'id', + }, + model: { + uid: 'upload-field', + props: { + value, + }, + selectedRows, + change, + _closeView: closeView, + flowEngine: { + context: { + themeToken: { + colorBgLayout: '#fff', + }, + }, + }, + }, + viewer: { + open, + }, + isMobileLayout: false, + layoutContentElement: null, + }; + + return { change, closeView, context, open, selectedRows }; +} + +function getOpenedView(open: ReturnType void>>) { + const view = open.mock.calls[0]?.[0]; + + if (!view) { + throw new Error('Record picker view was not opened'); + } + + return view; +} + +describe('UploadFieldModel existing-record picker', () => { + it('dispatches the picker event from the current field fork', () => { + const onChange = vi.fn(); + const model = new UploadFieldModel({ + uid: 'upload-field-dispatch', + use: 'UploadFieldModel', + flowEngine: new FlowEngine(), + props: {}, + }); + model.onInit({}); + const fork = model.createFork({ onChange }, 'subtable-row'); + const dispatchEvent = vi.fn().mockResolvedValue([]); + fork.dispatchEvent = dispatchEvent; + vi.spyOn(model, 'dispatchEvent').mockResolvedValue([]); + const event = { type: 'click' }; + const field = ( + fork as unknown as { + renderOriginal: () => ReactElement<{ + onSelectExitRecordClick: (event: { type: string }) => void; + }>; + } + ).renderOriginal(); + + field.props.onSelectExitRecordClick(event); + + expect(dispatchEvent).toHaveBeenCalledWith('openView', { + event, + }); + }); + + it('preserves a custom existing-record picker handler on a fork', () => { + const customHandler = vi.fn(); + const model = new UploadFieldModel({ + uid: 'upload-field-custom-handler', + use: 'UploadFieldModel', + flowEngine: new FlowEngine(), + props: {}, + }); + model.onInit({}); + model.onSelectExitRecordClick = customHandler; + const fork = model.createFork({ onChange: vi.fn() }, 'subtable-row'); + const dispatchEvent = vi.fn().mockResolvedValue([]); + fork.dispatchEvent = dispatchEvent; + const event = { type: 'click' }; + const field = ( + fork as unknown as { + renderOriginal: () => ReactElement<{ + onSelectExitRecordClick: (event: { type: string }) => void; + }>; + } + ).renderOriginal(); + + field.props.onSelectExitRecordClick(event); + + expect(customHandler).toHaveBeenCalledWith(event); + expect(dispatchEvent).not.toHaveBeenCalled(); + }); + + it('configures a to-one picker without a separate submit action', () => { + const { context, open } = createContext('belongsTo'); + + getOpenViewHandler()(context, { mode: 'drawer', size: 'medium' }); + + const view = getOpenedView(open); + expect(view.inputArgs.rowSelectionProps.type).toBe('radio'); + expect(view.content().props.toOne).toBe(true); + }); + + it('commits through the current model and closes a to-one picker', () => { + const { change, closeView, context, open, selectedRows } = createContext('belongsTo'); + const selectedRecord = { id: 1, filename: 'report.pdf' }; + + getOpenViewHandler()(context, { mode: 'drawer', size: 'medium' }); + getOpenedView(open).inputArgs.rowSelectionProps.onChange(undefined, [selectedRecord]); + + expect(selectedRows.value).toBe(selectedRecord); + expect(change).toHaveBeenCalledOnce(); + expect(closeView).toHaveBeenCalledOnce(); + }); + + it('keeps a to-many picker pending until its submit action is used', () => { + const existingRecord = { id: 1, filename: 'existing.pdf' }; + const addedRecord = { id: 2, filename: 'added.pdf' }; + const { change, closeView, context, open, selectedRows } = createContext('belongsToMany', [existingRecord]); + + getOpenViewHandler()(context, { mode: 'drawer', size: 'medium' }); + + const view = getOpenedView(open); + expect(view.inputArgs.rowSelectionProps.type).toBe('checkbox'); + expect(view.content().props.toOne ?? false).toBe(false); + + view.inputArgs.rowSelectionProps.onChange(undefined, [existingRecord, addedRecord]); + + expect(selectedRows.value).toEqual([existingRecord, addedRecord]); + expect(closeView).not.toHaveBeenCalled(); + expect(change).not.toHaveBeenCalled(); + }); +});