mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-30 16:56:07 +08:00
Merge branch 'next' into develop
This commit is contained in:
@@ -167,6 +167,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';
|
||||
|
||||
+25
-3
@@ -246,6 +246,10 @@ export const CardUpload = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
type SelectExistingRecordHandler = (event?: unknown) => void;
|
||||
|
||||
const defaultSelectExistingRecordHandlers = new WeakSet<SelectExistingRecordHandler>();
|
||||
|
||||
@largeField()
|
||||
export class UploadFieldModel extends FieldModel {
|
||||
selectedRows = observable.ref([]);
|
||||
@@ -257,11 +261,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 });
|
||||
@@ -276,7 +282,23 @@ export class UploadFieldModel extends FieldModel {
|
||||
const fileCollectionReference = fileCollection
|
||||
? { dataSourceKey: fileCollection.dataSourceKey, collectionName: fileCollection.name }
|
||||
: undefined;
|
||||
return <CardUpload {...this.props} fileCollection={fileCollectionReference} />;
|
||||
const configuredHandler = this.props.onSelectExitRecordClick as SelectExistingRecordHandler | undefined;
|
||||
const onSelectExitRecordClick =
|
||||
configuredHandler && defaultSelectExistingRecordHandlers.has(configuredHandler)
|
||||
? (e?: unknown) => {
|
||||
this.dispatchEvent('openView', {
|
||||
event: e,
|
||||
});
|
||||
}
|
||||
: configuredHandler;
|
||||
|
||||
return (
|
||||
<CardUpload
|
||||
{...this.props}
|
||||
fileCollection={fileCollectionReference}
|
||||
onSelectExitRecordClick={onSelectExitRecordClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,7 +595,7 @@ UploadFieldModel.registerFlow({
|
||||
},
|
||||
},
|
||||
},
|
||||
content: () => <RecordPickerContent model={ctx.model} />,
|
||||
content: () => <RecordPickerContent model={ctx.model} toOne={toOne} />,
|
||||
styles: {
|
||||
content: {
|
||||
padding: 0,
|
||||
|
||||
+236
@@ -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<typeof vi.fn<(options: PickerViewOptions) => 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<typeof fork.dispatchEvent>().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<typeof fork.dispatchEvent>().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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -27,6 +27,15 @@ type StorageFileURLResolver = {
|
||||
}) => Promise<string | undefined>;
|
||||
};
|
||||
|
||||
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<string, unknown>,
|
||||
preview,
|
||||
download,
|
||||
})) || (await plugin.getFileURL(file, preview, { download }));
|
||||
const finalUrl = preserveSubAppInLocalURL(storageUrl, ctx.state.fileAccess?.appName);
|
||||
ctx.status = 302;
|
||||
ctx.redirect(finalUrl);
|
||||
await next();
|
||||
|
||||
@@ -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<any>(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 (
|
||||
<Result
|
||||
status="404"
|
||||
title={t('Not found')}
|
||||
subTitle={t('Workflow of execution is not existed')}
|
||||
extra={<Button onClick={onBack}>{t('Go back')}</Button>}
|
||||
extra={
|
||||
<Button href={app.getHref(app.pluginSettingsManager.getRoutePath('workflow'))}>
|
||||
{t('Back to Workflow List')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+48
@@ -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(<ExecutionCanvas record={{ id: 1, jobs: [] }} resource={{}} refresh={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Back to Workflow List' })).toHaveAttribute(
|
||||
'href',
|
||||
'/v/admin/settings/workflow',
|
||||
);
|
||||
});
|
||||
});
|
||||
+11
-2
@@ -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;
|
||||
}
|
||||
|
||||
+19
-3
@@ -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 <Spin />;
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title={t('Workflow does not exist')}
|
||||
extra={
|
||||
<Button type="primary" href={app.getHref(app.pluginSettingsManager.getRoutePath('workflow'))}>
|
||||
{t('Back to Workflow List')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+27
@@ -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(<ExecutionViewPage />);
|
||||
|
||||
await screen.findByTestId('execution-canvas');
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe('Approval workflow - Execution history - NocoBase');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+24
@@ -46,6 +46,17 @@ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@nocobase/client-v2', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nocobase/client-v2')>();
|
||||
return {
|
||||
...actual,
|
||||
useApp: () => ({
|
||||
getHref: (path: string) => `/v${path}`,
|
||||
pluginSettingsManager: { getRoutePath: () => '/admin/settings/workflow' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../components/WorkflowCanvasHeader', () => ({
|
||||
WorkflowCanvasHeader: ({ record }: { record: { title?: string } }) => (
|
||||
<div data-testid="workflow-header">{record.title}</div>
|
||||
@@ -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(<WorkflowCanvasPage />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow ausgeführt, der Ergebnisstatus ist <1>{{statusText}}</1><2>Ausführung anzeigen</2>",
|
||||
"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.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>",
|
||||
"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).",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>",
|
||||
"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.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>",
|
||||
"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.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Munkafolyamat végrehajtva, az eredmény állapota: <1>{{statusText}}</1><2>Végrehajtás megtekintése</2>",
|
||||
"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.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Alur kerja dieksekusi, status hasilnya adalah <1>{{statusText}}</1><2>Lihat eksekusi</2>",
|
||||
"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.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow eseguito, lo stato del risultato è <1>{{statusText}}</1><2>Visualizza l'esecuzione</2>",
|
||||
"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.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "ワークフローが実行されました。結果ステータス: <1>{{statusText}}</1><2>実行詳細を表示</2>",
|
||||
"Workflow was not triggered because the current request did not meet the trigger requirements.": "現在のリクエストがトリガー要件を満たしていないため、ワークフローはトリガーされませんでした。",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "워크플로우가 실행되었습니다. 결과 상태는 <1>{{statusText}}</1><2>실행 보기</2>",
|
||||
"Workflow was not triggered because the current request did not meet the trigger requirements.": "현재 요청이 트리거 요구 사항을 충족하지 않아 워크플로우가 트리거되지 않았습니다.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>",
|
||||
"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.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>",
|
||||
"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.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Рабочий процесс выполнен, статус результата: <1>{{statusText}}</1><2>Посмотреть выполнение</2>",
|
||||
"Workflow was not triggered because the current request did not meet the trigger requirements.": "Рабочий процесс не был запущен, так как текущий запрос не соответствует требованиям триггера.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>",
|
||||
"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.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>",
|
||||
"Workflow was not triggered because the current request did not meet the trigger requirements.": "Робочий процес не було запущено, оскільки поточний запит не відповідає вимогам тригера.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>",
|
||||
"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.",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "工作流已执行,结果状态为 <1>{{statusText}}</1><2>查看执行详情</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).": "删除成功之前触发工作流(支持操作前事件)。",
|
||||
|
||||
@@ -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}}</1><2>View the execution</2>": "Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>",
|
||||
|
||||
Reference in New Issue
Block a user