@nocobase/app-template-default 1.0.0-beta.69 → 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.
@@ -0,0 +1,310 @@
1
+ import {
2
+ useMemo,
3
+ useRef,
4
+ useState,
5
+ type ReactElement,
6
+ type ReactNode,
7
+ } from 'react';
8
+ import { ChevronDownIcon, Settings2Icon } from 'lucide-react';
9
+ import { useTranslation } from '@nocobase/i18n/client';
10
+ import { Button } from '#components/ui/button';
11
+ import {
12
+ Popover,
13
+ PopoverContent,
14
+ PopoverTitle,
15
+ PopoverTrigger,
16
+ } from '#components/ui/popover';
17
+ import type { LifecycleDescription } from '@nocobase/lifecycle';
18
+ import { FlowDiagram, type FlowDiagramProps } from '#components/flow-diagram';
19
+ import type { FlowDiagramNode } from '#components/flow-layout';
20
+
21
+ import {
22
+ lifecycleDiagramGraph,
23
+ lifecycleConfigurationSelection,
24
+ lifecycleNodeConfigurationSelections,
25
+ type LifecycleConfigurationSelection,
26
+ type LifecycleConfigurationTarget,
27
+ type LifecycleDiagramConfiguration,
28
+ type LifecycleDiagramOptions,
29
+ } from './lifecycle-diagram-model.js';
30
+
31
+ export interface LifecycleDiagramProps
32
+ extends
33
+ LifecycleDiagramOptions,
34
+ Omit<
35
+ FlowDiagramProps,
36
+ | 'nodes'
37
+ | 'edges'
38
+ | 'renderNode'
39
+ | 'nodeActions'
40
+ | 'onNodeClick'
41
+ | 'onEdgeClick'
42
+ > {
43
+ readonly description: LifecycleDescription;
44
+ /** Opens the application-owned, authorized parameter editor for declared bindings. */
45
+ readonly configuration?: LifecycleDiagramConfiguration;
46
+ readonly stateActions?: (
47
+ state: LifecycleDescription['stateInfo'][number],
48
+ ) => ReactNode;
49
+ readonly renderState?: (
50
+ state: LifecycleDescription['stateInfo'][number],
51
+ node: FlowDiagramNode,
52
+ ) => ReactNode;
53
+ readonly onStateClick?: (
54
+ state: LifecycleDescription['stateInfo'][number],
55
+ ) => void;
56
+ readonly onTransitionClick?: (
57
+ transition: LifecycleDescription['transitions'][number],
58
+ ) => void;
59
+ }
60
+
61
+ /** Definitions are source-owned; clicking opens consumer-owned details, never fires a transition. */
62
+ export function LifecycleDiagram({
63
+ description,
64
+ currentState,
65
+ endLabel,
66
+ history,
67
+ visitedStates,
68
+ availableTransitions,
69
+ stateLabel,
70
+ transitionLabel,
71
+ renderState,
72
+ stateActions,
73
+ configuration,
74
+ onStateClick,
75
+ onTransitionClick,
76
+ ...props
77
+ }: LifecycleDiagramProps): ReactElement {
78
+ const sourceGraph = useMemo(
79
+ () =>
80
+ lifecycleDiagramGraph(description, {
81
+ currentState,
82
+ endLabel,
83
+ history,
84
+ visitedStates,
85
+ availableTransitions,
86
+ stateLabel,
87
+ transitionLabel,
88
+ }),
89
+ [
90
+ description,
91
+ currentState,
92
+ endLabel,
93
+ history,
94
+ visitedStates,
95
+ availableTransitions,
96
+ stateLabel,
97
+ transitionLabel,
98
+ ],
99
+ );
100
+ const graph = configuration
101
+ ? {
102
+ nodes: sourceGraph.nodes.map((node) => ({
103
+ ...node,
104
+ configurable: Boolean(
105
+ lifecycleNodeConfigurationSelections(
106
+ description,
107
+ node.id,
108
+ configuration.canConfigure,
109
+ ).length,
110
+ ),
111
+ })),
112
+ edges: sourceGraph.edges.map((edge) => ({
113
+ ...edge,
114
+ configurable:
115
+ !edge.decorative &&
116
+ Boolean(
117
+ lifecycleConfigurationSelection(
118
+ description,
119
+ {
120
+ kind: 'transition',
121
+ name: (JSON.parse(edge.id) as string[])[0],
122
+ },
123
+ configuration.canConfigure,
124
+ ),
125
+ ),
126
+ })),
127
+ }
128
+ : sourceGraph;
129
+ return (
130
+ <FlowDiagram
131
+ direction='DOWN'
132
+ initialMinZoom={0.7}
133
+ autoHeight
134
+ {...props}
135
+ {...graph}
136
+ nodeActions={(node) => {
137
+ const state = description.stateInfo.find(
138
+ (state) => state.name === node.id,
139
+ );
140
+ if (!state) return undefined;
141
+ const actions = stateActions?.(state);
142
+ const selections =
143
+ configuration &&
144
+ lifecycleNodeConfigurationSelections(
145
+ description,
146
+ state.name,
147
+ configuration.canConfigure,
148
+ );
149
+ if (!selections?.length || !configuration) return actions;
150
+ return (
151
+ <div className='nodrag nopan flex items-center gap-1'>
152
+ {actions}
153
+ <NodeConfigurationActions
154
+ configuration={configuration}
155
+ selections={selections}
156
+ state={{ kind: 'state', name: state.name }}
157
+ title={(target) => {
158
+ if (target.kind === 'state')
159
+ return stateLabel?.(state) ?? state.title;
160
+ const transition = description.transitions.find(
161
+ (item) => item.name === target.name,
162
+ )!;
163
+ return transitionLabel?.(transition) ?? transition.title;
164
+ }}
165
+ />
166
+ </div>
167
+ );
168
+ }}
169
+ renderNode={(node) => {
170
+ const state = description.stateInfo.find(
171
+ (state) => state.name === node.id,
172
+ );
173
+ return state && renderState ? renderState(state, node) : node.label;
174
+ }}
175
+ onNodeClick={(node) => {
176
+ const state = description.stateInfo.find(
177
+ (state) => state.name === node.id,
178
+ );
179
+ if (state) onStateClick?.(state);
180
+ }}
181
+ onEdgeClick={(edge) => {
182
+ if (edge.decorative) return;
183
+ const [name] = JSON.parse(edge.id) as string[];
184
+ const transition = description.transitions.find(
185
+ (item) => item.name === name,
186
+ );
187
+ if (!transition) return;
188
+ const selection =
189
+ configuration &&
190
+ lifecycleConfigurationSelection(
191
+ description,
192
+ { kind: 'transition', name: transition.name },
193
+ configuration.canConfigure,
194
+ );
195
+ if (selection && configuration) configuration.onConfigure(selection);
196
+ else onTransitionClick?.(transition);
197
+ }}
198
+ />
199
+ );
200
+ }
201
+
202
+ /** Reuse the authoritative editor; this chooser never invents or saves values. */
203
+ function NodeConfigurationActions({
204
+ configuration,
205
+ selections,
206
+ state,
207
+ title,
208
+ }: {
209
+ readonly configuration: LifecycleDiagramConfiguration;
210
+ readonly selections: readonly LifecycleConfigurationSelection[];
211
+ readonly state: LifecycleConfigurationTarget;
212
+ readonly title: (target: LifecycleConfigurationTarget) => string;
213
+ }): ReactElement {
214
+ const { t } = useTranslation();
215
+ const [open, setOpen] = useState(false);
216
+ const pendingRef = useRef<LifecycleConfigurationSelection | undefined>(
217
+ undefined,
218
+ );
219
+ const first = selections[0];
220
+ const direct =
221
+ first.target.kind === 'state' || selections.length === 1
222
+ ? first
223
+ : undefined;
224
+ const choices = direct ? selections.slice(1) : selections;
225
+ const label = t('flowDiagram.configure', { defaultValue: 'Configure' });
226
+ const className =
227
+ 'h-6 border border-primary/15 bg-primary/5 px-1.5 text-xs text-primary hover:bg-primary/10';
228
+ return (
229
+ <>
230
+ {direct && (
231
+ <Button
232
+ type='button'
233
+ variant='ghost'
234
+ size='sm'
235
+ className={className}
236
+ onClick={(event) => {
237
+ event.stopPropagation();
238
+ configuration.onConfigure(direct);
239
+ }}
240
+ >
241
+ <Settings2Icon aria-hidden />
242
+ {configuration.label(direct.target)}
243
+ </Button>
244
+ )}
245
+ {choices.length > 0 && (
246
+ <Popover
247
+ open={open}
248
+ onOpenChange={(nextOpen) => {
249
+ if (nextOpen) pendingRef.current = undefined;
250
+ setOpen(nextOpen);
251
+ }}
252
+ onOpenChangeComplete={(nextOpen) => {
253
+ // Open the editor only after the chooser has closed and restored focus.
254
+ // Otherwise its focus restoration can steal focus from the editor.
255
+ if (!nextOpen && pendingRef.current) {
256
+ const selection = pendingRef.current;
257
+ pendingRef.current = undefined;
258
+ configuration.onConfigure(selection);
259
+ }
260
+ }}
261
+ >
262
+ <PopoverTrigger
263
+ render={
264
+ <Button
265
+ type='button'
266
+ variant='ghost'
267
+ size='sm'
268
+ className={className}
269
+ aria-label={`${label}: ${title(state)}`}
270
+ onClick={(event) => event.stopPropagation()}
271
+ />
272
+ }
273
+ >
274
+ {!direct && (
275
+ <>
276
+ <Settings2Icon aria-hidden />
277
+ {label}
278
+ </>
279
+ )}
280
+ <ChevronDownIcon aria-hidden />
281
+ </PopoverTrigger>
282
+ <PopoverContent
283
+ align='end'
284
+ className='w-auto max-w-[min(var(--container-sm),var(--available-width))]'
285
+ onClick={(event) => event.stopPropagation()}
286
+ >
287
+ <PopoverTitle className='sr-only'>
288
+ {label}: {title(state)}
289
+ </PopoverTitle>
290
+ {choices.map((selection) => (
291
+ <Button
292
+ key={`${selection.target.kind}:${selection.target.name}`}
293
+ type='button'
294
+ variant='ghost'
295
+ className='h-auto justify-start whitespace-normal text-left'
296
+ onClick={() => {
297
+ pendingRef.current = selection;
298
+ setOpen(false);
299
+ }}
300
+ >
301
+ {configuration.label(selection.target)} ·{' '}
302
+ {title(selection.target)}
303
+ </Button>
304
+ ))}
305
+ </PopoverContent>
306
+ </Popover>
307
+ )}
308
+ </>
309
+ );
310
+ }
@@ -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
+ }