@stonecrop/nuxt 0.16.6 → 0.18.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.
- package/README.md +2 -2
- package/dist/module.json +1 -1
- package/dist/runtime/app/components/DocBuilderFieldsPanel.d.vue.ts +1 -1
- package/dist/runtime/app/components/DocBuilderFieldsPanel.vue +2 -11
- package/dist/runtime/app/components/DocBuilderFieldsPanel.vue.d.ts +1 -1
- package/dist/runtime/app/components/docbuilderFields.d.ts +32 -0
- package/dist/runtime/app/components/docbuilderFields.js +10 -0
- package/dist/runtime/app/composables/useClientAction.d.ts +9 -26
- package/dist/runtime/app/composables/useClientAction.js +1 -51
- package/dist/runtime/app/composables/useStonecropRegistry.d.ts +6 -1
- package/dist/runtime/app/composables/useStonecropRegistry.js +6 -1
- package/dist/runtime/app/composables/useStonecropSetup.d.ts +1 -1
- package/dist/runtime/app/composables/useStonecropSetup.js +1 -1
- package/dist/runtime/server/api/docbuilder/mergeDoctype.d.ts +25 -0
- package/dist/runtime/server/api/docbuilder/mergeDoctype.js +18 -0
- package/dist/runtime/server/api/docbuilder/save.post.js +2 -16
- package/package.json +14 -14
- package/templates/Project.json +2 -1
- package/templates/Task.json +5 -0
- package/templates/index.vue +14 -71
- package/templates/plugins.ts +32 -13
- package/templates/resolvers.ts +131 -85
- package/templates/schema.graphql +5 -16
- package/templates/useDoctypes.ts +12 -31
package/README.md
CHANGED
|
@@ -125,7 +125,7 @@ export default defineNuxtPlugin(() => {
|
|
|
125
125
|
})
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
This wires
|
|
128
|
+
This wires record loading to your GraphQL (or any other) backend. It is the seam Desktop reads through as well. There is no fallback: without a registered client, `getRecord` and `getRecords` throw naming `setClient`, and Desktop skips the read and renders empty.
|
|
129
129
|
|
|
130
130
|
### Use the Stonecrop Composable
|
|
131
131
|
|
|
@@ -400,7 +400,7 @@ await dispatchAction({ name: 'plan' }, 'SUBMIT', [recordId])
|
|
|
400
400
|
|-----------------|------|-------------|
|
|
401
401
|
| `registry` | `Registry` | The Registry instance for doctype management. |
|
|
402
402
|
| `stonecrop` | `Stonecrop` | The Stonecrop instance for HST and operation log access. Throws if not initialized. |
|
|
403
|
-
| `setMeta(fn)` | `(fn: (ctx) => Doctype \| Promise<Doctype>) => void` | Sets the `getMeta` function on the Registry. Called by `useStonecrop()` to lazy-load doctype metadata
|
|
403
|
+
| `setMeta(fn)` | `(fn: (ctx) => Doctype \| Promise<Doctype>) => void` | Sets the `getMeta` function on the Registry. Called by `useStonecrop({ doctype: 'slug' })` to lazy-load that doctype's metadata. `ctx` = `{ path, segments }`. |
|
|
404
404
|
| `setClient(client)` | `(client: DataClient) => void` | Set the data client for record fetching. Throws if stonecrop not available. |
|
|
405
405
|
| `getClient()` | `() => DataClient \| undefined` | Get the currently configured client. |
|
|
406
406
|
| `dispatchAction(doctype, action, args)` | `Promise<{ success, data, error }>` | Dispatch an action via the configured client. Returns error if doctype not found in registry. |
|
package/dist/module.json
CHANGED
|
@@ -129,6 +129,7 @@
|
|
|
129
129
|
import { ATable, ARow } from "@stonecrop/atable";
|
|
130
130
|
import { CANONICAL_COMPONENTS, INTROSPECTED_IDENTITY_PROPS } from "@stonecrop/schema";
|
|
131
131
|
import { computed, nextTick, ref, useId } from "vue";
|
|
132
|
+
import { updateFieldAt } from "./docbuilderFields";
|
|
132
133
|
const IDENTITY_PROPS = new Set(INTROSPECTED_IDENTITY_PROPS);
|
|
133
134
|
const isIdentity = (key) => IDENTITY_PROPS.has(key);
|
|
134
135
|
const componentListId = useId();
|
|
@@ -230,17 +231,7 @@ function collapseAllRows() {
|
|
|
230
231
|
});
|
|
231
232
|
}
|
|
232
233
|
function update(realIndex, key, val) {
|
|
233
|
-
emit(
|
|
234
|
-
"update:modelValue",
|
|
235
|
-
props.modelValue.map((f, i) => i === realIndex ? setOrDelete(f, key, val) : f)
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
function setOrDelete(field, key, val) {
|
|
239
|
-
if (val === void 0) {
|
|
240
|
-
const { [key]: _omit, ...rest } = field;
|
|
241
|
-
return rest;
|
|
242
|
-
}
|
|
243
|
-
return { ...field, [key]: val };
|
|
234
|
+
emit("update:modelValue", updateFieldAt(props.modelValue, realIndex, key, val));
|
|
244
235
|
}
|
|
245
236
|
const jsonErrors = ref({});
|
|
246
237
|
function jsonStr(v) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure write helpers backing {@link DocBuilderFieldsPanel}. Extracted from the SFC so they are
|
|
3
|
+
* unit-testable in a plain node environment, matching {@link docbuilderActions}.
|
|
4
|
+
*
|
|
5
|
+
* These are hop 3 of the four that must each preserve keys the builder never displays: the graph
|
|
6
|
+
* edit rebuilds `workflow.actions`, the actions panel writes a cell, **this** writes a field cell,
|
|
7
|
+
* then the server merges onto what is on disk. The invariant has regressed twice upstream — once
|
|
8
|
+
* dropping `clientHandler`, once dropping `triggers` — both times because a hop enumerated the
|
|
9
|
+
* named keys it knew about instead of spreading whatever was there.
|
|
10
|
+
*
|
|
11
|
+
* This hop is the one with the widest blast radius, because the server replaces `fields` wholesale
|
|
12
|
+
* with whatever the client sends. A key dropped here is dropped on disk. On the FAB app that is
|
|
13
|
+
* `filterFunction` x7, `isAsync` x7, `collapsible` x4, nested `schema` x4, `options` x1 and
|
|
14
|
+
* `source` x325 — none of which the panel renders, all of which must survive editing a label.
|
|
15
|
+
*/
|
|
16
|
+
/** A doctype field as authored on disk: known keys plus anything the builder does not model. */
|
|
17
|
+
export type Field = Record<string, unknown>;
|
|
18
|
+
/**
|
|
19
|
+
* Set `key` on `field`, or remove it when `val` is `undefined`.
|
|
20
|
+
*
|
|
21
|
+
* Clearing a cell must delete the key rather than write `undefined`: the doctype is serialised with
|
|
22
|
+
* `JSON.stringify`, which drops `undefined` values silently, so writing one would make "cleared" and
|
|
23
|
+
* "never set" indistinguishable on disk while still differing in memory.
|
|
24
|
+
*/
|
|
25
|
+
export declare function setOrDelete(field: Field, key: string, val: unknown): Field;
|
|
26
|
+
/**
|
|
27
|
+
* Rebuild the full field array with one cell changed at `realIndex`.
|
|
28
|
+
*
|
|
29
|
+
* Rebuilds every entry by index rather than splicing, so fields at other indices are returned
|
|
30
|
+
* untouched and order is preserved. An out-of-range index is a no-op returning an equal array.
|
|
31
|
+
*/
|
|
32
|
+
export declare function updateFieldAt(fields: readonly Field[], realIndex: number, key: string, val: unknown): Field[];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function setOrDelete(field, key, val) {
|
|
2
|
+
if (val === void 0) {
|
|
3
|
+
const { [key]: _omit, ...rest } = field;
|
|
4
|
+
return rest;
|
|
5
|
+
}
|
|
6
|
+
return { ...field, [key]: val };
|
|
7
|
+
}
|
|
8
|
+
export function updateFieldAt(fields, realIndex, key, val) {
|
|
9
|
+
return fields.map((f, i) => i === realIndex ? setOrDelete(f, key, val) : f);
|
|
10
|
+
}
|
|
@@ -1,30 +1,13 @@
|
|
|
1
|
-
import type { ActionEventPayload } from '@stonecrop/desktop';
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
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.
|
|
2
|
+
* The shared action executor, re-exported so Nuxt hosts keep getting it as an auto-import.
|
|
19
3
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
4
|
+
* It used to be defined here, which made the one blessed write path a Nuxt-only privilege while
|
|
5
|
+
* every other Vue host hand-rolled sixty lines of identity reconciliation — and both hosts that
|
|
6
|
+
* did got the create case wrong in the same way. Nothing in it was ever Nuxt-specific, so it now
|
|
7
|
+
* lives in `@stonecrop/stonecrop` alongside the store it writes to.
|
|
23
8
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* knowing that shape; an optional second argument is merged in for handlers needing more.
|
|
9
|
+
* This file stays because `addImportsDir` scans this directory: deleting it would silently remove
|
|
10
|
+
* the auto-import that every scaffolded app's `@action="run"` binding depends on.
|
|
27
11
|
*/
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
};
|
|
12
|
+
export { useClientAction } from '@stonecrop/stonecrop';
|
|
13
|
+
export type { ActionArgsContext, ActionDispatchResult, ActionFailure, FollowRecordContext, UseClientActionOptions, } from '@stonecrop/stonecrop';
|
|
@@ -1,51 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
export { useClientAction } from "@stonecrop/stonecrop";
|
|
@@ -95,6 +95,11 @@ export declare function useStonecropRegistry(): {
|
|
|
95
95
|
* Dispatch an action to the server via the configured data client.
|
|
96
96
|
* All state changes flow through this single mutation endpoint.
|
|
97
97
|
*
|
|
98
|
+
* This is the raw dispatch: it returns the result and writes nothing to the store. Prefer
|
|
99
|
+
* `useClientAction`, which also runs a declared `clientHandler` and writes the result back
|
|
100
|
+
* under the identity the *server* settled on — storing it under the id you dispatched
|
|
101
|
+
* strands a newly created record under a key nothing can fetch.
|
|
102
|
+
*
|
|
98
103
|
* @param doctype - Doctype reference object with `name` and optional `slug` properties
|
|
99
104
|
* @param doctype.name - Doctype name (e.g., 'plan')
|
|
100
105
|
* @param doctype.slug - Optional doctype slug if it differs from the name (e.g., 'project-plan')
|
|
@@ -121,7 +126,7 @@ export declare function useStonecropRegistry(): {
|
|
|
121
126
|
}>;
|
|
122
127
|
/**
|
|
123
128
|
* Set the `getMeta` function on the Registry.
|
|
124
|
-
* Called by `useStonecrop()` to lazy-load doctype metadata
|
|
129
|
+
* Called by `useStonecrop({ doctype: 'slug' })` to lazy-load that doctype's metadata.
|
|
125
130
|
*
|
|
126
131
|
* You must bridge `RouteContext` → `DoctypeContext`:
|
|
127
132
|
* - Extract doctype name from `segments` (e.g., `segments[0]`)
|
|
@@ -57,6 +57,11 @@ export function useStonecropRegistry() {
|
|
|
57
57
|
* Dispatch an action to the server via the configured data client.
|
|
58
58
|
* All state changes flow through this single mutation endpoint.
|
|
59
59
|
*
|
|
60
|
+
* This is the raw dispatch: it returns the result and writes nothing to the store. Prefer
|
|
61
|
+
* `useClientAction`, which also runs a declared `clientHandler` and writes the result back
|
|
62
|
+
* under the identity the *server* settled on — storing it under the id you dispatched
|
|
63
|
+
* strands a newly created record under a key nothing can fetch.
|
|
64
|
+
*
|
|
60
65
|
* @param doctype - Doctype reference object with `name` and optional `slug` properties
|
|
61
66
|
* @param doctype.name - Doctype name (e.g., 'plan')
|
|
62
67
|
* @param doctype.slug - Optional doctype slug if it differs from the name (e.g., 'project-plan')
|
|
@@ -93,7 +98,7 @@ export function useStonecropRegistry() {
|
|
|
93
98
|
},
|
|
94
99
|
/**
|
|
95
100
|
* Set the `getMeta` function on the Registry.
|
|
96
|
-
* Called by `useStonecrop()` to lazy-load doctype metadata
|
|
101
|
+
* Called by `useStonecrop({ doctype: 'slug' })` to lazy-load that doctype's metadata.
|
|
97
102
|
*
|
|
98
103
|
* You must bridge `RouteContext` → `DoctypeContext`:
|
|
99
104
|
* - Extract doctype name from `segments` (e.g., `segments[0]`)
|
|
@@ -72,7 +72,7 @@ export declare function useStonecropSetup(): {
|
|
|
72
72
|
registerClient(client: DataClient): void;
|
|
73
73
|
/**
|
|
74
74
|
* Set the `getMeta` function on the Registry.
|
|
75
|
-
* Called by `useStonecrop()` to lazy-load doctype metadata
|
|
75
|
+
* Called by `useStonecrop({ doctype: 'slug' })` to lazy-load that doctype's metadata.
|
|
76
76
|
*
|
|
77
77
|
* You must bridge `RouteContext` → `DoctypeContext`:
|
|
78
78
|
* - Extract doctype name from `segments` (e.g., `segments[0]`)
|
|
@@ -47,7 +47,7 @@ export function useStonecropSetup() {
|
|
|
47
47
|
},
|
|
48
48
|
/**
|
|
49
49
|
* Set the `getMeta` function on the Registry.
|
|
50
|
-
* Called by `useStonecrop()` to lazy-load doctype metadata
|
|
50
|
+
* Called by `useStonecrop({ doctype: 'slug' })` to lazy-load that doctype's metadata.
|
|
51
51
|
*
|
|
52
52
|
* You must bridge `RouteContext` → `DoctypeContext`:
|
|
53
53
|
* - Extract doctype name from `segments` (e.g., `segments[0]`)
|
|
@@ -9,3 +9,28 @@
|
|
|
9
9
|
* the intended one: re-imposing it keeps saves byte-stable while still honouring adds/removes.
|
|
10
10
|
*/
|
|
11
11
|
export declare function orderKeysByReference<T>(next: Record<string, T> | undefined, reference: Record<string, T> | undefined): Record<string, T> | undefined;
|
|
12
|
+
/** The subset of the save request body this merge reads. Extra keys are ignored, not written. */
|
|
13
|
+
export interface SaveDoctypeBody {
|
|
14
|
+
fields: unknown[];
|
|
15
|
+
workflow?: unknown;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build the object the docbuilder save writes to disk, from the file already there plus the
|
|
19
|
+
* builder's submission.
|
|
20
|
+
*
|
|
21
|
+
* This is the last of four hops that must each preserve keys the builder never displays — the
|
|
22
|
+
* graph edit, the actions-panel edit, the fields-panel edit, then this. The invariant has already
|
|
23
|
+
* regressed twice upstream (once dropping `clientHandler`, once dropping `triggers`), both times
|
|
24
|
+
* because a hop enumerated named keys instead of spreading, so it is worth stating exactly what
|
|
25
|
+
* this hop does and does not guarantee:
|
|
26
|
+
*
|
|
27
|
+
* - **Top-level keys survive** by spread. Anything on disk that the builder doesn't model —
|
|
28
|
+
* `links`, `primaryKey`, `source`, a consumer's own key — is carried through untouched.
|
|
29
|
+
* - **Field-level keys are NOT this hop's job.** `fields` is replaced wholesale with what the
|
|
30
|
+
* client sent, so a key dropped in the browser is dropped here too. Hop 3 owns that.
|
|
31
|
+
*
|
|
32
|
+
* @param existing - The parsed doctype already on disk, or `{}` when creating one
|
|
33
|
+
* @param body - The builder's submission
|
|
34
|
+
* @param requestedName - The slug the builder asked to save, used only as a `name` fallback
|
|
35
|
+
*/
|
|
36
|
+
export declare function mergeSavedDoctype(existing: Record<string, unknown>, body: SaveDoctypeBody, requestedName: string): Record<string, unknown>;
|
|
@@ -10,3 +10,21 @@ export function orderKeysByReference(next, reference) {
|
|
|
10
10
|
}
|
|
11
11
|
return result;
|
|
12
12
|
}
|
|
13
|
+
export function mergeSavedDoctype(existing, body, requestedName) {
|
|
14
|
+
const doctypeData = {
|
|
15
|
+
...existing,
|
|
16
|
+
fields: body.fields
|
|
17
|
+
};
|
|
18
|
+
if (body.workflow !== void 0 && body.workflow !== null) {
|
|
19
|
+
const existingActions = existing.workflow?.actions;
|
|
20
|
+
const workflow = body.workflow;
|
|
21
|
+
doctypeData.workflow = workflow.actions ? { ...workflow, actions: orderKeysByReference(workflow.actions, existingActions) } : workflow;
|
|
22
|
+
} else if (doctypeData.workflow === null || doctypeData.workflow === void 0) {
|
|
23
|
+
delete doctypeData.workflow;
|
|
24
|
+
}
|
|
25
|
+
if (typeof doctypeData.name !== "string" || doctypeData.name.length === 0) {
|
|
26
|
+
doctypeData.name = requestedName;
|
|
27
|
+
}
|
|
28
|
+
delete doctypeData.schema;
|
|
29
|
+
return doctypeData;
|
|
30
|
+
}
|
|
@@ -3,7 +3,7 @@ import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
4
|
import { createError, defineEventHandler, readBody } from "h3";
|
|
5
5
|
import { useRuntimeConfig } from "#imports";
|
|
6
|
-
import {
|
|
6
|
+
import { mergeSavedDoctype } from "./mergeDoctype.js";
|
|
7
7
|
export default defineEventHandler(async (event) => {
|
|
8
8
|
const body = await readBody(event);
|
|
9
9
|
if (!body.doctype || typeof body.doctype !== "string") {
|
|
@@ -50,21 +50,7 @@ export default defineEventHandler(async (event) => {
|
|
|
50
50
|
} catch {
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
const doctypeData =
|
|
54
|
-
...existing,
|
|
55
|
-
fields: body.fields
|
|
56
|
-
};
|
|
57
|
-
if (body.workflow !== void 0 && body.workflow !== null) {
|
|
58
|
-
const existingActions = existing.workflow?.actions;
|
|
59
|
-
const workflow = body.workflow;
|
|
60
|
-
doctypeData.workflow = workflow.actions ? { ...workflow, actions: orderKeysByReference(workflow.actions, existingActions) } : workflow;
|
|
61
|
-
} else if (doctypeData.workflow === null || doctypeData.workflow === void 0) {
|
|
62
|
-
delete doctypeData.workflow;
|
|
63
|
-
}
|
|
64
|
-
if (typeof doctypeData.name !== "string" || doctypeData.name.length === 0) {
|
|
65
|
-
doctypeData.name = requested;
|
|
66
|
-
}
|
|
67
|
-
delete doctypeData.schema;
|
|
53
|
+
const doctypeData = mergeSavedDoctype(existing, body, requested);
|
|
68
54
|
try {
|
|
69
55
|
await writeFile(filePath, JSON.stringify(doctypeData, null, " ") + "\n", "utf-8");
|
|
70
56
|
return { success: true, path: `doctypes/${basename(filePath)}` };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stonecrop/nuxt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Nuxt module for Stonecrop",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -45,18 +45,18 @@
|
|
|
45
45
|
"pathe": "^2.0.3",
|
|
46
46
|
"pinia": "^3.0.4",
|
|
47
47
|
"prompts": "^2.4.2",
|
|
48
|
-
"@stonecrop/aform": "0.
|
|
49
|
-
"@stonecrop/
|
|
50
|
-
"@stonecrop/
|
|
51
|
-
"@stonecrop/
|
|
52
|
-
"@stonecrop/
|
|
53
|
-
"@stonecrop/graphql-
|
|
54
|
-
"@stonecrop/node-editor": "0.
|
|
55
|
-
"@stonecrop/
|
|
56
|
-
"@stonecrop/nuxt-grafserv": "0.
|
|
57
|
-
"@stonecrop/
|
|
58
|
-
"@stonecrop/
|
|
59
|
-
"@stonecrop/
|
|
48
|
+
"@stonecrop/aform": "0.18.0",
|
|
49
|
+
"@stonecrop/casl-middleware": "0.18.0",
|
|
50
|
+
"@stonecrop/atable": "0.18.0",
|
|
51
|
+
"@stonecrop/code-editor": "0.18.0",
|
|
52
|
+
"@stonecrop/desktop": "0.18.0",
|
|
53
|
+
"@stonecrop/graphql-client": "0.18.0",
|
|
54
|
+
"@stonecrop/node-editor": "0.18.0",
|
|
55
|
+
"@stonecrop/graphql-middleware": "0.18.0",
|
|
56
|
+
"@stonecrop/nuxt-grafserv": "0.18.0",
|
|
57
|
+
"@stonecrop/schema": "0.18.0",
|
|
58
|
+
"@stonecrop/stonecrop": "0.18.0",
|
|
59
|
+
"@stonecrop/themes": "0.18.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@eslint/js": "^10.0.1",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"prepublish": "rushx dev:prepare && nuxt-module-build build",
|
|
104
104
|
"test": "vitest run",
|
|
105
105
|
"test:coverage": "vitest run --coverage.enabled --coverage.provider=istanbul",
|
|
106
|
-
"test:types": "vue-tsc --noEmit -p tsconfig.runtime.json && cd playground && vue-tsc --noEmit && cd ../fullstack && vue-tsc --noEmit && vue-tsc --noEmit -p server/tsconfig.json",
|
|
106
|
+
"test:types": "vue-tsc --noEmit -p tsconfig.runtime.json && vue-tsc --noEmit -p tsconfig.templates-app.json && vue-tsc --noEmit -p tsconfig.templates-server.json && cd playground && vue-tsc --noEmit && cd ../fullstack && vue-tsc --noEmit && vue-tsc --noEmit -p server/tsconfig.json",
|
|
107
107
|
"test:ui": "vitest --ui",
|
|
108
108
|
"test:watch": "vitest watch"
|
|
109
109
|
}
|
package/templates/Project.json
CHANGED
package/templates/Task.json
CHANGED
package/templates/index.vue
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<ClientOnly>
|
|
3
|
-
<Desktop
|
|
4
|
-
:available-doctypes="availableDoctypes"
|
|
5
|
-
:route-adapter="routeAdapter"
|
|
6
|
-
@action="handleAction"
|
|
7
|
-
@load-records="handleLoadRecords"
|
|
8
|
-
@load-record="handleLoadRecord" />
|
|
3
|
+
<Desktop :available-doctypes="availableDoctypes" :route-adapter="routeAdapter" @action="run" />
|
|
9
4
|
<template #fallback>
|
|
10
5
|
<div class="sc-loading">
|
|
11
6
|
<p>Loading...</p>
|
|
@@ -15,76 +10,24 @@
|
|
|
15
10
|
</template>
|
|
16
11
|
|
|
17
12
|
<script setup lang="ts">
|
|
18
|
-
import {
|
|
19
|
-
Desktop,
|
|
20
|
-
type ActionEventPayload,
|
|
21
|
-
type LoadRecordEventPayload,
|
|
22
|
-
type LoadRecordsEventPayload,
|
|
23
|
-
} from '@stonecrop/desktop'
|
|
24
|
-
import { useStonecrop } from '@stonecrop/stonecrop'
|
|
13
|
+
import { Desktop } from '@stonecrop/desktop'
|
|
25
14
|
|
|
26
15
|
import { useRouteAdapter } from '~/composables/useRouteAdapter'
|
|
27
|
-
import {
|
|
28
|
-
doctypeMap,
|
|
29
|
-
useDoctypeConfig,
|
|
30
|
-
fetchDoctypeRecords,
|
|
31
|
-
fetchDoctypeRecord,
|
|
32
|
-
runDoctypeAction,
|
|
33
|
-
} from '~/composables/useDoctypes'
|
|
16
|
+
import { doctypeMap } from '~/composables/useDoctypes'
|
|
34
17
|
|
|
35
18
|
const routeAdapter = useRouteAdapter()
|
|
36
|
-
|
|
37
|
-
|
|
19
|
+
// Shared action executor, auto-imported from @stonecrop/nuxt: it runs an action's clientHandler
|
|
20
|
+
// when the doctype declares one, otherwise dispatches to the server, and writes the result back
|
|
21
|
+
// into the store under the identity the server settled on — a Save against a record that does not
|
|
22
|
+
// exist creates it, so that identity is not always the one the form dispatched. Bound straight to
|
|
23
|
+
// Desktop's @action; a host-written wrapper would have to restate all of that.
|
|
24
|
+
const { run } = useClientAction()
|
|
25
|
+
|
|
26
|
+
// Reads are not bound here either. The StonecropClient registered in `stonecrop.client.ts` is this
|
|
27
|
+
// app's whole data layer: Stonecrop fetches through it and keys each result by the doctype's
|
|
28
|
+
// declared identity. A handler here would only race that fetch with a second copy of the same rule,
|
|
29
|
+
// which is how a hardcoded `record.id` once dropped every row of a natural-keyed doctype.
|
|
38
30
|
const availableDoctypes = computed(() => Array.from(doctypeMap.keys()))
|
|
39
|
-
|
|
40
|
-
async function handleLoadRecords(payload: LoadRecordsEventPayload) {
|
|
41
|
-
const doctypeConfig = useDoctypeConfig(payload.doctype)
|
|
42
|
-
if (!doctypeConfig || !stonecrop.value) return
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
const { data } = await fetchDoctypeRecords({ name: doctypeConfig.name })
|
|
46
|
-
for (const record of data) {
|
|
47
|
-
const recordId = record.id as string
|
|
48
|
-
if (recordId) stonecrop.value.addRecord(payload.doctype, recordId, record)
|
|
49
|
-
}
|
|
50
|
-
} catch (error) {
|
|
51
|
-
console.error('Failed to load records:', error)
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function handleLoadRecord(payload: LoadRecordEventPayload) {
|
|
56
|
-
if (!stonecrop.value || payload.recordId.startsWith('new-')) return
|
|
57
|
-
|
|
58
|
-
const doctypeConfig = useDoctypeConfig(payload.doctype)
|
|
59
|
-
if (!doctypeConfig) return
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
const record = await fetchDoctypeRecord({ name: doctypeConfig.name }, payload.recordId)
|
|
63
|
-
if (record) stonecrop.value.addRecord(payload.doctype, payload.recordId, record)
|
|
64
|
-
} catch (error) {
|
|
65
|
-
console.error('Failed to load record:', error)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function handleAction(payload: ActionEventPayload) {
|
|
70
|
-
const doctypeConfig = useDoctypeConfig(payload.doctype)
|
|
71
|
-
if (!doctypeConfig) return
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
const result = await runDoctypeAction(doctypeConfig, payload.name, {
|
|
75
|
-
id: payload.recordId,
|
|
76
|
-
data: payload.data,
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
if (result.success && result.data && stonecrop.value && payload.recordId) {
|
|
80
|
-
stonecrop.value.addRecord(payload.doctype, payload.recordId, result.data as Record<string, unknown>)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!result.success) console.error('Action failed:', result.error)
|
|
84
|
-
} catch (error) {
|
|
85
|
-
console.error('Action error:', error)
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
31
|
</script>
|
|
89
32
|
|
|
90
33
|
<style>
|
package/templates/plugins.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* @see https://grafast.org/grafserv/plugins
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// `GraphileConfig` is an ambient global declared by graphile-config's own types, so it needs no
|
|
9
|
+
// import. The `import type { GraphileConfig } from 'graphile-config'` that used to be here never
|
|
10
|
+
// resolved — graphile-config is an optional peer dependency, so it is not installed — and an
|
|
11
|
+
// unresolved type silently degrades everything annotated with it to `any`.
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* Example: Request logging plugin
|
|
@@ -17,9 +20,13 @@ const loggingPlugin: GraphileConfig.Plugin = {
|
|
|
17
20
|
middleware: {
|
|
18
21
|
processGraphQLRequestBody: async (next, event) => {
|
|
19
22
|
const start = Date.now()
|
|
23
|
+
|
|
24
|
+
// `event.request` is optional: a GraphQL-over-WebSocket message carries no HTTP request.
|
|
25
|
+
// It is grafserv's own RequestDigest, not a Web `Request` — the path lives on `path`
|
|
26
|
+
// (there is no `url`), and headers are read with `getHeader()`, not `headers.get()`.
|
|
20
27
|
console.log('[GraphQL] Request started:', {
|
|
21
|
-
path: event.request
|
|
22
|
-
method: event.request
|
|
28
|
+
path: event.request?.path,
|
|
29
|
+
method: event.request?.method,
|
|
23
30
|
})
|
|
24
31
|
|
|
25
32
|
const result = await next()
|
|
@@ -42,8 +49,9 @@ const _authPlugin: GraphileConfig.Plugin = {
|
|
|
42
49
|
grafserv: {
|
|
43
50
|
middleware: {
|
|
44
51
|
processGraphQLRequestBody: async (next, event) => {
|
|
45
|
-
// Extract authentication from headers
|
|
46
|
-
|
|
52
|
+
// Extract authentication from headers. See the logging plugin above for why `request`
|
|
53
|
+
// is optional and why this is `getHeader()` rather than `headers.get()`.
|
|
54
|
+
const authHeader = event.request?.getHeader('authorization')
|
|
47
55
|
|
|
48
56
|
if (authHeader?.startsWith('Bearer ')) {
|
|
49
57
|
const token = authHeader.slice(7)
|
|
@@ -60,18 +68,29 @@ const _authPlugin: GraphileConfig.Plugin = {
|
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/**
|
|
63
|
-
* Export all plugins
|
|
64
|
-
*
|
|
71
|
+
* Export all plugins.
|
|
72
|
+
*
|
|
73
|
+
* Reference them from your PostGraphile preset file — `grafserv.preset` is a **path to that file**,
|
|
74
|
+
* not an inline object. Inline presets are not supported: Nitro's build/runtime split cannot
|
|
75
|
+
* serialize the functions and plugin instances a preset contains.
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* // server/graphile.preset.ts
|
|
79
|
+
* import { createStonecropPreset, makePgService, createStonecropPlugin } from '@stonecrop/graphql-middleware'
|
|
80
|
+
* import plugins from './plugins'
|
|
65
81
|
*
|
|
66
|
-
*
|
|
82
|
+
* export default {
|
|
83
|
+
* extends: [createStonecropPreset()],
|
|
84
|
+
* pgServices: [makePgService({ connectionString: process.env.DATABASE_URL, schemas: ['public'] })],
|
|
85
|
+
* plugins: [createStonecropPlugin(), ...plugins],
|
|
86
|
+
* }
|
|
67
87
|
*
|
|
88
|
+
* // nuxt.config.ts
|
|
68
89
|
* export default defineNuxtConfig({
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* plugins
|
|
72
|
-
* }
|
|
73
|
-
* }
|
|
90
|
+
* modules: ['@stonecrop/nuxt-grafserv'],
|
|
91
|
+
* grafserv: { type: 'postgraphile', preset: './server/graphile.preset.ts' },
|
|
74
92
|
* })
|
|
93
|
+
* ```
|
|
75
94
|
*/
|
|
76
95
|
export const plugins: GraphileConfig.Plugin[] = [
|
|
77
96
|
loggingPlugin,
|
package/templates/resolvers.ts
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* - loadOne($step, fn) — batch-load data ASYNCHRONOUSLY (for DB queries)
|
|
10
10
|
* - object({ ... }) — group multiple steps into a single step object
|
|
11
11
|
*
|
|
12
|
-
* Data reads go through loadOne. Workflow
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Data reads go through loadOne. Workflow outcomes are applied by the stonecropAction
|
|
13
|
+
* resolver itself (server-owns-transition, guarded by allowedStates), and anything a
|
|
14
|
+
* doctype cannot express — a Command with no state change — goes through the
|
|
15
|
+
* `actionHandlers` map below.
|
|
15
16
|
*
|
|
16
17
|
* To connect a real database, replace the imports from ./data with your
|
|
17
18
|
* PostGraphile setup. See: https://stonecrop.io/docs/guides/postgraphile
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
|
|
20
21
|
import { constant, lambda, loadOne, object } from 'grafast'
|
|
21
22
|
import { getMeta, getAllMeta, applyGuardedTransition } from '@stonecrop/graphql-middleware'
|
|
22
|
-
import {
|
|
23
|
+
import { getRecordIdField } from '@stonecrop/schema'
|
|
23
24
|
import type { DoctypeMeta } from '@stonecrop/schema'
|
|
24
25
|
import { projects, tasks, type Project, type Task } from './data'
|
|
25
26
|
|
|
@@ -68,12 +69,13 @@ export function formatDoctypeMeta(meta: DoctypeMeta) {
|
|
|
68
69
|
*
|
|
69
70
|
* The `id` fallback covers doctypes that declare no `primaryKey`. Every doctype in this repo now
|
|
70
71
|
* declares one, enforced by test/doctype-fixtures.test.ts, so in-repo the fallback is inert.
|
|
71
|
-
* It stays for consumer doctypes that have not adopted the rule
|
|
72
|
-
*
|
|
73
|
-
*
|
|
72
|
+
* It stays for consumer doctypes that have not adopted the rule, and every host now applies it —
|
|
73
|
+
* the Postgres adapter used to omit it and refuse those doctypes outright, which is what made the
|
|
74
|
+
* hosts disagree. This is a thin wrapper on the shared rule, kept for the `meta`-shaped signature
|
|
75
|
+
* the resolvers call it with.
|
|
74
76
|
*/
|
|
75
77
|
export function recordLookupField(meta: DoctypeMeta): string {
|
|
76
|
-
return
|
|
78
|
+
return getRecordIdField(meta.fields)
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
function getRecord(doctype: string, id: string, lookupField = 'id'): Project | Task | null {
|
|
@@ -91,6 +93,19 @@ function getRecord(doctype: string, id: string, lookupField = 'id'): Project | T
|
|
|
91
93
|
return null
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Write a record into its store under `key`.
|
|
98
|
+
*
|
|
99
|
+
* `key` is the record's identity under the doctype's declared `primaryKey` — the same field
|
|
100
|
+
* `getRecord` matches against — so the store is keyed by whatever the doctype says identifies a
|
|
101
|
+
* record, not by a hardcoded `id`. Both doctypes here declare `id`, so the two coincide today.
|
|
102
|
+
*/
|
|
103
|
+
function setRecord(doctype: string, key: string, record: Record<string, unknown>): void {
|
|
104
|
+
const d = doctype.toLowerCase()
|
|
105
|
+
if (d === 'project') projects.set(key, record as unknown as Project)
|
|
106
|
+
else if (d === 'task') tasks.set(key, record as unknown as Task)
|
|
107
|
+
}
|
|
108
|
+
|
|
94
109
|
function getRecords(doctype: string, filters?: Record<string, unknown>): (Project | Task)[] {
|
|
95
110
|
const d = doctype.toLowerCase()
|
|
96
111
|
if (d === 'project') {
|
|
@@ -113,6 +128,51 @@ function nextId(doctype: string): string {
|
|
|
113
128
|
return String(max + 1)
|
|
114
129
|
}
|
|
115
130
|
|
|
131
|
+
// ============================================================
|
|
132
|
+
// Server-side action effects
|
|
133
|
+
// ============================================================
|
|
134
|
+
// A doctype's workflow says whether an action may run (`allowedStates`) and what state results
|
|
135
|
+
// (`nextState`, `selfTransition`). It deliberately says nothing about what the action *does*: a
|
|
136
|
+
// doctype is runtime data edited in DocBuilder, and it must not name server code a different
|
|
137
|
+
// author owns. So the routing from action to effect lives here, keyed `[doctype name][action
|
|
138
|
+
// key]`, and is never published to the client.
|
|
139
|
+
//
|
|
140
|
+
// This is what makes a Command executable at all. An action with no `nextState` and no
|
|
141
|
+
// `selfTransition` has nothing for the dispatcher to apply, and without an entry here it fails
|
|
142
|
+
// loudly rather than reporting a false success.
|
|
143
|
+
//
|
|
144
|
+
// Add your own by registering under the doctype's `name`. Throwing rejects the action; returning
|
|
145
|
+
// the updated record makes it the client writeback payload.
|
|
146
|
+
|
|
147
|
+
type ActionHandler = (context: {
|
|
148
|
+
recordId?: string
|
|
149
|
+
/** Record field data the client sent. Unvalidated browser input — validate before trusting it. */
|
|
150
|
+
data: Record<string, unknown>
|
|
151
|
+
/** The state the guard read, or undefined when nothing about the action required reading it. */
|
|
152
|
+
currentState?: string
|
|
153
|
+
}) => Promise<unknown>
|
|
154
|
+
|
|
155
|
+
export const actionHandlers: Record<string, Record<string, ActionHandler>> = {
|
|
156
|
+
Task: {
|
|
157
|
+
/**
|
|
158
|
+
* Push the due date out by a week.
|
|
159
|
+
*
|
|
160
|
+
* The example is deliberately a *stateless* command: snoozing does not move the task
|
|
161
|
+
* through its workflow, so there is no state for the doctype to declare — only an effect.
|
|
162
|
+
*/
|
|
163
|
+
async snooze({ recordId }) {
|
|
164
|
+
const task = recordId != null ? tasks.get(recordId) : undefined
|
|
165
|
+
if (!task) throw new Error(`Task ${recordId ?? '(none)'} not found`)
|
|
166
|
+
|
|
167
|
+
const from = task.dueDate ? new Date(task.dueDate) : new Date()
|
|
168
|
+
from.setDate(from.getDate() + 7)
|
|
169
|
+
const updated: Task = { ...task, dueDate: from.toISOString().slice(0, 10) }
|
|
170
|
+
tasks.set(task.id, updated)
|
|
171
|
+
return Promise.resolve(updated)
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
116
176
|
// ============================================================
|
|
117
177
|
// Resolvers (Grafast plan format)
|
|
118
178
|
// ============================================================
|
|
@@ -154,7 +214,7 @@ export const resolvers = {
|
|
|
154
214
|
})
|
|
155
215
|
},
|
|
156
216
|
|
|
157
|
-
stonecropRecords(_: unknown, { $doctype, $filters, $orderBy, $limit, $offset, $options }: any) {
|
|
217
|
+
stonecropRecords(_: unknown, { $doctype, $filters, $orderBy, $limit, $offset, $includeTotal, $options }: any) {
|
|
158
218
|
return loadOne(
|
|
159
219
|
object({
|
|
160
220
|
doctype: $doctype,
|
|
@@ -162,6 +222,7 @@ export const resolvers = {
|
|
|
162
222
|
orderBy: $orderBy,
|
|
163
223
|
limit: $limit,
|
|
164
224
|
offset: $offset,
|
|
225
|
+
includeTotal: $includeTotal,
|
|
165
226
|
options: $options,
|
|
166
227
|
}),
|
|
167
228
|
async (specs: readonly any[]) => {
|
|
@@ -169,10 +230,15 @@ export const resolvers = {
|
|
|
169
230
|
const all = getRecords(spec.doctype, spec.filters ?? {})
|
|
170
231
|
const offset = spec.offset ?? 0
|
|
171
232
|
const limit = spec.limit ?? 100
|
|
233
|
+
const page = all.slice(offset, offset + limit)
|
|
172
234
|
return {
|
|
173
|
-
data:
|
|
235
|
+
data: page,
|
|
174
236
|
doctype: spec.doctype,
|
|
175
|
-
|
|
237
|
+
hasMore: offset + page.length < all.length,
|
|
238
|
+
// This store is in memory, so counting is free — but it stays opt-in anyway,
|
|
239
|
+
// because the scaffold is what a real adapter gets copied from and a backend
|
|
240
|
+
// that answers a total nobody asked for teaches the wrong default.
|
|
241
|
+
count: spec.includeTotal === true ? all.length : null,
|
|
176
242
|
}
|
|
177
243
|
})
|
|
178
244
|
}
|
|
@@ -201,6 +267,11 @@ export const resolvers = {
|
|
|
201
267
|
const recordId = argList[0]?.id != null ? String(argList[0].id) : undefined
|
|
202
268
|
const recordData: Record<string, unknown> = argList[0]?.data ?? {}
|
|
203
269
|
const d = spec.doctype.toLowerCase()
|
|
270
|
+
const handler = actionHandlers[meta.name]?.[String(spec.action)]
|
|
271
|
+
// The field a record is identified by. The read path already resolved records
|
|
272
|
+
// through this; the action path used to assume `id`, so an action on a
|
|
273
|
+
// natural-keyed doctype looked up a key the client never sent.
|
|
274
|
+
const lookupField = recordLookupField(meta)
|
|
204
275
|
|
|
205
276
|
try {
|
|
206
277
|
// The server owns the transition: read current state, guard against allowedStates,
|
|
@@ -210,28 +281,61 @@ export const resolvers = {
|
|
|
210
281
|
actionDef,
|
|
211
282
|
{
|
|
212
283
|
readState: async () => {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
284
|
+
// `null` means "no such record" and `undefined` means "exists, no
|
|
285
|
+
// state" — the dispatcher rejects the first outright. Returning
|
|
286
|
+
// `undefined` for both is what let a Save on a record that was
|
|
287
|
+
// never created report success while persisting nothing.
|
|
288
|
+
if (recordId == null) return null
|
|
289
|
+
const record = getRecord(d, recordId, lookupField)
|
|
290
|
+
if (!record) return null
|
|
291
|
+
return record.status == null ? undefined : String(record.status)
|
|
216
292
|
},
|
|
217
293
|
writeState: async (nextState: string) => {
|
|
218
294
|
if (recordId == null) return
|
|
219
|
-
const existing = getRecord(d, recordId)
|
|
295
|
+
const existing = getRecord(d, recordId, lookupField)
|
|
220
296
|
if (!existing) return
|
|
221
|
-
|
|
222
|
-
else if (d === 'task') tasks.set(recordId, { ...existing, status: nextState } as Task)
|
|
297
|
+
setRecord(d, recordId, { ...existing, status: nextState })
|
|
223
298
|
},
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
|
|
299
|
+
// Save is an upsert, and it is the only write path — there is no create action
|
|
300
|
+
// and no create mutation. Updating merges the edited fields in place (status
|
|
301
|
+
// untouched); creating derives the identity from the doctype's declared
|
|
302
|
+
// primary key when the submitted data carries it, which is how a
|
|
303
|
+
// natural-keyed doctype is identified, and mints one only otherwise.
|
|
304
|
+
// Either way the full record comes back for the client writeback.
|
|
305
|
+
writeData: async (patch: Record<string, unknown>, exists: boolean) => {
|
|
306
|
+
if (exists) {
|
|
307
|
+
if (recordId == null) return {}
|
|
308
|
+
const existing = getRecord(d, recordId, lookupField)
|
|
309
|
+
if (!existing) return {}
|
|
310
|
+
const updated = { ...existing, ...patch }
|
|
311
|
+
setRecord(d, recordId, updated)
|
|
312
|
+
return updated as Record<string, unknown>
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const declared = patch[lookupField]
|
|
316
|
+
const identity =
|
|
317
|
+
typeof declared === 'string' && declared !== ''
|
|
318
|
+
? declared
|
|
319
|
+
: typeof declared === 'number'
|
|
320
|
+
? String(declared)
|
|
321
|
+
: nextId(d)
|
|
322
|
+
const defaults =
|
|
323
|
+
d === 'project'
|
|
324
|
+
? { status: 'Active', description: '' }
|
|
325
|
+
: { status: 'Todo', description: '', dueDate: null }
|
|
326
|
+
const record = {
|
|
327
|
+
...defaults,
|
|
328
|
+
...patch,
|
|
329
|
+
[lookupField]: identity,
|
|
330
|
+
createdAt: new Date().toISOString(),
|
|
331
|
+
}
|
|
332
|
+
setRecord(d, identity, record)
|
|
333
|
+
return record
|
|
234
334
|
},
|
|
335
|
+
// The server-owned effect for this action, if one is registered above.
|
|
336
|
+
runEffect: handler
|
|
337
|
+
? (currentState: string | undefined) => handler({ recordId, data: recordData, currentState })
|
|
338
|
+
: undefined,
|
|
235
339
|
},
|
|
236
340
|
recordData
|
|
237
341
|
)
|
|
@@ -243,64 +347,6 @@ export const resolvers = {
|
|
|
243
347
|
}
|
|
244
348
|
)
|
|
245
349
|
},
|
|
246
|
-
|
|
247
|
-
stonecropCreate(_: unknown, { $doctype, $input }: any) {
|
|
248
|
-
return loadOne(object({ doctype: $doctype, input: $input }), async (specs: readonly any[]) => {
|
|
249
|
-
return specs.map(spec => {
|
|
250
|
-
const d = spec.doctype.toLowerCase()
|
|
251
|
-
const id = nextId(d)
|
|
252
|
-
const now = new Date().toISOString()
|
|
253
|
-
if (d === 'project') {
|
|
254
|
-
const record: Project = { id, createdAt: now, status: 'Active', description: '', ...spec.input }
|
|
255
|
-
projects.set(id, record)
|
|
256
|
-
return { data: record, doctype: spec.doctype }
|
|
257
|
-
}
|
|
258
|
-
if (d === 'task') {
|
|
259
|
-
const record: Task = { id, createdAt: now, status: 'Todo', description: '', dueDate: null, ...spec.input }
|
|
260
|
-
tasks.set(id, record)
|
|
261
|
-
return { data: record, doctype: spec.doctype }
|
|
262
|
-
}
|
|
263
|
-
return { data: null, doctype: spec.doctype }
|
|
264
|
-
})
|
|
265
|
-
})
|
|
266
|
-
},
|
|
267
|
-
|
|
268
|
-
stonecropUpdate(_: unknown, { $doctype, $id, $patch }: any) {
|
|
269
|
-
return loadOne(object({ doctype: $doctype, id: $id, patch: $patch }), async (specs: readonly any[]) => {
|
|
270
|
-
return specs.map(spec => {
|
|
271
|
-
const d = spec.doctype.toLowerCase()
|
|
272
|
-
const existing = getRecord(d, spec.id)
|
|
273
|
-
if (!existing) return null
|
|
274
|
-
if (d === 'project') {
|
|
275
|
-
const updated = { ...existing, ...spec.patch } as Project
|
|
276
|
-
projects.set(spec.id, updated)
|
|
277
|
-
return { data: updated, doctype: spec.doctype }
|
|
278
|
-
}
|
|
279
|
-
if (d === 'task') {
|
|
280
|
-
const updated = { ...existing, ...spec.patch } as Task
|
|
281
|
-
tasks.set(spec.id, updated)
|
|
282
|
-
return { data: updated, doctype: spec.doctype }
|
|
283
|
-
}
|
|
284
|
-
return null
|
|
285
|
-
})
|
|
286
|
-
})
|
|
287
|
-
},
|
|
288
|
-
|
|
289
|
-
stonecropDelete(_: unknown, { $doctype, $id }: any) {
|
|
290
|
-
return loadOne(object({ doctype: $doctype, id: $id }), async (specs: readonly any[]) => {
|
|
291
|
-
return specs.map(spec => {
|
|
292
|
-
const d = spec.doctype.toLowerCase()
|
|
293
|
-
let deleted = false
|
|
294
|
-
if (d === 'project') deleted = projects.delete(spec.id)
|
|
295
|
-
else if (d === 'task') deleted = tasks.delete(spec.id)
|
|
296
|
-
return {
|
|
297
|
-
success: deleted,
|
|
298
|
-
data: deleted ? { id: spec.id } : null,
|
|
299
|
-
error: deleted ? null : 'Record not found',
|
|
300
|
-
}
|
|
301
|
-
})
|
|
302
|
-
})
|
|
303
|
-
},
|
|
304
350
|
},
|
|
305
351
|
},
|
|
306
352
|
}
|
package/templates/schema.graphql
CHANGED
|
@@ -89,7 +89,10 @@ type RecordResult {
|
|
|
89
89
|
type RecordsResult {
|
|
90
90
|
data: [JSON!]!
|
|
91
91
|
doctype: String!
|
|
92
|
-
|
|
92
|
+
"Whether further records exist beyond this page."
|
|
93
|
+
hasMore: Boolean!
|
|
94
|
+
"Total matching the filters. Null unless the query asked for it with includeTotal."
|
|
95
|
+
count: Int
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
type ActionResult {
|
|
@@ -137,6 +140,7 @@ type Query {
|
|
|
137
140
|
orderBy: String
|
|
138
141
|
limit: Int
|
|
139
142
|
offset: Int
|
|
143
|
+
includeTotal: Boolean
|
|
140
144
|
options: JSON
|
|
141
145
|
): RecordsResult
|
|
142
146
|
}
|
|
@@ -150,19 +154,4 @@ type Mutation {
|
|
|
150
154
|
Execute a doctype action
|
|
151
155
|
"""
|
|
152
156
|
stonecropAction(doctype: String!, action: String!, args: JSON): ActionResult!
|
|
153
|
-
|
|
154
|
-
"""
|
|
155
|
-
Create a new record
|
|
156
|
-
"""
|
|
157
|
-
stonecropCreate(doctype: String!, input: JSON!): RecordResult!
|
|
158
|
-
|
|
159
|
-
"""
|
|
160
|
-
Update an existing record
|
|
161
|
-
"""
|
|
162
|
-
stonecropUpdate(doctype: String!, id: String!, patch: JSON!): RecordResult
|
|
163
|
-
|
|
164
|
-
"""
|
|
165
|
-
Delete a record
|
|
166
|
-
"""
|
|
167
|
-
stonecropDelete(doctype: String!, id: String!): ActionResult!
|
|
168
157
|
}
|
package/templates/useDoctypes.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import type { DoctypeRef } from '@stonecrop/schema'
|
|
2
1
|
import type { DoctypeConfig } from '@stonecrop/stonecrop'
|
|
3
|
-
import { useNuxtApp } from 'nuxt/app'
|
|
4
2
|
|
|
5
3
|
const modules = import.meta.glob<DoctypeConfig>('../../doctypes/*.json', {
|
|
6
4
|
eager: true,
|
|
@@ -22,32 +20,15 @@ export function useDoctypeConfig(slug: string): DoctypeConfig | undefined {
|
|
|
22
20
|
return doctypeMap.get(slug)
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return result.record
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ActionResult {
|
|
41
|
-
success: boolean
|
|
42
|
-
data?: unknown
|
|
43
|
-
error?: string | null
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function runDoctypeAction(
|
|
47
|
-
doctype: DoctypeConfig,
|
|
48
|
-
action: string,
|
|
49
|
-
args: { id: string; data?: Record<string, unknown> }
|
|
50
|
-
): Promise<ActionResult> {
|
|
51
|
-
const { $stonecropClient } = useNuxtApp()
|
|
52
|
-
return $stonecropClient.runAction({ name: doctype.name }, action, [args])
|
|
53
|
-
}
|
|
23
|
+
// There are deliberately no fetch helpers here. Fetching is not the whole job: the result has to
|
|
24
|
+
// land in the store under the identity the doctype declares, and something has to decide whether a
|
|
25
|
+
// read is warranted at all. `Stonecrop.getRecord`/`getRecords` own all of it and reach your backend
|
|
26
|
+
// through the client registered in `stonecrop.client.ts` — call those instead.
|
|
27
|
+
//
|
|
28
|
+
// The pair that used to live here also hardcoded `limit = 200`, which is a decision about what the
|
|
29
|
+
// backend can afford and therefore the server's to make, not a page's.
|
|
30
|
+
|
|
31
|
+
// Actions are deliberately not dispatched from here. Dispatching is only half the job: the result
|
|
32
|
+
// has to land in the store under the identity the server settled on, which is not always the id
|
|
33
|
+
// that was dispatched — a Save against a record that does not exist creates one. `useClientAction`
|
|
34
|
+
// (auto-imported from @stonecrop/nuxt) owns both halves; app/pages/index.vue binds it directly.
|