@mmapp/react 0.1.0-alpha.1 → 0.1.0-alpha.4
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/README.md +112 -0
- package/dist/index.d.mts +1378 -94
- package/dist/index.d.ts +1378 -94
- package/dist/index.js +1094 -1309
- package/dist/index.mjs +1038 -1296
- package/package.json +4 -3
- package/package.json.backup +0 -41
- package/src/Blueprint.ts +0 -216
- package/src/__tests__/Blueprint.test.ts +0 -106
- package/src/__tests__/action-context.test.ts +0 -166
- package/src/__tests__/actionCreators.test.ts +0 -179
- package/src/__tests__/builders.test.ts +0 -336
- package/src/__tests__/defineBlueprint-composition.test.ts +0 -106
- package/src/__tests__/factories.test.ts +0 -229
- package/src/__tests__/loader.test.ts +0 -159
- package/src/__tests__/logger.test.ts +0 -70
- package/src/__tests__/type-inference.test.ts +0 -160
- package/src/__tests__/typed-transitions.test.ts +0 -126
- package/src/__tests__/useModuleConfig.test.ts +0 -61
- package/src/actionCreators.ts +0 -132
- package/src/actions.ts +0 -547
- package/src/atoms/index.ts +0 -600
- package/src/authoring.ts +0 -92
- package/src/browser-player.ts +0 -783
- package/src/builders.ts +0 -1342
- package/src/components/ExperienceWorkflowBridge.tsx +0 -123
- package/src/components/PlayerProvider.tsx +0 -43
- package/src/components/atoms/index.tsx +0 -269
- package/src/components/index.ts +0 -36
- package/src/conditions.ts +0 -692
- package/src/config/defineBlueprint.ts +0 -329
- package/src/config/defineModel.ts +0 -753
- package/src/config/defineWorkspace.ts +0 -24
- package/src/core/WorkflowRuntime.ts +0 -153
- package/src/factories.ts +0 -425
- package/src/grammar/index.ts +0 -173
- package/src/hooks/index.ts +0 -106
- package/src/hooks/useAuth.ts +0 -288
- package/src/hooks/useChannel.ts +0 -304
- package/src/hooks/useComputed.ts +0 -154
- package/src/hooks/useDomainSubscription.ts +0 -110
- package/src/hooks/useDuringAction.ts +0 -99
- package/src/hooks/useExperienceState.ts +0 -59
- package/src/hooks/useExpressionLibrary.ts +0 -129
- package/src/hooks/useForm.ts +0 -352
- package/src/hooks/useGeolocation.ts +0 -207
- package/src/hooks/useMapView.ts +0 -259
- package/src/hooks/useMiddleware.ts +0 -291
- package/src/hooks/useModel.ts +0 -363
- package/src/hooks/useModule.ts +0 -59
- package/src/hooks/useModuleConfig.ts +0 -61
- package/src/hooks/useMutation.ts +0 -237
- package/src/hooks/useNotification.ts +0 -151
- package/src/hooks/useOnChange.ts +0 -30
- package/src/hooks/useOnEnter.ts +0 -59
- package/src/hooks/useOnEvent.ts +0 -37
- package/src/hooks/useOnExit.ts +0 -27
- package/src/hooks/useOnTransition.ts +0 -30
- package/src/hooks/usePackage.ts +0 -128
- package/src/hooks/useParams.ts +0 -33
- package/src/hooks/usePlayer.ts +0 -308
- package/src/hooks/useQuery.ts +0 -184
- package/src/hooks/useRealtimeQuery.ts +0 -222
- package/src/hooks/useRole.ts +0 -191
- package/src/hooks/useRouteParams.ts +0 -100
- package/src/hooks/useRouter.ts +0 -347
- package/src/hooks/useServerAction.ts +0 -178
- package/src/hooks/useServerState.ts +0 -284
- package/src/hooks/useToast.ts +0 -164
- package/src/hooks/useTransition.ts +0 -39
- package/src/hooks/useView.ts +0 -102
- package/src/hooks/useWhileIn.ts +0 -48
- package/src/hooks/useWorkflow.ts +0 -63
- package/src/index.ts +0 -465
- package/src/loader/experience-workflow-loader.ts +0 -192
- package/src/loader/index.ts +0 -6
- package/src/local/LocalEngine.ts +0 -388
- package/src/local/LocalEngineAdapter.ts +0 -175
- package/src/local/LocalEngineContext.ts +0 -30
- package/src/logger.ts +0 -37
- package/src/mixins.ts +0 -1160
- package/src/providers/RuntimeContext.ts +0 -20
- package/src/providers/WorkflowProvider.tsx +0 -28
- package/src/routing/instance-key.ts +0 -107
- package/src/server/transition-context.ts +0 -172
- package/src/testing/index.ts +0 -9
- package/src/testing/useBlueprintTestRunner.ts +0 -91
- package/src/testing/useGraphAnalysis.ts +0 -18
- package/src/testing/useTestRunner.ts +0 -77
- package/src/testing.ts +0 -995
- package/src/types/workflow-inference.ts +0 -158
- package/src/types.ts +0 -114
- package/tsconfig.json +0 -27
- package/vitest.config.ts +0 -8
package/src/mixins.ts
DELETED
|
@@ -1,1160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Model Mixins — higher-order functions that enhance ModelDefinitions.
|
|
3
|
-
*
|
|
4
|
-
* Mixins compose reusable cross-cutting concerns (audit trails, soft delete,
|
|
5
|
-
* versioning, RBAC, etc.) into workflow model definitions. Each mixin takes
|
|
6
|
-
* a ModelDefinition and returns a new ModelDefinition with additional fields,
|
|
7
|
-
* states, and/or transitions — without mutating the input.
|
|
8
|
-
*
|
|
9
|
-
* @example
|
|
10
|
-
* ```typescript
|
|
11
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
12
|
-
* import { pipe, withAuditTrail, withSoftDelete, withTags } from '@mindmatrix/react/mixins';
|
|
13
|
-
*
|
|
14
|
-
* export default defineModel(pipe(
|
|
15
|
-
* {
|
|
16
|
-
* slug: 'invoice',
|
|
17
|
-
* fields: { amount: { type: 'currency', required: true } },
|
|
18
|
-
* states: { draft: { type: 'initial' }, sent: {}, paid: { type: 'end' } },
|
|
19
|
-
* transitions: {
|
|
20
|
-
* send: { from: 'draft', to: 'sent' },
|
|
21
|
-
* pay: { from: 'sent', to: 'paid' },
|
|
22
|
-
* },
|
|
23
|
-
* },
|
|
24
|
-
* withAuditTrail(),
|
|
25
|
-
* withSoftDelete({ retentionDays: 90 }),
|
|
26
|
-
* withTags(),
|
|
27
|
-
* ));
|
|
28
|
-
* ```
|
|
29
|
-
*
|
|
30
|
-
* @module @mindmatrix/react/mixins
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
import type {
|
|
34
|
-
ModelDefinition,
|
|
35
|
-
WorkflowFieldDescriptor,
|
|
36
|
-
StateDescriptor,
|
|
37
|
-
TransitionDescriptor,
|
|
38
|
-
ActionDefinition,
|
|
39
|
-
RoleDefinition,
|
|
40
|
-
} from './config/defineModel';
|
|
41
|
-
|
|
42
|
-
// =============================================================================
|
|
43
|
-
// Core Types
|
|
44
|
-
// =============================================================================
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* A mixin function that takes a ModelDefinition and returns an enhanced one.
|
|
48
|
-
*
|
|
49
|
-
* Mixins MUST be pure — they never mutate the input definition. They return
|
|
50
|
-
* a new object with merged fields, states, transitions, and roles.
|
|
51
|
-
*/
|
|
52
|
-
export type ModelMixin = (def: ModelDefinition) => ModelDefinition;
|
|
53
|
-
|
|
54
|
-
// =============================================================================
|
|
55
|
-
// Deep Merge Helpers (internal)
|
|
56
|
-
// =============================================================================
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Deep-merge two records of fields, preserving existing entries.
|
|
60
|
-
* New fields are added; existing fields are NOT overwritten.
|
|
61
|
-
*/
|
|
62
|
-
function mergeFields(
|
|
63
|
-
existing: Record<string, WorkflowFieldDescriptor>,
|
|
64
|
-
incoming: Record<string, WorkflowFieldDescriptor>,
|
|
65
|
-
): Record<string, WorkflowFieldDescriptor> {
|
|
66
|
-
const result = { ...existing };
|
|
67
|
-
for (const [key, value] of Object.entries(incoming)) {
|
|
68
|
-
if (!(key in result)) {
|
|
69
|
-
result[key] = value;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return result;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Deep-merge two records of states. For existing states, onEnter/onExit
|
|
77
|
-
* actions from `incoming` are appended (not replaced).
|
|
78
|
-
*/
|
|
79
|
-
function mergeStates(
|
|
80
|
-
existing: Record<string, StateDescriptor>,
|
|
81
|
-
incoming: Record<string, StateDescriptor>,
|
|
82
|
-
): Record<string, StateDescriptor> {
|
|
83
|
-
const result: Record<string, StateDescriptor> = {};
|
|
84
|
-
// Copy all existing states
|
|
85
|
-
for (const [key, value] of Object.entries(existing)) {
|
|
86
|
-
result[key] = { ...value };
|
|
87
|
-
}
|
|
88
|
-
// Merge incoming states
|
|
89
|
-
for (const [key, value] of Object.entries(incoming)) {
|
|
90
|
-
if (key in result) {
|
|
91
|
-
const merged: StateDescriptor = { ...result[key] };
|
|
92
|
-
if (value.onEnter && value.onEnter.length > 0) {
|
|
93
|
-
merged.onEnter = [...(merged.onEnter ?? []), ...value.onEnter];
|
|
94
|
-
}
|
|
95
|
-
if (value.onExit && value.onExit.length > 0) {
|
|
96
|
-
merged.onExit = [...(merged.onExit ?? []), ...value.onExit];
|
|
97
|
-
}
|
|
98
|
-
result[key] = merged;
|
|
99
|
-
} else {
|
|
100
|
-
result[key] = { ...value };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return result;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Merge transitions — new transitions are added; existing ones are NOT overwritten.
|
|
108
|
-
*/
|
|
109
|
-
function mergeTransitions(
|
|
110
|
-
existing: Record<string, TransitionDescriptor>,
|
|
111
|
-
incoming: Record<string, TransitionDescriptor>,
|
|
112
|
-
): Record<string, TransitionDescriptor> {
|
|
113
|
-
const result = { ...existing };
|
|
114
|
-
for (const [key, value] of Object.entries(incoming)) {
|
|
115
|
-
if (!(key in result)) {
|
|
116
|
-
result[key] = value;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return result;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Merge role definitions — new roles are added; existing ones are NOT overwritten.
|
|
124
|
-
*/
|
|
125
|
-
function mergeRoles(
|
|
126
|
-
existing: Record<string, RoleDefinition> | undefined,
|
|
127
|
-
incoming: Record<string, RoleDefinition> | undefined,
|
|
128
|
-
): Record<string, RoleDefinition> | undefined {
|
|
129
|
-
if (!incoming) return existing;
|
|
130
|
-
if (!existing) return incoming;
|
|
131
|
-
const result = { ...existing };
|
|
132
|
-
for (const [key, value] of Object.entries(incoming)) {
|
|
133
|
-
if (!(key in result)) {
|
|
134
|
-
result[key] = value;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return result;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Collect all non-terminal state names from a definition.
|
|
142
|
-
* Non-terminal = not type 'end' and not type 'cancelled'.
|
|
143
|
-
*/
|
|
144
|
-
function getNonTerminalStates(def: ModelDefinition): string[] {
|
|
145
|
-
return Object.entries(def.states)
|
|
146
|
-
.filter(([, s]) => s.type !== 'end' && s.type !== 'cancelled')
|
|
147
|
-
.map(([name]) => name);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// =============================================================================
|
|
151
|
-
// Core Infrastructure
|
|
152
|
-
// =============================================================================
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Apply multiple mixins to a model definition, left to right.
|
|
156
|
-
*
|
|
157
|
-
* Each mixin receives the output of the previous mixin, allowing
|
|
158
|
-
* composed enhancements to build on each other.
|
|
159
|
-
*
|
|
160
|
-
* @param def - The base model definition.
|
|
161
|
-
* @param mixins - One or more mixin functions to apply.
|
|
162
|
-
* @returns A new ModelDefinition with all mixins applied.
|
|
163
|
-
*
|
|
164
|
-
* @example
|
|
165
|
-
* ```typescript
|
|
166
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
167
|
-
* import { applyMixins, withTimestamps, withTags } from '@mindmatrix/react/mixins';
|
|
168
|
-
*
|
|
169
|
-
* const base = {
|
|
170
|
-
* slug: 'article',
|
|
171
|
-
* fields: { title: { type: 'string' } },
|
|
172
|
-
* states: { draft: { type: 'initial' as const }, published: { type: 'end' as const } },
|
|
173
|
-
* transitions: { publish: { from: 'draft', to: 'published' } },
|
|
174
|
-
* };
|
|
175
|
-
*
|
|
176
|
-
* export default defineModel(applyMixins(base, withTimestamps(), withTags()));
|
|
177
|
-
* ```
|
|
178
|
-
*/
|
|
179
|
-
export function applyMixins(def: ModelDefinition, ...mixins: ModelMixin[]): ModelDefinition {
|
|
180
|
-
return mixins.reduce<ModelDefinition>((acc, mixin) => mixin(acc), def);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Functional composition helper — alias for `applyMixins`.
|
|
185
|
-
*
|
|
186
|
-
* Reads naturally as a pipeline: start with a definition, then pipe
|
|
187
|
-
* it through a series of mixins.
|
|
188
|
-
*
|
|
189
|
-
* @param def - The base model definition.
|
|
190
|
-
* @param mixins - One or more mixin functions to apply.
|
|
191
|
-
* @returns A new ModelDefinition with all mixins applied.
|
|
192
|
-
*
|
|
193
|
-
* @example
|
|
194
|
-
* ```typescript
|
|
195
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
196
|
-
* import { pipe, withAuditTrail, withSoftDelete, withOwnership } from '@mindmatrix/react/mixins';
|
|
197
|
-
*
|
|
198
|
-
* export default defineModel(pipe(
|
|
199
|
-
* {
|
|
200
|
-
* slug: 'task',
|
|
201
|
-
* fields: { title: { type: 'string', required: true } },
|
|
202
|
-
* states: { open: { type: 'initial' }, done: { type: 'end' } },
|
|
203
|
-
* transitions: { complete: { from: 'open', to: 'done' } },
|
|
204
|
-
* },
|
|
205
|
-
* withOwnership(),
|
|
206
|
-
* withAuditTrail(),
|
|
207
|
-
* withSoftDelete(),
|
|
208
|
-
* ));
|
|
209
|
-
* ```
|
|
210
|
-
*/
|
|
211
|
-
export function pipe(def: ModelDefinition, ...mixins: ModelMixin[]): ModelDefinition {
|
|
212
|
-
return applyMixins(def, ...mixins);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// =============================================================================
|
|
216
|
-
// withAuditTrail
|
|
217
|
-
// =============================================================================
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Options for the {@link withAuditTrail} mixin.
|
|
221
|
-
*/
|
|
222
|
-
export interface WithAuditTrailOptions {
|
|
223
|
-
/**
|
|
224
|
-
* If specified, adds a `changeLog` array field that records which
|
|
225
|
-
* tracked fields changed on each transition.
|
|
226
|
-
*
|
|
227
|
-
* @example `{ trackFields: ['status', 'amount', 'assignee'] }`
|
|
228
|
-
*/
|
|
229
|
-
trackFields?: string[];
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Adds audit trail fields and auto-set actions to a model definition.
|
|
234
|
-
*
|
|
235
|
-
* Injects:
|
|
236
|
-
* - `createdAt` (datetime) — set on initial state entry
|
|
237
|
-
* - `createdBy` (string) — set on initial state entry from `context.actor_id`
|
|
238
|
-
* - `updatedAt` (datetime) — set on every transition
|
|
239
|
-
* - `updatedBy` (string) — set on every transition from `context.actor_id`
|
|
240
|
-
* - `changeLog` (array, optional) — records field-level changes if `trackFields` is set
|
|
241
|
-
*
|
|
242
|
-
* @param options - Optional configuration.
|
|
243
|
-
* @returns A {@link ModelMixin} function.
|
|
244
|
-
*
|
|
245
|
-
* @example
|
|
246
|
-
* ```typescript
|
|
247
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
248
|
-
* import { pipe, withAuditTrail } from '@mindmatrix/react/mixins';
|
|
249
|
-
*
|
|
250
|
-
* export default defineModel(pipe(
|
|
251
|
-
* {
|
|
252
|
-
* slug: 'order',
|
|
253
|
-
* fields: { total: { type: 'currency' } },
|
|
254
|
-
* states: { draft: { type: 'initial' }, placed: { type: 'end' } },
|
|
255
|
-
* transitions: { place: { from: 'draft', to: 'placed' } },
|
|
256
|
-
* },
|
|
257
|
-
* withAuditTrail({ trackFields: ['total'] }),
|
|
258
|
-
* ));
|
|
259
|
-
* ```
|
|
260
|
-
*/
|
|
261
|
-
export function withAuditTrail(options: WithAuditTrailOptions = {}): ModelMixin {
|
|
262
|
-
return (def: ModelDefinition): ModelDefinition => {
|
|
263
|
-
const auditFields: Record<string, WorkflowFieldDescriptor> = {
|
|
264
|
-
createdAt: { type: 'datetime', label: 'Created At' },
|
|
265
|
-
createdBy: { type: 'string', label: 'Created By' },
|
|
266
|
-
updatedAt: { type: 'datetime', label: 'Updated At' },
|
|
267
|
-
updatedBy: { type: 'string', label: 'Updated By' },
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
if (options.trackFields && options.trackFields.length > 0) {
|
|
271
|
-
auditFields.changeLog = {
|
|
272
|
-
type: 'array',
|
|
273
|
-
items: { type: 'object' },
|
|
274
|
-
label: 'Change Log',
|
|
275
|
-
description: `Tracks changes to: ${options.trackFields.join(', ')}`,
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Build set_fields actions for transitions
|
|
280
|
-
const updateAction: ActionDefinition = {
|
|
281
|
-
id: '_audit_update',
|
|
282
|
-
type: 'set_fields',
|
|
283
|
-
mode: 'auto',
|
|
284
|
-
config: {
|
|
285
|
-
fields: {
|
|
286
|
-
updatedAt: { expression: 'NOW()' },
|
|
287
|
-
updatedBy: { expression: 'context.actor_id' },
|
|
288
|
-
},
|
|
289
|
-
},
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
// Inject updatedAt/updatedBy into all existing transitions
|
|
293
|
-
const transitions: Record<string, TransitionDescriptor> = {};
|
|
294
|
-
for (const [name, t] of Object.entries(def.transitions)) {
|
|
295
|
-
transitions[name] = {
|
|
296
|
-
...t,
|
|
297
|
-
actions: [...(t.actions ?? []), updateAction],
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Inject createdAt/createdBy into onEnter of the initial state
|
|
302
|
-
const states: Record<string, StateDescriptor> = {};
|
|
303
|
-
for (const [name, s] of Object.entries(def.states)) {
|
|
304
|
-
if (s.type === 'initial') {
|
|
305
|
-
const creationAction: ActionDefinition = {
|
|
306
|
-
id: '_audit_create',
|
|
307
|
-
type: 'set_fields',
|
|
308
|
-
mode: 'auto',
|
|
309
|
-
config: {
|
|
310
|
-
fields: {
|
|
311
|
-
createdAt: { expression: 'NOW()' },
|
|
312
|
-
createdBy: { expression: 'context.actor_id' },
|
|
313
|
-
updatedAt: { expression: 'NOW()' },
|
|
314
|
-
updatedBy: { expression: 'context.actor_id' },
|
|
315
|
-
},
|
|
316
|
-
},
|
|
317
|
-
};
|
|
318
|
-
states[name] = {
|
|
319
|
-
...s,
|
|
320
|
-
onEnter: [...(s.onEnter ?? []), creationAction],
|
|
321
|
-
};
|
|
322
|
-
} else {
|
|
323
|
-
states[name] = { ...s };
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return {
|
|
328
|
-
...def,
|
|
329
|
-
fields: mergeFields(def.fields, auditFields),
|
|
330
|
-
states: mergeStates(def.states, states),
|
|
331
|
-
transitions,
|
|
332
|
-
};
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// =============================================================================
|
|
337
|
-
// withSoftDelete
|
|
338
|
-
// =============================================================================
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Options for the {@link withSoftDelete} mixin.
|
|
342
|
-
*/
|
|
343
|
-
export interface WithSoftDeleteOptions {
|
|
344
|
-
/**
|
|
345
|
-
* Number of days to retain soft-deleted records before permanent deletion.
|
|
346
|
-
* Stored as metadata; enforcement is handled by the engine or a scheduled job.
|
|
347
|
-
*/
|
|
348
|
-
retentionDays?: number;
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Whether restore (un-delete) is allowed. Default: `true`.
|
|
352
|
-
*/
|
|
353
|
-
restoreAllowed?: boolean;
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Roles allowed to perform delete/restore. If omitted, any role can.
|
|
357
|
-
*/
|
|
358
|
-
roles?: string[];
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Adds soft-delete capability to a model definition.
|
|
363
|
-
*
|
|
364
|
-
* Injects:
|
|
365
|
-
* - A `deleted` end state
|
|
366
|
-
* - `deletedAt` (datetime) and `deletedBy` (string) fields
|
|
367
|
-
* - A `delete` transition from all non-terminal states to `deleted`
|
|
368
|
-
* - A `restore` transition from `deleted` back to the initial state (if `restoreAllowed`)
|
|
369
|
-
*
|
|
370
|
-
* @param options - Optional configuration.
|
|
371
|
-
* @returns A {@link ModelMixin} function.
|
|
372
|
-
*
|
|
373
|
-
* @example
|
|
374
|
-
* ```typescript
|
|
375
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
376
|
-
* import { pipe, withSoftDelete } from '@mindmatrix/react/mixins';
|
|
377
|
-
*
|
|
378
|
-
* export default defineModel(pipe(
|
|
379
|
-
* {
|
|
380
|
-
* slug: 'document',
|
|
381
|
-
* fields: { title: { type: 'string' } },
|
|
382
|
-
* states: { active: { type: 'initial' }, archived: { type: 'end' } },
|
|
383
|
-
* transitions: { archive: { from: 'active', to: 'archived' } },
|
|
384
|
-
* },
|
|
385
|
-
* withSoftDelete({ retentionDays: 30, roles: ['admin'] }),
|
|
386
|
-
* ));
|
|
387
|
-
* ```
|
|
388
|
-
*/
|
|
389
|
-
export function withSoftDelete(options: WithSoftDeleteOptions = {}): ModelMixin {
|
|
390
|
-
const { retentionDays, restoreAllowed = true, roles } = options;
|
|
391
|
-
|
|
392
|
-
return (def: ModelDefinition): ModelDefinition => {
|
|
393
|
-
const deleteFields: Record<string, WorkflowFieldDescriptor> = {
|
|
394
|
-
deletedAt: { type: 'datetime', label: 'Deleted At' },
|
|
395
|
-
deletedBy: { type: 'string', label: 'Deleted By' },
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
const deleteState: Record<string, StateDescriptor> = {
|
|
399
|
-
deleted: { type: 'end', description: 'Soft-deleted record' },
|
|
400
|
-
};
|
|
401
|
-
|
|
402
|
-
const nonTerminalStates = getNonTerminalStates(def);
|
|
403
|
-
|
|
404
|
-
const deleteTransition: TransitionDescriptor = {
|
|
405
|
-
from: nonTerminalStates,
|
|
406
|
-
to: 'deleted',
|
|
407
|
-
description: 'Soft-delete this record',
|
|
408
|
-
...(roles ? { roles } : {}),
|
|
409
|
-
actions: [
|
|
410
|
-
{
|
|
411
|
-
id: '_soft_delete_stamp',
|
|
412
|
-
type: 'set_fields',
|
|
413
|
-
mode: 'auto',
|
|
414
|
-
config: {
|
|
415
|
-
fields: {
|
|
416
|
-
deletedAt: { expression: 'NOW()' },
|
|
417
|
-
deletedBy: { expression: 'context.actor_id' },
|
|
418
|
-
},
|
|
419
|
-
},
|
|
420
|
-
},
|
|
421
|
-
],
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
const newTransitions: Record<string, TransitionDescriptor> = {
|
|
425
|
-
delete: deleteTransition,
|
|
426
|
-
};
|
|
427
|
-
|
|
428
|
-
// Find the initial state for restore target
|
|
429
|
-
if (restoreAllowed) {
|
|
430
|
-
const initialState = Object.entries(def.states).find(([, s]) => s.type === 'initial');
|
|
431
|
-
if (initialState) {
|
|
432
|
-
// Restore goes back to initial state; change type to regular (not end)
|
|
433
|
-
deleteState.deleted = { description: 'Soft-deleted record' };
|
|
434
|
-
newTransitions.restore = {
|
|
435
|
-
from: 'deleted',
|
|
436
|
-
to: initialState[0],
|
|
437
|
-
description: 'Restore a soft-deleted record',
|
|
438
|
-
...(roles ? { roles } : {}),
|
|
439
|
-
actions: [
|
|
440
|
-
{
|
|
441
|
-
id: '_soft_delete_clear',
|
|
442
|
-
type: 'set_fields',
|
|
443
|
-
mode: 'auto',
|
|
444
|
-
config: {
|
|
445
|
-
fields: {
|
|
446
|
-
deletedAt: { expression: 'null' },
|
|
447
|
-
deletedBy: { expression: 'null' },
|
|
448
|
-
},
|
|
449
|
-
},
|
|
450
|
-
},
|
|
451
|
-
],
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
const result: ModelDefinition = {
|
|
457
|
-
...def,
|
|
458
|
-
fields: mergeFields(def.fields, deleteFields),
|
|
459
|
-
states: mergeStates(def.states, deleteState),
|
|
460
|
-
transitions: mergeTransitions(def.transitions, newTransitions),
|
|
461
|
-
};
|
|
462
|
-
|
|
463
|
-
if (retentionDays !== undefined) {
|
|
464
|
-
result.metadata = {
|
|
465
|
-
...def.metadata,
|
|
466
|
-
softDelete: { retentionDays },
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
return result;
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// =============================================================================
|
|
475
|
-
// withVersioning
|
|
476
|
-
// =============================================================================
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Options for the {@link withVersioning} mixin.
|
|
480
|
-
*/
|
|
481
|
-
export interface WithVersioningOptions {
|
|
482
|
-
/**
|
|
483
|
-
* Transition names that should automatically create a version snapshot
|
|
484
|
-
* before executing. A `set_fields` action incrementing `version` is
|
|
485
|
-
* prepended to these transitions.
|
|
486
|
-
*
|
|
487
|
-
* @example `{ autoVersionOnTransitions: ['publish', 'approve'] }`
|
|
488
|
-
*/
|
|
489
|
-
autoVersionOnTransitions?: string[];
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* Adds version tracking to a model definition.
|
|
494
|
-
*
|
|
495
|
-
* Injects:
|
|
496
|
-
* - `version` (number, default 1) — incremented on version-creating transitions
|
|
497
|
-
* - `previousVersionId` (string) — ID of the prior version snapshot
|
|
498
|
-
* - A `create_version` self-transition on the initial state that spawns
|
|
499
|
-
* a snapshot sub-workflow and increments the version counter
|
|
500
|
-
*
|
|
501
|
-
* @param options - Optional configuration.
|
|
502
|
-
* @returns A {@link ModelMixin} function.
|
|
503
|
-
*
|
|
504
|
-
* @example
|
|
505
|
-
* ```typescript
|
|
506
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
507
|
-
* import { pipe, withVersioning } from '@mindmatrix/react/mixins';
|
|
508
|
-
*
|
|
509
|
-
* export default defineModel(pipe(
|
|
510
|
-
* {
|
|
511
|
-
* slug: 'policy',
|
|
512
|
-
* fields: { content: { type: 'string' } },
|
|
513
|
-
* states: { draft: { type: 'initial' }, published: {} },
|
|
514
|
-
* transitions: { publish: { from: 'draft', to: 'published' } },
|
|
515
|
-
* },
|
|
516
|
-
* withVersioning({ autoVersionOnTransitions: ['publish'] }),
|
|
517
|
-
* ));
|
|
518
|
-
* ```
|
|
519
|
-
*/
|
|
520
|
-
export function withVersioning(options: WithVersioningOptions = {}): ModelMixin {
|
|
521
|
-
return (def: ModelDefinition): ModelDefinition => {
|
|
522
|
-
const versionFields: Record<string, WorkflowFieldDescriptor> = {
|
|
523
|
-
version: { type: 'number', default: 1, label: 'Version' },
|
|
524
|
-
previousVersionId: { type: 'string', label: 'Previous Version ID' },
|
|
525
|
-
};
|
|
526
|
-
|
|
527
|
-
const nonTerminalStates = getNonTerminalStates(def);
|
|
528
|
-
|
|
529
|
-
const newTransitions: Record<string, TransitionDescriptor> = {
|
|
530
|
-
create_version: {
|
|
531
|
-
from: nonTerminalStates,
|
|
532
|
-
to: nonTerminalStates[0],
|
|
533
|
-
description: 'Create a version snapshot',
|
|
534
|
-
actions: [
|
|
535
|
-
{
|
|
536
|
-
id: '_version_snapshot',
|
|
537
|
-
type: 'spawn_subworkflow',
|
|
538
|
-
mode: 'auto',
|
|
539
|
-
config: {
|
|
540
|
-
definition_slug: def.slug,
|
|
541
|
-
blocking: false,
|
|
542
|
-
input_mapping: { _snapshot: 'true' },
|
|
543
|
-
},
|
|
544
|
-
},
|
|
545
|
-
{
|
|
546
|
-
id: '_version_increment',
|
|
547
|
-
type: 'set_fields',
|
|
548
|
-
mode: 'auto',
|
|
549
|
-
config: {
|
|
550
|
-
fields: {
|
|
551
|
-
version: { expression: 'state_data.version + 1' },
|
|
552
|
-
},
|
|
553
|
-
},
|
|
554
|
-
},
|
|
555
|
-
],
|
|
556
|
-
},
|
|
557
|
-
};
|
|
558
|
-
|
|
559
|
-
// If autoVersionOnTransitions is specified, inject version increment
|
|
560
|
-
// into those transitions
|
|
561
|
-
const transitions: Record<string, TransitionDescriptor> = { ...def.transitions };
|
|
562
|
-
if (options.autoVersionOnTransitions) {
|
|
563
|
-
const versionAction: ActionDefinition = {
|
|
564
|
-
id: '_auto_version',
|
|
565
|
-
type: 'set_fields',
|
|
566
|
-
mode: 'auto',
|
|
567
|
-
config: {
|
|
568
|
-
fields: {
|
|
569
|
-
version: { expression: 'state_data.version + 1' },
|
|
570
|
-
},
|
|
571
|
-
},
|
|
572
|
-
};
|
|
573
|
-
|
|
574
|
-
for (const transName of options.autoVersionOnTransitions) {
|
|
575
|
-
if (transName in transitions) {
|
|
576
|
-
const existing = transitions[transName];
|
|
577
|
-
transitions[transName] = {
|
|
578
|
-
...existing,
|
|
579
|
-
actions: [versionAction, ...(existing.actions ?? [])],
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
return {
|
|
586
|
-
...def,
|
|
587
|
-
fields: mergeFields(def.fields, versionFields),
|
|
588
|
-
transitions: mergeTransitions(transitions, newTransitions),
|
|
589
|
-
};
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// =============================================================================
|
|
594
|
-
// withRBAC
|
|
595
|
-
// =============================================================================
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* Options for the {@link withRBAC} mixin.
|
|
599
|
-
*/
|
|
600
|
-
export interface WithRBACOptions {
|
|
601
|
-
/**
|
|
602
|
-
* Role definitions to add to the model. Each role can have permissions
|
|
603
|
-
* and can inherit from other roles.
|
|
604
|
-
*
|
|
605
|
-
* @example
|
|
606
|
-
* ```typescript
|
|
607
|
-
* roles: {
|
|
608
|
-
* admin: { permissions: ['manage_users', 'delete'], inherits: ['editor'] },
|
|
609
|
-
* editor: { permissions: ['read', 'write'] },
|
|
610
|
-
* viewer: { permissions: ['read'] },
|
|
611
|
-
* }
|
|
612
|
-
* ```
|
|
613
|
-
*/
|
|
614
|
-
roles: Record<string, { permissions?: string[]; inherits?: string[] }>;
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Map of transition names to the roles allowed to trigger them.
|
|
618
|
-
* Injects role guards into the specified transitions.
|
|
619
|
-
*
|
|
620
|
-
* @example `{ transitionRoles: { approve: ['admin', 'manager'], delete: ['admin'] } }`
|
|
621
|
-
*/
|
|
622
|
-
transitionRoles?: Record<string, string[]>;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Adds role-based access control (RBAC) to a model definition.
|
|
627
|
-
*
|
|
628
|
-
* Injects:
|
|
629
|
-
* - Role definitions into `roles`
|
|
630
|
-
* - Role guards (via `roles` property) into specified transitions
|
|
631
|
-
*
|
|
632
|
-
* @param options - RBAC configuration (required).
|
|
633
|
-
* @returns A {@link ModelMixin} function.
|
|
634
|
-
*
|
|
635
|
-
* @example
|
|
636
|
-
* ```typescript
|
|
637
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
638
|
-
* import { pipe, withRBAC } from '@mindmatrix/react/mixins';
|
|
639
|
-
*
|
|
640
|
-
* export default defineModel(pipe(
|
|
641
|
-
* {
|
|
642
|
-
* slug: 'expense-report',
|
|
643
|
-
* fields: { amount: { type: 'currency' } },
|
|
644
|
-
* states: { draft: { type: 'initial' }, approved: { type: 'end' } },
|
|
645
|
-
* transitions: { approve: { from: 'draft', to: 'approved' } },
|
|
646
|
-
* },
|
|
647
|
-
* withRBAC({
|
|
648
|
-
* roles: {
|
|
649
|
-
* admin: { permissions: ['approve', 'delete'] },
|
|
650
|
-
* submitter: { permissions: ['create', 'read'] },
|
|
651
|
-
* },
|
|
652
|
-
* transitionRoles: { approve: ['admin'] },
|
|
653
|
-
* }),
|
|
654
|
-
* ));
|
|
655
|
-
* ```
|
|
656
|
-
*/
|
|
657
|
-
export function withRBAC(options: WithRBACOptions): ModelMixin {
|
|
658
|
-
return (def: ModelDefinition): ModelDefinition => {
|
|
659
|
-
const newRoles: Record<string, RoleDefinition> = {};
|
|
660
|
-
for (const [name, role] of Object.entries(options.roles)) {
|
|
661
|
-
newRoles[name] = {
|
|
662
|
-
permissions: role.permissions,
|
|
663
|
-
inherits: role.inherits,
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// Inject role guards into transitions
|
|
668
|
-
const transitions: Record<string, TransitionDescriptor> = { ...def.transitions };
|
|
669
|
-
if (options.transitionRoles) {
|
|
670
|
-
for (const [transName, allowedRoles] of Object.entries(options.transitionRoles)) {
|
|
671
|
-
if (transName in transitions) {
|
|
672
|
-
transitions[transName] = {
|
|
673
|
-
...transitions[transName],
|
|
674
|
-
roles: allowedRoles,
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
return {
|
|
681
|
-
...def,
|
|
682
|
-
roles: mergeRoles(def.roles, newRoles),
|
|
683
|
-
transitions,
|
|
684
|
-
};
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// =============================================================================
|
|
689
|
-
// withTimestamps
|
|
690
|
-
// =============================================================================
|
|
691
|
-
|
|
692
|
-
/**
|
|
693
|
-
* Adds simple timestamp fields (`createdAt`, `updatedAt`) with auto-set logic.
|
|
694
|
-
*
|
|
695
|
-
* This is a lighter alternative to {@link withAuditTrail} when you only need
|
|
696
|
-
* timestamps without `createdBy`/`updatedBy` tracking.
|
|
697
|
-
*
|
|
698
|
-
* Injects:
|
|
699
|
-
* - `createdAt` (datetime) — set on initial state entry
|
|
700
|
-
* - `updatedAt` (datetime) — set on every transition
|
|
701
|
-
*
|
|
702
|
-
* @returns A {@link ModelMixin} function.
|
|
703
|
-
*
|
|
704
|
-
* @example
|
|
705
|
-
* ```typescript
|
|
706
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
707
|
-
* import { pipe, withTimestamps } from '@mindmatrix/react/mixins';
|
|
708
|
-
*
|
|
709
|
-
* export default defineModel(pipe(
|
|
710
|
-
* {
|
|
711
|
-
* slug: 'note',
|
|
712
|
-
* fields: { body: { type: 'string' } },
|
|
713
|
-
* states: { active: { type: 'initial' } },
|
|
714
|
-
* transitions: {},
|
|
715
|
-
* },
|
|
716
|
-
* withTimestamps(),
|
|
717
|
-
* ));
|
|
718
|
-
* ```
|
|
719
|
-
*/
|
|
720
|
-
export function withTimestamps(): ModelMixin {
|
|
721
|
-
return (def: ModelDefinition): ModelDefinition => {
|
|
722
|
-
const timestampFields: Record<string, WorkflowFieldDescriptor> = {
|
|
723
|
-
createdAt: { type: 'datetime', label: 'Created At' },
|
|
724
|
-
updatedAt: { type: 'datetime', label: 'Updated At' },
|
|
725
|
-
};
|
|
726
|
-
|
|
727
|
-
// Set createdAt on initial state entry
|
|
728
|
-
const states: Record<string, StateDescriptor> = {};
|
|
729
|
-
for (const [name, s] of Object.entries(def.states)) {
|
|
730
|
-
if (s.type === 'initial') {
|
|
731
|
-
states[name] = {
|
|
732
|
-
...s,
|
|
733
|
-
onEnter: [
|
|
734
|
-
...(s.onEnter ?? []),
|
|
735
|
-
{
|
|
736
|
-
id: '_timestamps_create',
|
|
737
|
-
type: 'set_fields',
|
|
738
|
-
mode: 'auto' as const,
|
|
739
|
-
config: {
|
|
740
|
-
fields: {
|
|
741
|
-
createdAt: { expression: 'NOW()' },
|
|
742
|
-
updatedAt: { expression: 'NOW()' },
|
|
743
|
-
},
|
|
744
|
-
},
|
|
745
|
-
},
|
|
746
|
-
],
|
|
747
|
-
};
|
|
748
|
-
} else {
|
|
749
|
-
states[name] = { ...s };
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// Set updatedAt on every transition
|
|
754
|
-
const transitions: Record<string, TransitionDescriptor> = {};
|
|
755
|
-
const updateAction: ActionDefinition = {
|
|
756
|
-
id: '_timestamps_update',
|
|
757
|
-
type: 'set_fields',
|
|
758
|
-
mode: 'auto',
|
|
759
|
-
config: {
|
|
760
|
-
fields: {
|
|
761
|
-
updatedAt: { expression: 'NOW()' },
|
|
762
|
-
},
|
|
763
|
-
},
|
|
764
|
-
};
|
|
765
|
-
for (const [name, t] of Object.entries(def.transitions)) {
|
|
766
|
-
transitions[name] = {
|
|
767
|
-
...t,
|
|
768
|
-
actions: [...(t.actions ?? []), updateAction],
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
return {
|
|
773
|
-
...def,
|
|
774
|
-
fields: mergeFields(def.fields, timestampFields),
|
|
775
|
-
states: mergeStates(def.states, states),
|
|
776
|
-
transitions,
|
|
777
|
-
};
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// =============================================================================
|
|
782
|
-
// withSlug
|
|
783
|
-
// =============================================================================
|
|
784
|
-
|
|
785
|
-
/**
|
|
786
|
-
* Options for the {@link withSlug} mixin.
|
|
787
|
-
*/
|
|
788
|
-
export interface WithSlugOptions {
|
|
789
|
-
/**
|
|
790
|
-
* The field name to derive the slug from (e.g., `'title'`, `'name'`).
|
|
791
|
-
*/
|
|
792
|
-
sourceField: string;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/**
|
|
796
|
-
* Adds an auto-generated `slug` field derived from a source field.
|
|
797
|
-
*
|
|
798
|
-
* The slug is computed as a kebab-case, URL-safe version of the source field.
|
|
799
|
-
* It is set via a computed expression.
|
|
800
|
-
*
|
|
801
|
-
* Injects:
|
|
802
|
-
* - `slug` (string) — computed from the source field
|
|
803
|
-
*
|
|
804
|
-
* @param options - Configuration specifying the source field.
|
|
805
|
-
* @returns A {@link ModelMixin} function.
|
|
806
|
-
*
|
|
807
|
-
* @example
|
|
808
|
-
* ```typescript
|
|
809
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
810
|
-
* import { pipe, withSlug } from '@mindmatrix/react/mixins';
|
|
811
|
-
*
|
|
812
|
-
* export default defineModel(pipe(
|
|
813
|
-
* {
|
|
814
|
-
* slug: 'article',
|
|
815
|
-
* fields: { title: { type: 'string', required: true } },
|
|
816
|
-
* states: { draft: { type: 'initial' }, published: { type: 'end' } },
|
|
817
|
-
* transitions: { publish: { from: 'draft', to: 'published' } },
|
|
818
|
-
* },
|
|
819
|
-
* withSlug({ sourceField: 'title' }),
|
|
820
|
-
* ));
|
|
821
|
-
* ```
|
|
822
|
-
*/
|
|
823
|
-
export function withSlug(options: WithSlugOptions): ModelMixin {
|
|
824
|
-
return (def: ModelDefinition): ModelDefinition => {
|
|
825
|
-
const slugField: Record<string, WorkflowFieldDescriptor> = {
|
|
826
|
-
slug: {
|
|
827
|
-
type: 'string',
|
|
828
|
-
label: 'Slug',
|
|
829
|
-
description: `Auto-generated from ${options.sourceField}`,
|
|
830
|
-
computed: `SLUGIFY(state_data.${options.sourceField})`,
|
|
831
|
-
computedDeps: [options.sourceField],
|
|
832
|
-
},
|
|
833
|
-
};
|
|
834
|
-
|
|
835
|
-
return {
|
|
836
|
-
...def,
|
|
837
|
-
fields: mergeFields(def.fields, slugField),
|
|
838
|
-
};
|
|
839
|
-
};
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
// =============================================================================
|
|
843
|
-
// withOwnership
|
|
844
|
-
// =============================================================================
|
|
845
|
-
|
|
846
|
-
/**
|
|
847
|
-
* Options for the {@link withOwnership} mixin.
|
|
848
|
-
*/
|
|
849
|
-
export interface WithOwnershipOptions {
|
|
850
|
-
/**
|
|
851
|
-
* Field name for the owner ID. Default: `'ownerId'`.
|
|
852
|
-
*/
|
|
853
|
-
ownerField?: string;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/**
|
|
857
|
-
* Adds ownership fields auto-set from context on creation.
|
|
858
|
-
*
|
|
859
|
-
* Injects:
|
|
860
|
-
* - `ownerId` (string, or custom name) — set to `context.actor_id` on initial state entry
|
|
861
|
-
* - `ownerName` (string) — set to `context.actor_name` on initial state entry
|
|
862
|
-
*
|
|
863
|
-
* @param options - Optional configuration.
|
|
864
|
-
* @returns A {@link ModelMixin} function.
|
|
865
|
-
*
|
|
866
|
-
* @example
|
|
867
|
-
* ```typescript
|
|
868
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
869
|
-
* import { pipe, withOwnership } from '@mindmatrix/react/mixins';
|
|
870
|
-
*
|
|
871
|
-
* export default defineModel(pipe(
|
|
872
|
-
* {
|
|
873
|
-
* slug: 'project',
|
|
874
|
-
* fields: { name: { type: 'string' } },
|
|
875
|
-
* states: { active: { type: 'initial' }, completed: { type: 'end' } },
|
|
876
|
-
* transitions: { complete: { from: 'active', to: 'completed' } },
|
|
877
|
-
* },
|
|
878
|
-
* withOwnership(),
|
|
879
|
-
* ));
|
|
880
|
-
* ```
|
|
881
|
-
*/
|
|
882
|
-
export function withOwnership(options: WithOwnershipOptions = {}): ModelMixin {
|
|
883
|
-
const ownerField = options.ownerField ?? 'ownerId';
|
|
884
|
-
|
|
885
|
-
return (def: ModelDefinition): ModelDefinition => {
|
|
886
|
-
const ownerFields: Record<string, WorkflowFieldDescriptor> = {
|
|
887
|
-
[ownerField]: { type: 'string', label: 'Owner ID' },
|
|
888
|
-
ownerName: { type: 'string', label: 'Owner Name' },
|
|
889
|
-
};
|
|
890
|
-
|
|
891
|
-
// Set ownership fields on initial state entry
|
|
892
|
-
const states: Record<string, StateDescriptor> = {};
|
|
893
|
-
for (const [name, s] of Object.entries(def.states)) {
|
|
894
|
-
if (s.type === 'initial') {
|
|
895
|
-
states[name] = {
|
|
896
|
-
...s,
|
|
897
|
-
onEnter: [
|
|
898
|
-
...(s.onEnter ?? []),
|
|
899
|
-
{
|
|
900
|
-
id: '_ownership_set',
|
|
901
|
-
type: 'set_fields',
|
|
902
|
-
mode: 'auto' as const,
|
|
903
|
-
config: {
|
|
904
|
-
fields: {
|
|
905
|
-
[ownerField]: { expression: 'context.actor_id' },
|
|
906
|
-
ownerName: { expression: 'context.actor_name' },
|
|
907
|
-
},
|
|
908
|
-
},
|
|
909
|
-
},
|
|
910
|
-
],
|
|
911
|
-
};
|
|
912
|
-
} else {
|
|
913
|
-
states[name] = { ...s };
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
return {
|
|
918
|
-
...def,
|
|
919
|
-
fields: mergeFields(def.fields, ownerFields),
|
|
920
|
-
states: mergeStates(def.states, states),
|
|
921
|
-
};
|
|
922
|
-
};
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
// =============================================================================
|
|
926
|
-
// withTags
|
|
927
|
-
// =============================================================================
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* Adds a `tags` array field with `add_tag` and `remove_tag` self-transitions.
|
|
931
|
-
*
|
|
932
|
-
* The self-transitions are available from all non-terminal states, allowing
|
|
933
|
-
* tags to be managed at any point in the workflow lifecycle.
|
|
934
|
-
*
|
|
935
|
-
* Injects:
|
|
936
|
-
* - `tags` (array of strings) — default empty array
|
|
937
|
-
* - `add_tag` transition — appends a tag (self-transition)
|
|
938
|
-
* - `remove_tag` transition — removes a tag (self-transition)
|
|
939
|
-
*
|
|
940
|
-
* @returns A {@link ModelMixin} function.
|
|
941
|
-
*
|
|
942
|
-
* @example
|
|
943
|
-
* ```typescript
|
|
944
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
945
|
-
* import { pipe, withTags } from '@mindmatrix/react/mixins';
|
|
946
|
-
*
|
|
947
|
-
* export default defineModel(pipe(
|
|
948
|
-
* {
|
|
949
|
-
* slug: 'ticket',
|
|
950
|
-
* fields: { title: { type: 'string' } },
|
|
951
|
-
* states: { open: { type: 'initial' }, closed: { type: 'end' } },
|
|
952
|
-
* transitions: { close: { from: 'open', to: 'closed' } },
|
|
953
|
-
* },
|
|
954
|
-
* withTags(),
|
|
955
|
-
* ));
|
|
956
|
-
* ```
|
|
957
|
-
*/
|
|
958
|
-
export function withTags(): ModelMixin {
|
|
959
|
-
return (def: ModelDefinition): ModelDefinition => {
|
|
960
|
-
const tagFields: Record<string, WorkflowFieldDescriptor> = {
|
|
961
|
-
tags: {
|
|
962
|
-
type: 'array',
|
|
963
|
-
items: { type: 'string' },
|
|
964
|
-
default: [],
|
|
965
|
-
label: 'Tags',
|
|
966
|
-
},
|
|
967
|
-
};
|
|
968
|
-
|
|
969
|
-
const nonTerminalStates = getNonTerminalStates(def);
|
|
970
|
-
|
|
971
|
-
const tagTransitions: Record<string, TransitionDescriptor> = {};
|
|
972
|
-
|
|
973
|
-
if (nonTerminalStates.length > 0) {
|
|
974
|
-
tagTransitions.add_tag = {
|
|
975
|
-
from: nonTerminalStates,
|
|
976
|
-
to: nonTerminalStates[0],
|
|
977
|
-
description: 'Add a tag',
|
|
978
|
-
actions: [
|
|
979
|
-
{
|
|
980
|
-
id: '_tag_add',
|
|
981
|
-
type: 'set_field',
|
|
982
|
-
mode: 'auto',
|
|
983
|
-
config: {
|
|
984
|
-
field: 'tags',
|
|
985
|
-
expression: 'APPEND(state_data.tags, input.tag)',
|
|
986
|
-
},
|
|
987
|
-
},
|
|
988
|
-
],
|
|
989
|
-
};
|
|
990
|
-
|
|
991
|
-
tagTransitions.remove_tag = {
|
|
992
|
-
from: nonTerminalStates,
|
|
993
|
-
to: nonTerminalStates[0],
|
|
994
|
-
description: 'Remove a tag',
|
|
995
|
-
actions: [
|
|
996
|
-
{
|
|
997
|
-
id: '_tag_remove',
|
|
998
|
-
type: 'set_field',
|
|
999
|
-
mode: 'auto',
|
|
1000
|
-
config: {
|
|
1001
|
-
field: 'tags',
|
|
1002
|
-
expression: 'FILTER(state_data.tags, item != input.tag)',
|
|
1003
|
-
},
|
|
1004
|
-
},
|
|
1005
|
-
],
|
|
1006
|
-
};
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
return {
|
|
1010
|
-
...def,
|
|
1011
|
-
fields: mergeFields(def.fields, tagFields),
|
|
1012
|
-
transitions: mergeTransitions(def.transitions, tagTransitions),
|
|
1013
|
-
};
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
// =============================================================================
|
|
1018
|
-
// withSearch
|
|
1019
|
-
// =============================================================================
|
|
1020
|
-
|
|
1021
|
-
/**
|
|
1022
|
-
* Options for the {@link withSearch} mixin.
|
|
1023
|
-
*/
|
|
1024
|
-
export interface WithSearchOptions {
|
|
1025
|
-
/**
|
|
1026
|
-
* Field names to include in the full-text search index.
|
|
1027
|
-
* These fields are concatenated into a single `searchText` computed field.
|
|
1028
|
-
*
|
|
1029
|
-
* @example `{ fields: ['title', 'description', 'content'] }`
|
|
1030
|
-
*/
|
|
1031
|
-
fields: string[];
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
/**
|
|
1035
|
-
* Adds a `searchText` computed field that concatenates specified fields
|
|
1036
|
-
* for full-text search indexing.
|
|
1037
|
-
*
|
|
1038
|
-
* Injects:
|
|
1039
|
-
* - `searchText` (string, computed) — concatenation of the specified fields
|
|
1040
|
-
*
|
|
1041
|
-
* @param options - Configuration specifying which fields to index.
|
|
1042
|
-
* @returns A {@link ModelMixin} function.
|
|
1043
|
-
*
|
|
1044
|
-
* @example
|
|
1045
|
-
* ```typescript
|
|
1046
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
1047
|
-
* import { pipe, withSearch } from '@mindmatrix/react/mixins';
|
|
1048
|
-
*
|
|
1049
|
-
* export default defineModel(pipe(
|
|
1050
|
-
* {
|
|
1051
|
-
* slug: 'article',
|
|
1052
|
-
* fields: {
|
|
1053
|
-
* title: { type: 'string' },
|
|
1054
|
-
* body: { type: 'string' },
|
|
1055
|
-
* author: { type: 'string' },
|
|
1056
|
-
* },
|
|
1057
|
-
* states: { draft: { type: 'initial' } },
|
|
1058
|
-
* transitions: {},
|
|
1059
|
-
* },
|
|
1060
|
-
* withSearch({ fields: ['title', 'body', 'author'] }),
|
|
1061
|
-
* ));
|
|
1062
|
-
* ```
|
|
1063
|
-
*/
|
|
1064
|
-
export function withSearch(options: WithSearchOptions): ModelMixin {
|
|
1065
|
-
return (def: ModelDefinition): ModelDefinition => {
|
|
1066
|
-
const concatExpr = options.fields
|
|
1067
|
-
.map((f) => `COALESCE(state_data.${f}, "")`)
|
|
1068
|
-
.join(' + " " + ');
|
|
1069
|
-
|
|
1070
|
-
const searchFields: Record<string, WorkflowFieldDescriptor> = {
|
|
1071
|
-
searchText: {
|
|
1072
|
-
type: 'string',
|
|
1073
|
-
label: 'Search Text',
|
|
1074
|
-
description: `Full-text search index over: ${options.fields.join(', ')}`,
|
|
1075
|
-
computed: concatExpr,
|
|
1076
|
-
computedDeps: options.fields,
|
|
1077
|
-
},
|
|
1078
|
-
};
|
|
1079
|
-
|
|
1080
|
-
return {
|
|
1081
|
-
...def,
|
|
1082
|
-
fields: mergeFields(def.fields, searchFields),
|
|
1083
|
-
};
|
|
1084
|
-
};
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
// =============================================================================
|
|
1088
|
-
// withPagination
|
|
1089
|
-
// =============================================================================
|
|
1090
|
-
|
|
1091
|
-
/**
|
|
1092
|
-
* Adds pagination and sorting fields for collection querying patterns.
|
|
1093
|
-
*
|
|
1094
|
-
* These fields are typically used by views and data grids to control
|
|
1095
|
-
* how records are fetched and displayed. They live at the ephemeral scope
|
|
1096
|
-
* (not persisted to the database).
|
|
1097
|
-
*
|
|
1098
|
-
* Injects:
|
|
1099
|
-
* - `sortField` (string, default `'createdAt'`) — field name to sort by
|
|
1100
|
-
* - `sortOrder` (string, default `'desc'`) — sort direction (`'asc'` or `'desc'`)
|
|
1101
|
-
* - `page` (number, default 1) — current page number
|
|
1102
|
-
* - `pageSize` (number, default 25) — records per page
|
|
1103
|
-
*
|
|
1104
|
-
* @returns A {@link ModelMixin} function.
|
|
1105
|
-
*
|
|
1106
|
-
* @example
|
|
1107
|
-
* ```typescript
|
|
1108
|
-
* import { defineModel } from '@mindmatrix/react';
|
|
1109
|
-
* import { pipe, withPagination, withTimestamps } from '@mindmatrix/react/mixins';
|
|
1110
|
-
*
|
|
1111
|
-
* export default defineModel(pipe(
|
|
1112
|
-
* {
|
|
1113
|
-
* slug: 'product-catalog',
|
|
1114
|
-
* fields: { name: { type: 'string' }, price: { type: 'currency' } },
|
|
1115
|
-
* states: { active: { type: 'initial' } },
|
|
1116
|
-
* transitions: {},
|
|
1117
|
-
* },
|
|
1118
|
-
* withTimestamps(),
|
|
1119
|
-
* withPagination(),
|
|
1120
|
-
* ));
|
|
1121
|
-
* ```
|
|
1122
|
-
*/
|
|
1123
|
-
export function withPagination(): ModelMixin {
|
|
1124
|
-
return (def: ModelDefinition): ModelDefinition => {
|
|
1125
|
-
const paginationFields: Record<string, WorkflowFieldDescriptor> = {
|
|
1126
|
-
sortField: {
|
|
1127
|
-
type: 'string',
|
|
1128
|
-
default: 'createdAt',
|
|
1129
|
-
label: 'Sort Field',
|
|
1130
|
-
stateHome: { scope: 'ephemeral', persistence: 'none' },
|
|
1131
|
-
},
|
|
1132
|
-
sortOrder: {
|
|
1133
|
-
type: 'string',
|
|
1134
|
-
default: 'desc',
|
|
1135
|
-
enum: ['asc', 'desc'],
|
|
1136
|
-
label: 'Sort Order',
|
|
1137
|
-
stateHome: { scope: 'ephemeral', persistence: 'none' },
|
|
1138
|
-
},
|
|
1139
|
-
page: {
|
|
1140
|
-
type: 'number',
|
|
1141
|
-
default: 1,
|
|
1142
|
-
label: 'Page',
|
|
1143
|
-
validation: { min: 1 },
|
|
1144
|
-
stateHome: { scope: 'ephemeral', persistence: 'none' },
|
|
1145
|
-
},
|
|
1146
|
-
pageSize: {
|
|
1147
|
-
type: 'number',
|
|
1148
|
-
default: 25,
|
|
1149
|
-
label: 'Page Size',
|
|
1150
|
-
validation: { min: 1, max: 100 },
|
|
1151
|
-
stateHome: { scope: 'ephemeral', persistence: 'none' },
|
|
1152
|
-
},
|
|
1153
|
-
};
|
|
1154
|
-
|
|
1155
|
-
return {
|
|
1156
|
-
...def,
|
|
1157
|
-
fields: mergeFields(def.fields, paginationFields),
|
|
1158
|
-
};
|
|
1159
|
-
};
|
|
1160
|
-
}
|