@nocobase/app-template-default 1.0.0-beta.68 → 1.0.0-beta.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/components.md +5 -1
- package/.agents/rules/shell.md +3 -1
- package/AGENTS.md +7 -3
- package/Dockerfile.dockerignore +0 -1
- package/README.MD +3 -1
- package/client/app.ts +15 -0
- package/client/components/data-table/column-header.tsx +91 -0
- package/client/components/data-table/data-table.tsx +216 -0
- package/client/components/data-table/index.ts +13 -0
- package/client/components/data-table/pagination.tsx +165 -0
- package/client/components/data-table/view-options.tsx +77 -0
- package/client/components/flow-diagram-css.d.ts +2 -0
- package/client/components/flow-diagram.tsx +674 -0
- package/client/components/flow-layout.ts +250 -0
- package/client/components/lifecycle-diagram-model.ts +217 -0
- package/client/components/lifecycle-diagram.tsx +310 -0
- package/client/components/permission-editor.tsx +589 -0
- package/client/components/permission-filter.tsx +455 -0
- package/client/components/route-overlay.tsx +26 -13
- package/client/components/ui/checkbox.tsx +25 -0
- package/client/components/ui/sidebar.tsx +1 -4
- package/client/components/ui/switch.tsx +30 -0
- package/client/components/ui/table.tsx +113 -0
- package/client/extensions/nocobase-workflow-management/README.md +70 -0
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +148 -0
- package/client/extensions/nocobase-workflow-management/execution-detail.tsx +230 -0
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +175 -0
- package/client/extensions/nocobase-workflow-management/layout.tsx +95 -0
- package/client/layouts/components/app-sidebar.tsx +1 -1
- package/client/layouts/use-sidebar-preference.ts +62 -42
- package/client/locales/system/en-US.ts +29 -0
- package/client/locales/system/settings-en-US.ts +142 -0
- package/client/locales/system/settings-zh-CN.ts +136 -0
- package/client/locales/system/zh-CN.ts +29 -0
- package/client/pages/auth/auth-page.css +15 -0
- package/client/pages/auth/login.tsx +1 -1
- package/client/pages/auth/shared.tsx +3 -1
- package/client/pages/settings/hooks.tsx +137 -0
- package/client/pages/settings/page-permission-titles.ts +7 -0
- package/client/pages/settings/page-permissions.ts +42 -0
- package/client/pages/settings/permission-filters.tsx +119 -0
- package/client/pages/settings/permissions-model.ts +260 -0
- package/client/pages/settings/role-form.tsx +405 -0
- package/client/pages/settings/roles.tsx +163 -0
- package/client/pages/settings/routes.ts +79 -0
- package/client/pages/settings/shared.tsx +119 -0
- package/client/pages/settings/user-detail.tsx +268 -0
- package/client/pages/settings/user-form.tsx +427 -0
- package/client/pages/settings/users.tsx +451 -0
- package/client/routing/app-router.tsx +4 -1
- package/client/routing/session-preparation.tsx +65 -0
- package/client/styles.css +4 -7
- package/package.json +27 -24
- package/tests/components/app-layout-routes.test.tsx +14 -7
- package/tests/components/data-table.test.tsx +127 -0
- package/tests/components/layout-i18n.test.tsx +8 -1
- package/tests/components/navigation-menu.test.tsx +24 -14
- package/tests/components/permission-editor.test.tsx +477 -0
- package/tests/components/permission-filter-binding.test.tsx +150 -0
- package/tests/components/permission-filter.test.tsx +202 -0
- package/tests/components/settings.test.tsx +294 -0
- package/tests/components/sidebar-permissions.test.tsx +12 -4
- package/tests/components/sidebar-preference.test.tsx +209 -14
- package/tests/components/user-permissions.test.tsx +142 -0
- package/tests/fixtures/theme-tokens.tsx +9 -1
- package/tests/logic/asset-url-build.test.ts +0 -1
- package/tests/logic/client-routes.test.ts +40 -0
- package/tests/logic/client-shell.test.tsx +10 -2
- package/tests/logic/config-example.test.ts +30 -0
- package/tests/logic/dev-client-watch.test.ts +8 -1
- package/tests/logic/page-permission-titles.test.ts +223 -0
- package/tests/logic/route-overlay.test.tsx +42 -0
- package/tests/logic/session-preparation.test.tsx +223 -0
- package/tests/logic/settings-permissions.test.ts +168 -0
- package/vite.config.ts +0 -22
- package/client/agent-annotations-host.ts +0 -25
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cn } from 'cn';
|
|
3
|
+
|
|
4
|
+
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
|
5
|
+
return (
|
|
6
|
+
<div
|
|
7
|
+
data-slot='table-container'
|
|
8
|
+
className='relative w-full overflow-x-auto'
|
|
9
|
+
>
|
|
10
|
+
<table
|
|
11
|
+
data-slot='table'
|
|
12
|
+
className={cn('w-full caption-bottom text-sm', className)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
|
20
|
+
return (
|
|
21
|
+
<thead
|
|
22
|
+
data-slot='table-header'
|
|
23
|
+
className={cn('[&_tr]:border-b', className)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
|
30
|
+
return (
|
|
31
|
+
<tbody
|
|
32
|
+
data-slot='table-body'
|
|
33
|
+
className={cn('[&_tr:last-child]:border-0', className)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
|
40
|
+
return (
|
|
41
|
+
<tfoot
|
|
42
|
+
data-slot='table-footer'
|
|
43
|
+
className={cn(
|
|
44
|
+
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
|
53
|
+
return (
|
|
54
|
+
<tr
|
|
55
|
+
data-slot='table-row'
|
|
56
|
+
className={cn(
|
|
57
|
+
'border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted',
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
|
66
|
+
return (
|
|
67
|
+
<th
|
|
68
|
+
data-slot='table-head'
|
|
69
|
+
className={cn(
|
|
70
|
+
'h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0',
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
{...props}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
|
79
|
+
return (
|
|
80
|
+
<td
|
|
81
|
+
data-slot='table-cell'
|
|
82
|
+
className={cn(
|
|
83
|
+
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
|
84
|
+
className,
|
|
85
|
+
)}
|
|
86
|
+
{...props}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function TableCaption({
|
|
92
|
+
className,
|
|
93
|
+
...props
|
|
94
|
+
}: React.ComponentProps<'caption'>) {
|
|
95
|
+
return (
|
|
96
|
+
<caption
|
|
97
|
+
data-slot='table-caption'
|
|
98
|
+
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
|
99
|
+
{...props}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
Table,
|
|
106
|
+
TableHeader,
|
|
107
|
+
TableBody,
|
|
108
|
+
TableFooter,
|
|
109
|
+
TableHead,
|
|
110
|
+
TableRow,
|
|
111
|
+
TableCell,
|
|
112
|
+
TableCaption,
|
|
113
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Workflow management
|
|
2
|
+
|
|
3
|
+
Install with `yes n | pnpm exec shadcn add @nocobase/workflow-management`. This block provides the workflow example's administration layout and interactions as presentational parts in `client/extensions/nocobase-workflow-management/`. Read `@nocobase/workflow-management-demo` for a standalone composition. Every label and formatted value is supplied by the caller; the block imports no example plugin, API client, authorization provider or router.
|
|
4
|
+
|
|
5
|
+
| File | Exports and responsibility |
|
|
6
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| `layout.tsx` | `WorkflowManagementLayout`: page container, shared header and line-style tabs, hidden in detail mode. `WorkflowDefinitionDetail`: diagram and parameter form cards in two columns. |
|
|
8
|
+
| `definition-list.tsx` | `WorkflowDefinitionList`: optional category rail, search slot, refresh and execution-activity sort controls, adapted definition rows and empty/status content. |
|
|
9
|
+
| `execution-list.tsx` | `WorkflowExecutionList`: filters slot, refresh, controlled ordering, adapted execution rows, empty/status content and controlled pagination. |
|
|
10
|
+
| `execution-detail.tsx` | `WorkflowExecutionDetail`: ordered history, step selection, diagram rendering callback, effects and optional recorded-parameter content. |
|
|
11
|
+
|
|
12
|
+
## Routes and ownership
|
|
13
|
+
|
|
14
|
+
Register one clickable Workflow management entry directly under the application's System management or Settings group. Declare `navigation` only on that parent within the workflow route subtree. Use `definitions` and `executions` child routes without `navigation`, with deeper definition/execution detail routes. Set `tab` from the URL and navigate in `onTabChange`; redirect the parent to its default accessible child. Render the application's `Outlet` inside `WorkflowManagementLayout` and set `detail` while a detail route is active, so details replace the list header and tabs while the same sidebar entry stays active. Retain breadcrumb links with the list's query parameters for returning to the list.
|
|
15
|
+
|
|
16
|
+
For example, keep the tab routes out of sidebar navigation while retaining direct URLs (the application supplies these page modules and registers the matching settings permission on the server):
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
import { defineAppRoutes } from '@nocobase/app-client/plugins';
|
|
20
|
+
|
|
21
|
+
const routes = defineAppRoutes([
|
|
22
|
+
{
|
|
23
|
+
name: 'systemManagement',
|
|
24
|
+
navigation: { title: 'navigation.systemManagement' },
|
|
25
|
+
children: [
|
|
26
|
+
{
|
|
27
|
+
name: 'workflows',
|
|
28
|
+
path: '/workflows',
|
|
29
|
+
auth: 'required',
|
|
30
|
+
authz: {
|
|
31
|
+
resource: { type: 'settings', id: 'workflow-management' },
|
|
32
|
+
action: 'read',
|
|
33
|
+
},
|
|
34
|
+
navigation: { title: 'navigation.workflows' },
|
|
35
|
+
componentLoader: () => import('./pages/workflows/index.js'),
|
|
36
|
+
children: [
|
|
37
|
+
{
|
|
38
|
+
name: 'workflowDefinitions',
|
|
39
|
+
path: 'definitions',
|
|
40
|
+
componentLoader: () => import('./pages/workflows/definitions.js'),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'workflowExecutions',
|
|
44
|
+
path: 'executions',
|
|
45
|
+
componentLoader: () => import('./pages/workflows/executions.js'),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Add definition and execution detail routes beneath the corresponding tab route, also without `navigation`. The parent page maps the URL to `tab`, navigates in `onTabChange`, and supplies its `Outlet` as children. Reuse an existing system group instead of registering another one.
|
|
55
|
+
|
|
56
|
+
Keep requests, query parameters, IME-aware search, filtering, pagination, category CRUD and permissions in application adapters. Map rows into display values and router links before passing them to the lists. `loaded` distinguishes an empty response from data that has not arrived; `loading` disables refresh, and the caller disables pagination while pending. Supply error/loading/retry content through `status`, and separate empty-catalog and no-results wording through `empty`. The search slot is constrained beside Refresh on wider screens; the category rail stacks above the list on small screens. Omit `categories` when classification is unnecessary. The installable demo provides in-memory category filtering, named color selection and add/edit/delete dialogs as an application adapter; deleting a category preserves its workflows and clears the selected category filter. Its definition header also demonstrates a colored category dropdown with an Uncategorized option; assignment uses the same category state as list filtering. The production adapter keeps the assignment API and read/update permission checks in the application.
|
|
57
|
+
|
|
58
|
+
`WorkflowDefinitionDetail` accepts a diagram slot and a parameter form slot. Render the installed `@nocobase/lifecycle-diagram` or an existing compatible diagram in a definite-height or content-sized canvas. Keep schema validation, effective timing, complete-group saves, CAS conflicts and read-only permissions in the application's parameter editor. Show a translated empty state when a definition declares no adjustable parameters.
|
|
59
|
+
|
|
60
|
+
`WorkflowExecutionDetail` sorts transitions by version, timestamp and id without mutating the input. Selecting a step passes only that history prefix and its destination state to `renderDiagram`; no transition or server action occurs. Pass the runtime's transition records directly, retaining extra fields through its generic history type. `effects` displays persisted statuses, attempts and failures; it does not retry them. Pass recorded parameter content through `parameters` and label historical facts separately from current configuration. Remount the detail on record changes or an explicit refresh to reset historical selection, as the example application does.
|
|
61
|
+
|
|
62
|
+
The application owns authentication, initial administrator grants, navigation and direct-page guards, server read/update authorization and any recovery actions. Configuration permission does not grant business approval permission. Installing this block creates source files, not routes, permissions, runtime services or database collections.
|
|
63
|
+
|
|
64
|
+
## Standard diagram and contextual configuration
|
|
65
|
+
|
|
66
|
+
Current application templates already contain these parts and the standard React Flow/ELK components. Reuse the installed `LifecycleDiagram` and preserve its default visuals unless the user explicitly asks to customize them. UI Library entries are shadcn registry source, not npm packages; use `shadcn view/add`, never an npm 404 as a fallback decision.
|
|
67
|
+
|
|
68
|
+
Connect `LifecycleDiagram.configuration` to the application's existing authorized parameter editor. Native `parameterConfiguration` bindings supply the exact state/transition fields; the component automatically renders bound state controls and sends bound transition selections to `onConfigure`. `canConfigure` is mandatory and controls the affordance only, never server security. The application editor reads current values/revision, preserves other group fields, validates and saves the complete authorized group with CAS, and refreshes the diagram summary from persisted data. Avoid replacing a group with the callback's filtered field subset. Keep historical snapshots read-only.
|
|
69
|
+
|
|
70
|
+
Omit `WorkflowDefinitionDetail.parameters` when the editor opens contextually so the diagram spans the available width; keep the slot when the same editor is visible beside the diagram. No editor, API, sample business registration or menu is installed by this block. Definition and execution lists use the same TanStack DataTable as the templates, with no second local pagination over a server page.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
CellContext,
|
|
4
|
+
ColumnMeta,
|
|
5
|
+
HeaderContext,
|
|
6
|
+
} from '@tanstack/react-table';
|
|
7
|
+
import { ArrowDown, ArrowUp } from 'lucide-react';
|
|
8
|
+
import { Button } from '#components/ui/button';
|
|
9
|
+
import { Empty, EmptyDescription } from '#components/ui/empty';
|
|
10
|
+
import { DataTable } from '#components/data-table/data-table';
|
|
11
|
+
|
|
12
|
+
export interface WorkflowDefinitionRow {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
title: ReactNode;
|
|
16
|
+
activity: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
export interface WorkflowDefinitionListProps {
|
|
19
|
+
rows: readonly WorkflowDefinitionRow[];
|
|
20
|
+
categories?: ReactNode;
|
|
21
|
+
search: ReactNode;
|
|
22
|
+
status?: ReactNode;
|
|
23
|
+
loaded: boolean;
|
|
24
|
+
loading: boolean;
|
|
25
|
+
empty: ReactNode;
|
|
26
|
+
labels: {
|
|
27
|
+
refresh: string;
|
|
28
|
+
workflow: string;
|
|
29
|
+
executionCount: string;
|
|
30
|
+
executionActivity: string;
|
|
31
|
+
};
|
|
32
|
+
sort?: { ascending: boolean };
|
|
33
|
+
onSort: () => void;
|
|
34
|
+
onRefresh: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type DefinitionActivityMeta = ColumnMeta<WorkflowDefinitionRow, unknown> &
|
|
38
|
+
Pick<WorkflowDefinitionListProps, 'labels' | 'sort' | 'onSort'>;
|
|
39
|
+
|
|
40
|
+
export function WorkflowDefinitionList({
|
|
41
|
+
rows,
|
|
42
|
+
categories,
|
|
43
|
+
search,
|
|
44
|
+
status,
|
|
45
|
+
loaded,
|
|
46
|
+
loading,
|
|
47
|
+
empty,
|
|
48
|
+
labels,
|
|
49
|
+
sort,
|
|
50
|
+
onSort,
|
|
51
|
+
onRefresh,
|
|
52
|
+
}: WorkflowDefinitionListProps): ReactElement {
|
|
53
|
+
const activityMeta: DefinitionActivityMeta = { labels, sort, onSort };
|
|
54
|
+
return (
|
|
55
|
+
<section className='flex flex-col items-stretch gap-3 sm:flex-row sm:items-start sm:gap-4'>
|
|
56
|
+
{categories !== undefined && (
|
|
57
|
+
<aside className='w-full shrink-0 space-y-4 border-b pb-3 sm:w-48 sm:border-r sm:border-b-0 sm:pr-4 sm:pb-0'>
|
|
58
|
+
{categories}
|
|
59
|
+
</aside>
|
|
60
|
+
)}
|
|
61
|
+
<div className='flex w-full min-w-0 flex-1 flex-col gap-4'>
|
|
62
|
+
<div className='flex flex-col items-stretch justify-between gap-2 sm:flex-row sm:items-center'>
|
|
63
|
+
<div className='min-w-0 flex-1 sm:max-w-xs'>{search}</div>
|
|
64
|
+
<Button
|
|
65
|
+
className='shrink-0 self-start sm:self-auto'
|
|
66
|
+
variant='outline'
|
|
67
|
+
disabled={loading}
|
|
68
|
+
onClick={onRefresh}
|
|
69
|
+
>
|
|
70
|
+
{labels.refresh}
|
|
71
|
+
</Button>
|
|
72
|
+
</div>
|
|
73
|
+
{status}
|
|
74
|
+
{loaded && rows.length > 0 && (
|
|
75
|
+
<div className='bg-card'>
|
|
76
|
+
<DataTable
|
|
77
|
+
data={rows}
|
|
78
|
+
getRowId={(row) => row.id}
|
|
79
|
+
pagination={false}
|
|
80
|
+
manualSorting
|
|
81
|
+
sortingState={
|
|
82
|
+
sort ? [{ id: 'activity', desc: !sort.ascending }] : []
|
|
83
|
+
}
|
|
84
|
+
columns={[
|
|
85
|
+
{
|
|
86
|
+
id: 'workflow',
|
|
87
|
+
header: labels.workflow,
|
|
88
|
+
enableSorting: false,
|
|
89
|
+
cell: DefinitionTitleCell,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'activity',
|
|
93
|
+
accessorFn: (row) => row.activity,
|
|
94
|
+
enableSorting: true,
|
|
95
|
+
meta: activityMeta,
|
|
96
|
+
header: DefinitionActivityHeader,
|
|
97
|
+
cell: DefinitionActivityCell,
|
|
98
|
+
},
|
|
99
|
+
]}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
{loaded && rows.length === 0 && (
|
|
104
|
+
<Empty>
|
|
105
|
+
<EmptyDescription>{empty}</EmptyDescription>
|
|
106
|
+
</Empty>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Stable renderer identities preserve mounted links and keyboard focus while
|
|
114
|
+
// the owning application refreshes rows, labels or sorting callbacks.
|
|
115
|
+
function DefinitionTitleCell({
|
|
116
|
+
row,
|
|
117
|
+
}: CellContext<WorkflowDefinitionRow, unknown>) {
|
|
118
|
+
return (
|
|
119
|
+
<>
|
|
120
|
+
{row.original.title}
|
|
121
|
+
<p className='text-xs text-muted-foreground'>{row.original.name}</p>
|
|
122
|
+
</>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
function DefinitionActivityCell({
|
|
126
|
+
row,
|
|
127
|
+
}: CellContext<WorkflowDefinitionRow, unknown>) {
|
|
128
|
+
return <div className='text-right tabular-nums'>{row.original.activity}</div>;
|
|
129
|
+
}
|
|
130
|
+
function DefinitionActivityHeader({
|
|
131
|
+
column,
|
|
132
|
+
}: HeaderContext<WorkflowDefinitionRow, unknown>) {
|
|
133
|
+
const { labels, sort, onSort } = column.columnDef
|
|
134
|
+
.meta as DefinitionActivityMeta;
|
|
135
|
+
return (
|
|
136
|
+
<div className='text-right'>
|
|
137
|
+
<Button
|
|
138
|
+
variant='ghost'
|
|
139
|
+
size='sm'
|
|
140
|
+
title={labels.executionCount}
|
|
141
|
+
onClick={onSort}
|
|
142
|
+
>
|
|
143
|
+
{labels.executionActivity}
|
|
144
|
+
{sort && (sort.ascending ? <ArrowUp /> : <ArrowDown />)}
|
|
145
|
+
</Button>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { useState, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { Button } from '#components/ui/button';
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from '#components/ui/card';
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from '#components/ui/table';
|
|
12
|
+
|
|
13
|
+
export interface WorkflowHistoryEntry {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly version: number;
|
|
16
|
+
readonly at: string;
|
|
17
|
+
readonly transition: string;
|
|
18
|
+
readonly from: string | null;
|
|
19
|
+
readonly to: string;
|
|
20
|
+
readonly actorId?: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface WorkflowEffectEntry {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly effect: string;
|
|
25
|
+
readonly status: string;
|
|
26
|
+
readonly attempts: number;
|
|
27
|
+
readonly maxAttempts: number;
|
|
28
|
+
readonly error?: string | null;
|
|
29
|
+
}
|
|
30
|
+
export interface WorkflowExecutionLabels {
|
|
31
|
+
processDiagram: string;
|
|
32
|
+
history: string;
|
|
33
|
+
historyHint: string;
|
|
34
|
+
step: string;
|
|
35
|
+
transition: string;
|
|
36
|
+
from: string;
|
|
37
|
+
to: string;
|
|
38
|
+
actor: string;
|
|
39
|
+
time: string;
|
|
40
|
+
noHistory: string;
|
|
41
|
+
effects: string;
|
|
42
|
+
effect: string;
|
|
43
|
+
state: string;
|
|
44
|
+
attempts: string;
|
|
45
|
+
error: string;
|
|
46
|
+
noEffects: string;
|
|
47
|
+
executionParameters: string;
|
|
48
|
+
snapshotHint: string;
|
|
49
|
+
showStep: (step: number) => string;
|
|
50
|
+
}
|
|
51
|
+
/** Historical selection only changes the display; execution and recovery remain caller-owned. */
|
|
52
|
+
export interface WorkflowExecutionDetailProps<T extends WorkflowHistoryEntry> {
|
|
53
|
+
transitions: readonly T[];
|
|
54
|
+
state: string;
|
|
55
|
+
effects: readonly WorkflowEffectEntry[];
|
|
56
|
+
parameters?: ReactNode;
|
|
57
|
+
labels: WorkflowExecutionLabels;
|
|
58
|
+
stateLabel: (name: string) => ReactNode;
|
|
59
|
+
transitionLabel: (name: string) => ReactNode;
|
|
60
|
+
dateLabel: (at: string) => ReactNode;
|
|
61
|
+
effectStatusLabel: (status: string) => ReactNode;
|
|
62
|
+
renderDiagram: (selection: {
|
|
63
|
+
current: string;
|
|
64
|
+
history: readonly T[];
|
|
65
|
+
}) => ReactNode;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function WorkflowExecutionDetail<T extends WorkflowHistoryEntry>({
|
|
69
|
+
transitions: transitionsInput,
|
|
70
|
+
state,
|
|
71
|
+
effects,
|
|
72
|
+
parameters,
|
|
73
|
+
labels,
|
|
74
|
+
stateLabel,
|
|
75
|
+
transitionLabel,
|
|
76
|
+
dateLabel,
|
|
77
|
+
effectStatusLabel,
|
|
78
|
+
renderDiagram,
|
|
79
|
+
}: WorkflowExecutionDetailProps<T>): ReactElement {
|
|
80
|
+
const [selected, setSelected] = useState<string>();
|
|
81
|
+
const transitions = [...transitionsInput].sort(
|
|
82
|
+
(a, b) =>
|
|
83
|
+
a.version - b.version ||
|
|
84
|
+
a.at.localeCompare(b.at) ||
|
|
85
|
+
a.id.localeCompare(b.id),
|
|
86
|
+
);
|
|
87
|
+
const index = selected
|
|
88
|
+
? transitions.findIndex((entry) => entry.id === selected)
|
|
89
|
+
: -1;
|
|
90
|
+
const history = index < 0 ? transitions : transitions.slice(0, index + 1);
|
|
91
|
+
const current = index < 0 ? state : transitions[index].to;
|
|
92
|
+
return (
|
|
93
|
+
<div className='flex flex-col gap-4'>
|
|
94
|
+
<div className='grid min-w-0 grid-cols-1 items-stretch gap-6 lg:grid-cols-2'>
|
|
95
|
+
<Card
|
|
96
|
+
className='min-w-0'
|
|
97
|
+
role='region'
|
|
98
|
+
aria-label={labels.processDiagram}
|
|
99
|
+
>
|
|
100
|
+
<CardHeader>
|
|
101
|
+
<CardTitle>
|
|
102
|
+
<h2>{labels.processDiagram}</h2>
|
|
103
|
+
</CardTitle>
|
|
104
|
+
</CardHeader>
|
|
105
|
+
<CardContent className='flex flex-1 flex-col gap-4'>
|
|
106
|
+
{renderDiagram({ current, history })}
|
|
107
|
+
</CardContent>
|
|
108
|
+
</Card>
|
|
109
|
+
<div className='flex min-w-0 flex-col gap-6'>
|
|
110
|
+
<Card className='min-w-0' role='region' aria-label={labels.history}>
|
|
111
|
+
<CardHeader>
|
|
112
|
+
<CardTitle>
|
|
113
|
+
<h2>{labels.history}</h2>
|
|
114
|
+
</CardTitle>
|
|
115
|
+
</CardHeader>
|
|
116
|
+
<CardContent className='min-w-0'>
|
|
117
|
+
<p className='mb-4 text-sm text-muted-foreground'>
|
|
118
|
+
{labels.historyHint}
|
|
119
|
+
</p>
|
|
120
|
+
<Table>
|
|
121
|
+
<TableHeader>
|
|
122
|
+
<TableRow>
|
|
123
|
+
<TableHead>{labels.step}</TableHead>
|
|
124
|
+
<TableHead>{labels.transition}</TableHead>
|
|
125
|
+
<TableHead>{labels.from}</TableHead>
|
|
126
|
+
<TableHead>{labels.to}</TableHead>
|
|
127
|
+
<TableHead>{labels.actor}</TableHead>
|
|
128
|
+
<TableHead>{labels.time}</TableHead>
|
|
129
|
+
</TableRow>
|
|
130
|
+
</TableHeader>
|
|
131
|
+
<TableBody>
|
|
132
|
+
{transitions.map((entry, step) => (
|
|
133
|
+
<TableRow
|
|
134
|
+
key={entry.id}
|
|
135
|
+
data-selected={entry.id === selected ? 'true' : undefined}
|
|
136
|
+
>
|
|
137
|
+
<TableCell>
|
|
138
|
+
<Button
|
|
139
|
+
size='sm'
|
|
140
|
+
variant={
|
|
141
|
+
entry.id === selected ? 'secondary' : 'ghost'
|
|
142
|
+
}
|
|
143
|
+
aria-label={labels.showStep(step + 1)}
|
|
144
|
+
aria-pressed={entry.id === selected}
|
|
145
|
+
onClick={() => setSelected(entry.id)}
|
|
146
|
+
>
|
|
147
|
+
{step + 1}
|
|
148
|
+
</Button>
|
|
149
|
+
</TableCell>
|
|
150
|
+
<TableCell>{transitionLabel(entry.transition)}</TableCell>
|
|
151
|
+
<TableCell>
|
|
152
|
+
{entry.from ? stateLabel(entry.from) : '—'}
|
|
153
|
+
</TableCell>
|
|
154
|
+
<TableCell>{stateLabel(entry.to)}</TableCell>
|
|
155
|
+
<TableCell>{entry.actorId}</TableCell>
|
|
156
|
+
<TableCell>{dateLabel(entry.at)}</TableCell>
|
|
157
|
+
</TableRow>
|
|
158
|
+
))}
|
|
159
|
+
</TableBody>
|
|
160
|
+
</Table>
|
|
161
|
+
{!transitions.length && (
|
|
162
|
+
<p className='text-sm text-muted-foreground'>
|
|
163
|
+
{labels.noHistory}
|
|
164
|
+
</p>
|
|
165
|
+
)}
|
|
166
|
+
</CardContent>
|
|
167
|
+
</Card>
|
|
168
|
+
<Card className='min-w-0' role='region' aria-label={labels.effects}>
|
|
169
|
+
<CardHeader>
|
|
170
|
+
<CardTitle>
|
|
171
|
+
<h2>{labels.effects}</h2>
|
|
172
|
+
</CardTitle>
|
|
173
|
+
</CardHeader>
|
|
174
|
+
<CardContent className='min-w-0'>
|
|
175
|
+
{effects.length ? (
|
|
176
|
+
<Table>
|
|
177
|
+
<TableHeader>
|
|
178
|
+
<TableRow>
|
|
179
|
+
<TableHead>{labels.effect}</TableHead>
|
|
180
|
+
<TableHead>{labels.state}</TableHead>
|
|
181
|
+
<TableHead>{labels.attempts}</TableHead>
|
|
182
|
+
<TableHead>{labels.error}</TableHead>
|
|
183
|
+
</TableRow>
|
|
184
|
+
</TableHeader>
|
|
185
|
+
<TableBody>
|
|
186
|
+
{effects.map((run) => (
|
|
187
|
+
<TableRow key={run.id}>
|
|
188
|
+
<TableCell>{run.effect}</TableCell>
|
|
189
|
+
<TableCell>{effectStatusLabel(run.status)}</TableCell>
|
|
190
|
+
<TableCell>
|
|
191
|
+
{run.attempts} / {run.maxAttempts}
|
|
192
|
+
</TableCell>
|
|
193
|
+
<TableCell className='max-w-80 whitespace-normal break-words text-destructive'>
|
|
194
|
+
{run.error || '—'}
|
|
195
|
+
</TableCell>
|
|
196
|
+
</TableRow>
|
|
197
|
+
))}
|
|
198
|
+
</TableBody>
|
|
199
|
+
</Table>
|
|
200
|
+
) : (
|
|
201
|
+
<p className='text-sm text-muted-foreground'>
|
|
202
|
+
{labels.noEffects}
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
</CardContent>
|
|
206
|
+
</Card>
|
|
207
|
+
{parameters && (
|
|
208
|
+
<Card
|
|
209
|
+
className='min-w-0'
|
|
210
|
+
role='region'
|
|
211
|
+
aria-label={labels.executionParameters}
|
|
212
|
+
>
|
|
213
|
+
<CardHeader>
|
|
214
|
+
<CardTitle>
|
|
215
|
+
<h2>{labels.executionParameters}</h2>
|
|
216
|
+
</CardTitle>
|
|
217
|
+
</CardHeader>
|
|
218
|
+
<CardContent className='min-w-0'>
|
|
219
|
+
<p className='mb-4 text-sm text-muted-foreground'>
|
|
220
|
+
{labels.snapshotHint}
|
|
221
|
+
</p>
|
|
222
|
+
{parameters}
|
|
223
|
+
</CardContent>
|
|
224
|
+
</Card>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|