@stonecrop/nuxt 0.13.14 → 0.14.0

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.
Files changed (33) hide show
  1. package/README.md +12 -19
  2. package/bin/init.mjs +4 -1
  3. package/dist/module.d.mts +10 -0
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +91 -50
  6. package/dist/runtime/app/components/DocBuilderActionsPanel.d.vue.ts +59 -0
  7. package/dist/runtime/app/components/DocBuilderActionsPanel.vue +175 -0
  8. package/dist/runtime/app/components/DocBuilderActionsPanel.vue.d.ts +59 -0
  9. package/dist/runtime/app/components/DocBuilderFieldsPanel.d.vue.ts +11 -0
  10. package/dist/runtime/app/components/DocBuilderFieldsPanel.vue +353 -0
  11. package/dist/runtime/app/components/DocBuilderFieldsPanel.vue.d.ts +11 -0
  12. package/dist/runtime/app/components/docbuilderActions.d.ts +62 -0
  13. package/dist/runtime/app/components/docbuilderActions.js +87 -0
  14. package/dist/runtime/app/composables/useClientAction.d.ts +30 -0
  15. package/dist/runtime/app/composables/useClientAction.js +51 -0
  16. package/dist/runtime/app/pages/DocBuilderDetail.vue +116 -71
  17. package/dist/runtime/app/pages/DocBuilderIndex.vue +83 -22
  18. package/dist/runtime/server/api/docbuilder/[doctype].get.d.ts +6 -1
  19. package/dist/runtime/server/api/docbuilder/[doctype].get.js +15 -5
  20. package/dist/runtime/server/api/docbuilder/doctypes.get.d.ts +5 -1
  21. package/dist/runtime/server/api/docbuilder/doctypes.get.js +5 -3
  22. package/dist/runtime/server/api/docbuilder/mergeDoctype.d.ts +11 -0
  23. package/dist/runtime/server/api/docbuilder/mergeDoctype.js +12 -0
  24. package/dist/runtime/server/api/docbuilder/save.post.d.ts +4 -1
  25. package/dist/runtime/server/api/docbuilder/save.post.js +48 -11
  26. package/dist/runtime/server/api/docbuilder/validate.post.d.ts +2 -1
  27. package/dist/runtime/server/api/docbuilder/validate.post.js +1 -1
  28. package/package.json +32 -29
  29. package/templates/Project.json +4 -10
  30. package/templates/Task.json +10 -17
  31. package/templates/resolvers.ts +49 -44
  32. package/templates/schema.graphql +23 -15
  33. package/templates/stonecrop.ts +3 -69
@@ -0,0 +1,353 @@
1
+ <template>
2
+ <div class="fields-panel">
3
+ <!-- Shared by every row's component input; the id is instance-scoped so two mounted panels
4
+ cannot collide. -->
5
+ <datalist :id="componentListId">
6
+ <option v-for="c in CANONICAL_COMPONENTS" :key="c" :value="c" />
7
+ </datalist>
8
+ <!--
9
+ ATable owns the frame (header, filter row, list-expansion chrome). We override #body to
10
+ keep the per-row #content property form and to bind every cell straight to the source via
11
+ update() — ATable never calls setCellData/handleRowAction, so it never mutates the
12
+ projection. After a structural edit (add/delete/move/duplicate) we collapse all rows via
13
+ the exposed store, so ATable's index-keyed expand state can't outlive a reorder — without
14
+ remounting, which would also drop the active filter.
15
+ -->
16
+ <ATable v-if="hasFields" ref="tableRef" :columns="FIELD_COLUMNS" :rows="fieldProjection" :config="FIELD_CONFIG">
17
+ <template #body="{ data: store }">
18
+ <ARow
19
+ v-for="row in store.filteredRows"
20
+ :key="row.__realIndex"
21
+ :row-index="row.originalIndex"
22
+ :store="store"
23
+ @row:action="onRowAction">
24
+ <template #default>
25
+ <td>
26
+ <input
27
+ type="text"
28
+ :value="row.fieldname"
29
+ :disabled="isLocked(row.__field)"
30
+ :class="{ locked: isLocked(row.__field) }"
31
+ @input="update(row.__realIndex, 'fieldname', value($event))" />
32
+ </td>
33
+ <td>
34
+ <input
35
+ type="text"
36
+ :value="row.label"
37
+ @input="update(row.__realIndex, 'label', value($event) || void 0)" />
38
+ </td>
39
+ <td>
40
+ <!--
41
+ `component` is an open axis — naming a custom component is how an app renders a
42
+ field Stonecrop ships no widget for — so this suggests the canonical set rather
43
+ than restricting to it. A <select> would show a blank box for any custom
44
+ component and make new ones unauthorable. Not frozen for introspected fields:
45
+ the widget is an authoring choice, not a database fact (see ValueField.source).
46
+ -->
47
+ <input
48
+ type="text"
49
+ :value="row.component"
50
+ :list="componentListId"
51
+ @input="update(row.__realIndex, 'component', value($event) || void 0)" />
52
+ </td>
53
+ <td class="center">
54
+ <input
55
+ type="checkbox"
56
+ :checked="bool(row.required)"
57
+ :disabled="isLocked(row.__field)"
58
+ @change="update(row.__realIndex, 'required', checked($event) || void 0)" />
59
+ </td>
60
+ <td>
61
+ <span class="badge" :class="isLocked(row.__field) ? 'badge-introspected' : 'badge-manual'">
62
+ {{ isLocked(row.__field) ? "introspected" : "manual" }}
63
+ </span>
64
+ </td>
65
+ </template>
66
+ <template #content>
67
+ <div class="field-detail">
68
+ <label v-for="p in TEXT_PROPS" :key="p.key" class="field-prop">
69
+ <span>{{ p.label }}</span>
70
+ <input
71
+ type="text"
72
+ :value="str(row.__field[p.key])"
73
+ :disabled="!!p.identity && isLocked(row.__field)"
74
+ @input="update(row.__realIndex, p.key, value($event) || void 0)" />
75
+ </label>
76
+ <label v-for="p in SELECT_PROPS" :key="p.key" class="field-prop">
77
+ <span>{{ p.label }}</span>
78
+ <select
79
+ :value="str(row.__field[p.key])"
80
+ :disabled="!!p.identity && isLocked(row.__field)"
81
+ @change="update(row.__realIndex, p.key, value($event) || void 0)">
82
+ <option value="">—</option>
83
+ <option v-for="o in p.options" :key="o" :value="o">{{ o }}</option>
84
+ </select>
85
+ </label>
86
+ <label v-for="p in BOOL_PROPS" :key="p.key" class="field-prop field-prop-inline">
87
+ <input
88
+ type="checkbox"
89
+ :checked="bool(row.__field[p.key])"
90
+ :disabled="!!p.identity && isLocked(row.__field)"
91
+ @change="update(row.__realIndex, p.key, checked($event) || void 0)" />
92
+ <span>{{ p.label }}</span>
93
+ </label>
94
+ <label v-for="p in JSON_PROPS" :key="p.key" class="field-prop field-prop-wide">
95
+ <span>{{ p.label }} <em>(JSON — smart controls pending)</em></span>
96
+ <input
97
+ type="text"
98
+ :value="jsonStr(row.__field[p.key])"
99
+ :disabled="!!p.identity && isLocked(row.__field)"
100
+ :class="{ 'json-invalid': jsonErrors[`${row.__realIndex}:${p.key}`] }"
101
+ @change="updateJson(row.__realIndex, p.key, value($event))" />
102
+ </label>
103
+ <label class="field-prop field-prop-wide">
104
+ <span>Validation message</span>
105
+ <input
106
+ type="text"
107
+ :value="str(validationMessage(row.__field))"
108
+ @input="updateValidation(row.__realIndex, value($event))" />
109
+ </label>
110
+ </div>
111
+ <div v-if="isLocked(row.__field)" class="field-detail-actions">
112
+ <span class="locked-note">
113
+ Identity (id, primary key, required, options, cardinality, link target) is read-only — this field
114
+ mirrors a database column. Component is yours to choose.
115
+ </span>
116
+ </div>
117
+ </template>
118
+ </ARow>
119
+ </template>
120
+ </ATable>
121
+ <p v-else class="fields-empty">No fields yet.</p>
122
+ <div class="fields-add">
123
+ <button class="btn-add" type="button" @click="addField">+ Add field</button>
124
+ </div>
125
+ </div>
126
+ </template>
127
+
128
+ <script setup>
129
+ import { ATable, ARow } from "@stonecrop/atable";
130
+ import { CANONICAL_COMPONENTS } from "@stonecrop/schema";
131
+ import { computed, nextTick, ref, useId } from "vue";
132
+ const componentListId = useId();
133
+ const TEXT_PROPS = [
134
+ { key: "doctype", label: "Link target", identity: true },
135
+ { key: "width", label: "Width" },
136
+ { key: "mask", label: "Mask" },
137
+ { key: "format", label: "Format" },
138
+ { key: "language", label: "Code language" }
139
+ ];
140
+ const SELECT_PROPS = [
141
+ { key: "align", label: "Align", options: ["left", "center", "right", "start", "end"] },
142
+ { key: "mode", label: "Mode", options: ["edit", "read", "display"] },
143
+ {
144
+ key: "cardinality",
145
+ label: "Cardinality",
146
+ options: ["atMostOne", "one", "noneOrMany", "atLeastOne"],
147
+ identity: true
148
+ }
149
+ ];
150
+ const BOOL_PROPS = [
151
+ { key: "readOnly", label: "Read only" },
152
+ { key: "hidden", label: "Hidden" },
153
+ { key: "edit", label: "Editable in table" },
154
+ { key: "primaryKey", label: "Primary key", identity: true },
155
+ { key: "computed", label: "Computed (no DB column)" }
156
+ ];
157
+ const JSON_PROPS = [
158
+ { key: "options", label: "Options", identity: true },
159
+ { key: "default", label: "Default" }
160
+ ];
161
+ const FIELD_COLUMNS = [
162
+ { name: "fieldname", label: "ID", sortable: false, filterable: true },
163
+ { name: "label", label: "Label", sortable: false },
164
+ { name: "component", label: "Component", sortable: false, filterable: true, filterType: "select" },
165
+ { name: "required", label: "Required", sortable: false, align: "center" },
166
+ { name: "source", label: "Source", sortable: false, filterable: true, filterType: "select" }
167
+ ];
168
+ const FIELD_CONFIG = {
169
+ view: "list-expansion",
170
+ fullWidth: true,
171
+ rowActions: {
172
+ enabled: true,
173
+ forceDropdown: true,
174
+ position: "before-index",
175
+ actions: {
176
+ moveUp: { enabled: true, label: "Move up", disabled: (rowIndex) => rowIndex === 0 },
177
+ moveDown: {
178
+ enabled: true,
179
+ label: "Move down",
180
+ disabled: (rowIndex, store) => rowIndex === store.rows.length - 1
181
+ },
182
+ duplicate: { enabled: true, label: "Duplicate" },
183
+ insertAbove: { enabled: true, label: "Insert above" },
184
+ insertBelow: { enabled: true, label: "Insert below" },
185
+ delete: {
186
+ enabled: true,
187
+ label: "Delete",
188
+ disabled: (rowIndex, store) => isLocked(store.rows[rowIndex]?.__field ?? {})
189
+ }
190
+ }
191
+ }
192
+ };
193
+ const props = defineProps({
194
+ modelValue: { type: Array, required: true }
195
+ });
196
+ const emit = defineEmits(["update:modelValue"]);
197
+ function isValueField(f) {
198
+ if (typeof f.kind === "string") return f.kind === "field";
199
+ return !("schema" in f) && !("columns" in f);
200
+ }
201
+ function isLocked(f) {
202
+ return f.source === "introspected";
203
+ }
204
+ const valueFieldRows = computed(() => {
205
+ const out = [];
206
+ props.modelValue.forEach((f, realIndex) => {
207
+ if (isValueField(f)) out.push({ field: f, realIndex, rowIndex: out.length });
208
+ });
209
+ return out;
210
+ });
211
+ const hasFields = computed(() => valueFieldRows.value.length > 0);
212
+ const fieldProjection = computed(
213
+ () => valueFieldRows.value.map((r) => ({
214
+ fieldname: str(r.field.fieldname),
215
+ label: str(r.field.label),
216
+ component: str(r.field.component),
217
+ required: r.field.required === true,
218
+ source: isLocked(r.field) ? "introspected" : "manual",
219
+ __realIndex: r.realIndex,
220
+ __field: r.field
221
+ }))
222
+ );
223
+ const tableRef = ref();
224
+ function collapseAllRows() {
225
+ const store = tableRef.value?.store;
226
+ if (!store) return;
227
+ store.display.forEach((d, i) => {
228
+ if (d.expanded) store.toggleRowExpand(i);
229
+ });
230
+ }
231
+ function update(realIndex, key, val) {
232
+ emit(
233
+ "update:modelValue",
234
+ props.modelValue.map((f, i) => i === realIndex ? setOrDelete(f, key, val) : f)
235
+ );
236
+ }
237
+ function setOrDelete(field, key, val) {
238
+ if (val === void 0) {
239
+ const { [key]: _omit, ...rest } = field;
240
+ return rest;
241
+ }
242
+ return { ...field, [key]: val };
243
+ }
244
+ const jsonErrors = ref({});
245
+ function jsonStr(v) {
246
+ return v === void 0 ? "" : JSON.stringify(v);
247
+ }
248
+ function updateJson(realIndex, key, raw) {
249
+ const errKey = `${realIndex}:${key}`;
250
+ if (raw.trim() === "") {
251
+ jsonErrors.value[errKey] = false;
252
+ update(realIndex, key, void 0);
253
+ return;
254
+ }
255
+ try {
256
+ const parsed = JSON.parse(raw);
257
+ jsonErrors.value[errKey] = false;
258
+ update(realIndex, key, parsed);
259
+ } catch {
260
+ jsonErrors.value[errKey] = true;
261
+ }
262
+ }
263
+ function validationMessage(field) {
264
+ const v = field.validation;
265
+ if (v && typeof v === "object" && "errorMessage" in v)
266
+ return String(v.errorMessage ?? "");
267
+ return "";
268
+ }
269
+ function updateValidation(realIndex, message) {
270
+ update(realIndex, "validation", message ? { errorMessage: message } : void 0);
271
+ }
272
+ function addField() {
273
+ const base = { kind: "field", fieldname: uniqueName(), component: "ATextInput", label: "New Field" };
274
+ emit("update:modelValue", [...props.modelValue, base]);
275
+ void nextTick(collapseAllRows);
276
+ }
277
+ function insertField(at) {
278
+ const base = { kind: "field", fieldname: uniqueName(), component: "ATextInput", label: "New Field" };
279
+ const next = props.modelValue.slice();
280
+ next.splice(at, 0, base);
281
+ emit("update:modelValue", next);
282
+ void nextTick(collapseAllRows);
283
+ }
284
+ function onRowAction(type, rowIndex) {
285
+ const realIndex = fieldProjection.value[rowIndex]?.__realIndex;
286
+ if (realIndex === void 0) return;
287
+ if (type === "moveUp") moveField(realIndex, -1);
288
+ else if (type === "moveDown") moveField(realIndex, 1);
289
+ else if (type === "duplicate") duplicateField(realIndex);
290
+ else if (type === "delete") removeField(realIndex);
291
+ else if (type === "insertAbove") insertField(realIndex);
292
+ else if (type === "insertBelow") insertField(realIndex + 1);
293
+ }
294
+ function uniqueName() {
295
+ const existing = new Set(props.modelValue.map((f) => String(f.fieldname ?? "")));
296
+ let name = "new_field";
297
+ let n = 1;
298
+ while (existing.has(name)) name = `new_field_${++n}`;
299
+ return name;
300
+ }
301
+ function removeField(realIndex) {
302
+ emit(
303
+ "update:modelValue",
304
+ props.modelValue.filter((_, i) => i !== realIndex)
305
+ );
306
+ void nextTick(collapseAllRows);
307
+ }
308
+ function duplicateField(realIndex) {
309
+ const original = props.modelValue[realIndex];
310
+ if (!original) return;
311
+ const { source: _source, ...rest } = original;
312
+ const existing = new Set(props.modelValue.map((f) => String(f.fieldname ?? "")));
313
+ const baseName = `${String(rest.fieldname ?? "field")}_copy`;
314
+ let fieldname = baseName;
315
+ let n = 1;
316
+ while (existing.has(fieldname)) fieldname = `${baseName}_${++n}`;
317
+ const clone = { ...rest, fieldname };
318
+ const next = props.modelValue.slice();
319
+ next.splice(realIndex + 1, 0, clone);
320
+ emit("update:modelValue", next);
321
+ void nextTick(collapseAllRows);
322
+ }
323
+ function moveField(realIndex, dir) {
324
+ const rows = valueFieldRows.value;
325
+ const pos = rows.findIndex((r) => r.realIndex === realIndex);
326
+ const target = rows[pos + dir];
327
+ if (!target) return;
328
+ const next = props.modelValue.slice();
329
+ const a = next[realIndex];
330
+ const b = next[target.realIndex];
331
+ if (a === void 0 || b === void 0) return;
332
+ next[realIndex] = b;
333
+ next[target.realIndex] = a;
334
+ emit("update:modelValue", next);
335
+ void nextTick(collapseAllRows);
336
+ }
337
+ function value(e) {
338
+ return e.target.value;
339
+ }
340
+ function checked(e) {
341
+ return e.target.checked;
342
+ }
343
+ function str(v) {
344
+ return v == null ? "" : String(v);
345
+ }
346
+ function bool(v) {
347
+ return v === true;
348
+ }
349
+ </script>
350
+
351
+ <style scoped>
352
+ .fields-panel{padding:.5em 1em}.fields-panel :deep(.atable-row>td){border-top:1px solid var(--sc-row-border-color,#e5e7eb);padding:var(--sc-atable-row-padding,.125rem) .75em;vertical-align:middle}.fields-panel :deep(input[type=text]),.fields-panel :deep(select){border:1px solid var(--sc-gray-20,#d1d5db);border-radius:3px;font-family:inherit;font-size:inherit;padding:.25em .5em;width:100%}.fields-panel :deep(input.locked),.fields-panel :deep(select.locked){background:var(--sc-gray-10,#f3f4f6);color:#6b7280;cursor:not-allowed}.fields-panel :deep(input.json-invalid){background:#fef2f2;border-color:#f87171}.center{text-align:center}.badge{border-radius:9999px;display:inline-block;font-size:.75rem;font-weight:500;padding:.125em .5em}.badge-manual{background:#dcfce7;color:#166534}.badge-introspected{background:#e0e7ff;color:#3730a3}.field-detail{display:grid;gap:.75rem 1rem;grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));padding:.75rem 1rem}.field-prop{color:var(--sc-header-text-color,#374151);display:flex;flex-direction:column;font-size:.75rem;gap:.25rem}.field-prop-inline{align-items:center;flex-direction:row;gap:.5rem}.field-prop-wide{grid-column:1/-1}.field-detail-actions{align-items:center;display:flex;gap:1rem;justify-content:space-between;padding:0 1rem .75rem}.locked-note{color:#6b7280;font-size:.75rem;font-style:italic}.btn-add{background:none;border:1px dashed var(--sc-gray-20,#d1d5db);border-radius:4px;color:var(--sc-blue-40,#2563eb);cursor:pointer;font-size:.875rem;margin-top:.5rem;padding:.4em 1em}.fields-empty{color:#9ca3af;font-style:italic;padding:1rem 0}
353
+ </style>
@@ -0,0 +1,11 @@
1
+ type Field = Record<string, unknown>;
2
+ type __VLS_Props = {
3
+ modelValue: Field[];
4
+ };
5
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
6
+ "update:modelValue": (value: Field[]) => any;
7
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
8
+ "onUpdate:modelValue"?: ((value: Field[]) => any) | undefined;
9
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
10
+ declare const _default: typeof __VLS_export;
11
+ export default _default;
@@ -0,0 +1,62 @@
1
+ import type { ActionDefinition, TriggerDefinition, WorkflowMeta } from '@stonecrop/schema';
2
+ /**
3
+ * Pure projection + write helpers backing {@link DocBuilderActionsPanel}. Extracted from the SFC
4
+ * so they are unit-testable in a plain node environment — the panel itself embeds a Monaco editor
5
+ * (`ACodeEditor`) that cannot mount in the nuxt package's DOM-less test env, so its wiring is
6
+ * browser-verified while this logic is covered by `nuxt/test/docbuilderActions.test.ts`.
7
+ *
8
+ * The panel authors two sibling maps at once: `workflow.actions` (Transitions + stateless Commands,
9
+ * created via the graph) and `workflow.triggers` (field-validation Triggers, created here). Both are
10
+ * projected into one table; writes are routed back to the correct map, always preserving the other.
11
+ */
12
+ export type RowKind = 'transition' | 'self-transition' | 'command' | 'trigger';
13
+ /** A single table row — column scalars ATable renders via `row[name]`, plus `__`-prefixed backrefs. */
14
+ export interface ActionRow {
15
+ key: string;
16
+ label: string;
17
+ type: 'Transition' | 'Self-transition' | 'Command' | 'Trigger';
18
+ kind: RowKind;
19
+ /** Comma-joined fire-set for Triggers; `'—'` for actions (they don't fire on field edits). */
20
+ on: string;
21
+ allowedStates: string;
22
+ nextState: string;
23
+ __key: string;
24
+ __kind: RowKind;
25
+ __action?: ActionDefinition;
26
+ __trigger?: TriggerDefinition;
27
+ }
28
+ /** Join a Trigger's `on` fire-set for display. */
29
+ export declare function formatOn(on: string[] | undefined): string;
30
+ /** Parse the `on` text input (comma-separated fieldnames) into a trimmed, empty-free array. */
31
+ export declare function parseOnInput(value: string): string[];
32
+ /**
33
+ * Project both sibling maps into one row list — action rows first, then trigger rows. The `kind`
34
+ * discriminant drives the Type badge and routes cell edits (a Trigger can never be mistaken for a
35
+ * Transition/Command because it comes from a different map, not a `stateless` guess).
36
+ */
37
+ export declare function projectWorkflowRows(workflow: WorkflowMeta | undefined): ActionRow[];
38
+ /** The lowest-numbered `triggerN` key not already present — deterministic (no RNG). */
39
+ export declare function nextTriggerKey(triggers: Record<string, TriggerDefinition> | undefined): string;
40
+ /** The lowest-numbered `commandN` key not already present in the actions map — deterministic. */
41
+ export declare function nextCommandKey(actions: Record<string, ActionDefinition> | undefined): string;
42
+ /** Write one field of an existing action, preserving every other action and the whole triggers map. */
43
+ export declare function writeActionField(workflow: WorkflowMeta, key: string, field: string, value: unknown): WorkflowMeta;
44
+ /** Write one field of an existing trigger, preserving every other trigger and the whole actions map. */
45
+ export declare function writeTriggerField(workflow: WorkflowMeta, key: string, field: string, value: unknown): WorkflowMeta;
46
+ /** Append an empty Trigger under a fresh `triggerN` key, seeding a workflow when there is none yet. */
47
+ export declare function addTrigger(workflow: WorkflowMeta | undefined): WorkflowMeta;
48
+ /**
49
+ * Append a stateless Command under a fresh `commandN` key, seeding a workflow when there is none yet.
50
+ * Unlike a Trigger, a Command needs a non-empty label — `ActionDefinition.label` is `.min(1)`, so an
51
+ * empty seed would fail doctype validation the instant it is added. It carries no `allowedStates`, so
52
+ * it is available in every state; per-state scoping is not authored here (deferred to the graph).
53
+ */
54
+ export declare function addCommand(workflow: WorkflowMeta | undefined): WorkflowMeta;
55
+ /** Remove a Trigger, preserving every other trigger and the whole actions map. */
56
+ export declare function removeTrigger(workflow: WorkflowMeta, key: string): WorkflowMeta;
57
+ /**
58
+ * Remove an action from the actions map, preserving every other action and the triggers map. Used to
59
+ * delete a Command from the panel — Transitions are never removed this way (they are graph-owned; you
60
+ * delete a Transition by removing its edges), so the caller must guard by `kind`.
61
+ */
62
+ export declare function removeAction(workflow: WorkflowMeta, key: string): WorkflowMeta;
@@ -0,0 +1,87 @@
1
+ const NA = "\u2014";
2
+ export function formatOn(on) {
3
+ return (on ?? []).join(", ");
4
+ }
5
+ export function parseOnInput(value) {
6
+ return value.split(",").map((part) => part.trim()).filter(Boolean);
7
+ }
8
+ export function projectWorkflowRows(workflow) {
9
+ const actionRows = Object.entries(workflow?.actions ?? {}).map(([key, action]) => {
10
+ const kind = action.selfTransition ? "self-transition" : action.stateless ? "command" : "transition";
11
+ const type = action.selfTransition ? "Self-transition" : action.stateless ? "Command" : "Transition";
12
+ return {
13
+ key,
14
+ label: action.label ?? "",
15
+ type,
16
+ kind,
17
+ on: NA,
18
+ allowedStates: action.allowedStates?.join(", ") ?? "(all states)",
19
+ // Only a plain Transition has a target state; self-transitions stay put, Commands are stateless.
20
+ nextState: kind === "transition" ? action.nextState ?? "" : NA,
21
+ __key: key,
22
+ __kind: kind,
23
+ __action: action
24
+ };
25
+ });
26
+ const triggerRows = Object.entries(workflow?.triggers ?? {}).map(([key, trigger]) => ({
27
+ key,
28
+ label: trigger.label ?? "",
29
+ type: "Trigger",
30
+ kind: "trigger",
31
+ on: formatOn(trigger.on),
32
+ allowedStates: NA,
33
+ nextState: NA,
34
+ __key: key,
35
+ __kind: "trigger",
36
+ __trigger: trigger
37
+ }));
38
+ return [...actionRows, ...triggerRows];
39
+ }
40
+ export function nextTriggerKey(triggers) {
41
+ let n = 1;
42
+ while (triggers && `trigger${n}` in triggers) n++;
43
+ return `trigger${n}`;
44
+ }
45
+ export function nextCommandKey(actions) {
46
+ let n = 1;
47
+ while (actions && `command${n}` in actions) n++;
48
+ return `command${n}`;
49
+ }
50
+ export function writeActionField(workflow, key, field, value) {
51
+ const actions = workflow.actions ?? {};
52
+ return {
53
+ ...workflow,
54
+ actions: { ...actions, [key]: { ...actions[key], [field]: value } }
55
+ };
56
+ }
57
+ export function writeTriggerField(workflow, key, field, value) {
58
+ const triggers = workflow.triggers ?? {};
59
+ return {
60
+ ...workflow,
61
+ triggers: { ...triggers, [key]: { ...triggers[key], [field]: value } }
62
+ };
63
+ }
64
+ export function addTrigger(workflow) {
65
+ const triggers = workflow?.triggers ?? {};
66
+ const key = nextTriggerKey(triggers);
67
+ return {
68
+ ...workflow,
69
+ triggers: { ...triggers, [key]: { label: "", on: [], clientHandler: "" } }
70
+ };
71
+ }
72
+ export function addCommand(workflow) {
73
+ const actions = workflow?.actions ?? {};
74
+ const key = nextCommandKey(actions);
75
+ return {
76
+ ...workflow,
77
+ actions: { ...actions, [key]: { label: "New Command", stateless: true, clientHandler: "" } }
78
+ };
79
+ }
80
+ export function removeTrigger(workflow, key) {
81
+ const { [key]: _removed, ...rest } = workflow.triggers ?? {};
82
+ return { ...workflow, triggers: rest };
83
+ }
84
+ export function removeAction(workflow, key) {
85
+ const { [key]: _removed, ...rest } = workflow.actions ?? {};
86
+ return { ...workflow, actions: rest };
87
+ }
@@ -0,0 +1,30 @@
1
+ import type { ActionEventPayload } from '@stonecrop/desktop';
2
+ /**
3
+ * Result of dispatching an action to its server handler.
4
+ */
5
+ export type ActionDispatchResult = {
6
+ success: boolean;
7
+ data: unknown;
8
+ error: string | null;
9
+ };
10
+ /**
11
+ * Shared executor for doctype action clicks. A host's Desktop `@action` handler delegates
12
+ * here so every host runs the same logic from one definition:
13
+ *
14
+ * - If the clicked action carries a `clientHandler`, run it. The handler **owns
15
+ * orchestration** — it calls `runAction` itself when it needs the server, navigates via
16
+ * `router`, reads `record`, or queries `graphql`. It supersedes the default dispatch.
17
+ * - Otherwise dispatch the action to its server `handler` (the pre-existing behavior),
18
+ * so actions without a `clientHandler` are unchanged.
19
+ *
20
+ * `runAction` is the only blessed write: it dispatches **and** writes the returned record
21
+ * back into HST, keeping the store consistent — the same invariant the host handler
22
+ * previously upheld inline (`addRecord(result.data)` after dispatch).
23
+ *
24
+ * The composable owns the `[{ id, data }]` argument envelope every server handler reads
25
+ * (`const [{ id }] = args`), so an authored handler calls `runAction('Assign')` without
26
+ * knowing that shape; an optional second argument is merged in for handlers needing more.
27
+ */
28
+ export declare function useClientAction(): {
29
+ run: (payload: ActionEventPayload) => Promise<void>;
30
+ };
@@ -0,0 +1,51 @@
1
+ import { executeClientHandler, useStonecrop } from "@stonecrop/stonecrop";
2
+ import { useRouter } from "vue-router";
3
+ function notifyActionError(message) {
4
+ console.error("Action failed:", message);
5
+ if (typeof window !== "undefined") window.alert(message);
6
+ }
7
+ export function useClientAction() {
8
+ const { stonecrop } = useStonecrop();
9
+ const router = useRouter();
10
+ async function dispatchAndWriteback(doctypeSlug, recordId, data, action, extra) {
11
+ const sc = stonecrop.value;
12
+ if (!sc) return { success: false, data: null, error: "Stonecrop is not initialized" };
13
+ const doctype = sc.registry.getDoctype(doctypeSlug);
14
+ if (!doctype) return { success: false, data: null, error: `Unknown doctype: ${doctypeSlug}` };
15
+ const result = await sc.dispatchAction(doctype, action, [{ id: recordId, data, ...extra ?? {} }]);
16
+ if (result.success && result.data && recordId) {
17
+ sc.addRecord(doctypeSlug, recordId, result.data);
18
+ }
19
+ return result;
20
+ }
21
+ async function run(payload) {
22
+ const sc = stonecrop.value;
23
+ if (!sc) return;
24
+ const { name, doctype: doctypeSlug, recordId, data } = payload;
25
+ const doctype = sc.registry.getDoctype(doctypeSlug);
26
+ const workflow = doctype?.workflow;
27
+ const clientHandler = workflow?.actions?.[name]?.clientHandler;
28
+ try {
29
+ if (!clientHandler) {
30
+ const result = await dispatchAndWriteback(doctypeSlug, recordId, data, name);
31
+ if (!result.success) notifyActionError(result.error ?? `Action "${name}" failed`);
32
+ return;
33
+ }
34
+ const record = sc.getRecordById(doctypeSlug, recordId)?.get("") ?? data;
35
+ const runAction = (action, extra) => dispatchAndWriteback(doctypeSlug, recordId, data, action, extra);
36
+ const graphql = {
37
+ query(query, variables) {
38
+ const client = sc.getClient();
39
+ if (!client?.query) {
40
+ return Promise.reject(new Error("The configured data client does not support graphql.query"));
41
+ }
42
+ return client.query(query, variables);
43
+ }
44
+ };
45
+ await executeClientHandler(clientHandler, { router, record, runAction, graphql });
46
+ } catch (error) {
47
+ notifyActionError(error instanceof Error ? error.message : String(error));
48
+ }
49
+ }
50
+ return { run };
51
+ }