@stonecrop/nuxt 0.39.0 → 0.41.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/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stonecrop/nuxt",
3
3
  "configKey": "stonecrop",
4
- "version": "0.39.0",
4
+ "version": "0.41.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
@@ -129,7 +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 { isValueField, updateFieldAt } from "./docbuilderFields";
132
+ import { copyField, isValueField, updateFieldAt } from "./docbuilderFields";
133
133
  const IDENTITY_PROPS = new Set(INTROSPECTED_IDENTITY_PROPS);
134
134
  const isIdentity = (key) => IDENTITY_PROPS.has(key);
135
135
  const componentListId = useId();
@@ -156,10 +156,7 @@ const BOOL_PROPS = [
156
156
  { key: "primaryKey", label: "Primary key" },
157
157
  { key: "computed", label: "Computed (no DB column)" }
158
158
  ];
159
- const JSON_PROPS = [
160
- { key: "options", label: "Options" },
161
- { key: "default", label: "Default" }
162
- ];
159
+ const JSON_PROPS = [{ key: "options", label: "Options" }];
163
160
  const FIELD_COLUMNS = [
164
161
  { name: "fieldname", label: "ID", sortable: false, filterable: true },
165
162
  { name: "label", label: "Label", sortable: false },
@@ -295,13 +292,12 @@ function removeField(realIndex) {
295
292
  function duplicateField(realIndex) {
296
293
  const original = props.modelValue[realIndex];
297
294
  if (!original) return;
298
- const { source: _source, ...rest } = original;
299
295
  const existing = new Set(props.modelValue.map((f) => String(f.fieldname ?? "")));
300
- const baseName = `${String(rest.fieldname ?? "field")}_copy`;
296
+ const baseName = `${String(original.fieldname ?? "field")}_copy`;
301
297
  let fieldname = baseName;
302
298
  let n = 1;
303
299
  while (existing.has(fieldname)) fieldname = `${baseName}_${++n}`;
304
- const clone = { ...rest, fieldname };
300
+ const clone = copyField(original, fieldname);
305
301
  const next = props.modelValue.slice();
306
302
  next.splice(realIndex + 1, 0, clone);
307
303
  emit("update:modelValue", next);
@@ -43,3 +43,11 @@ export declare function setOrDelete(field: Field, key: string, val: unknown): Fi
43
43
  * untouched and order is preserved. An out-of-range index is a no-op returning an equal array.
44
44
  */
45
45
  export declare function updateFieldAt(fields: readonly Field[], realIndex: number, key: string, val: unknown): Field[];
46
+ /** Give each field that has no id yet a new one. Call it on load and on each save, before reading the fields' ids. */
47
+ export declare function withFieldIds(fields: readonly Field[]): Field[];
48
+ /** Each field's name as the file now has it, by id: from the fields loaded, or the fields a save wrote. */
49
+ export declare function savedNames(fields: readonly Field[]): ReadonlyMap<number, string>;
50
+ /** The fields renamed since `saved` was taken, from the name in the file to the current one. */
51
+ export declare function renamedFields(fields: readonly Field[], saved: ReadonlyMap<number, string>): Record<string, string>;
52
+ /** A new field copied from `field`: every key but its name, its introspected marker and its id. */
53
+ export declare function copyField(field: Field, fieldname: string): Field;
@@ -12,3 +12,29 @@ export function setOrDelete(field, key, val) {
12
12
  export function updateFieldAt(fields, realIndex, key, val) {
13
13
  return fields.map((f, i) => i === realIndex ? setOrDelete(f, key, val) : f);
14
14
  }
15
+ const FIELD_ID = Symbol("field id");
16
+ let lastId = 0;
17
+ export function withFieldIds(fields) {
18
+ return fields.map((f) => f[FIELD_ID] === void 0 ? { ...f, [FIELD_ID]: ++lastId } : f);
19
+ }
20
+ export function savedNames(fields) {
21
+ const names = /* @__PURE__ */ new Map();
22
+ for (const field of fields) {
23
+ const id = field[FIELD_ID];
24
+ if (id !== void 0) names.set(id, String(field.fieldname));
25
+ }
26
+ return names;
27
+ }
28
+ export function renamedFields(fields, saved) {
29
+ const renamed = {};
30
+ for (const field of fields) {
31
+ const id = field[FIELD_ID];
32
+ const before = id === void 0 ? void 0 : saved.get(id);
33
+ if (before !== void 0 && before !== field.fieldname) renamed[before] = String(field.fieldname);
34
+ }
35
+ return renamed;
36
+ }
37
+ export function copyField(field, fieldname) {
38
+ const { source: _source, [FIELD_ID]: _id, ...rest } = field;
39
+ return { ...rest, fieldname };
40
+ }
@@ -67,6 +67,7 @@ import { ref, watch, computed, onMounted } from "vue";
67
67
  import { useRoute, useRouter } from "nuxt/app";
68
68
  import DocBuilderActionsPanel from "../components/DocBuilderActionsPanel.vue";
69
69
  import DocBuilderFieldsPanel from "../components/DocBuilderFieldsPanel.vue";
70
+ import { renamedFields, savedNames, withFieldIds } from "../components/docbuilderFields";
70
71
  const route = useRoute();
71
72
  const router = useRouter();
72
73
  const doctypeName = computed(() => route.params.doctype);
@@ -76,6 +77,7 @@ const saving = ref(false);
76
77
  const warningsDismissed = ref(false);
77
78
  const saveMessage = ref(null);
78
79
  const fields = ref([]);
80
+ let namesInFile = /* @__PURE__ */ new Map();
79
81
  const workflowConfig = ref();
80
82
  const layout = ref({});
81
83
  const validationIssues = ref([]);
@@ -84,7 +86,8 @@ const warningCount = computed(() => validationIssues.value.filter((i) => i.sever
84
86
  onMounted(async () => {
85
87
  try {
86
88
  const data = await $fetch(`/api/_stonecrop/docbuilder/${doctypeName.value}`);
87
- fields.value = data.fields ?? [];
89
+ fields.value = withFieldIds(data.fields ?? []);
90
+ namesInFile = savedNames(fields.value);
88
91
  if (data.workflow) {
89
92
  const { layout: savedLayout, ...topology } = data.workflow;
90
93
  workflowConfig.value = topology;
@@ -124,18 +127,22 @@ async function saveToDisk() {
124
127
  if (errorCount.value > 0) return;
125
128
  saving.value = true;
126
129
  saveMessage.value = null;
130
+ const sent = withFieldIds(fields.value);
131
+ fields.value = sent;
127
132
  try {
128
133
  await $fetch("/api/_stonecrop/docbuilder/save", {
129
134
  method: "POST",
130
135
  body: {
131
136
  doctype: doctypeName.value,
132
- fields: fields.value,
137
+ fields: sent,
138
+ renamedFields: renamedFields(sent, namesInFile),
133
139
  workflow: workflowConfig.value ? { ...workflowConfig.value, ...Object.keys(layout.value).length > 0 && { layout: layout.value } } : null
134
140
  }
135
141
  });
142
+ namesInFile = savedNames(sent);
136
143
  saveMessage.value = { type: "success", text: "Saved." };
137
144
  } catch (error) {
138
- saveMessage.value = { type: "error", text: error.message || "Save failed." };
145
+ saveMessage.value = { type: "error", text: error.data?.message || error.message || "Save failed." };
139
146
  } finally {
140
147
  saving.value = false;
141
148
  }
@@ -13,6 +13,8 @@ export declare function orderKeysByReference<T>(next: Record<string, T> | undefi
13
13
  export interface SaveDoctypeBody {
14
14
  fields: unknown[];
15
15
  workflow?: unknown;
16
+ /** The fields the builder renamed since the file was read, from the name on disk to the new one. */
17
+ renamedFields?: Record<string, string>;
16
18
  }
17
19
  /**
18
20
  * Build the object the docbuilder save writes to disk, from the file already there plus the
@@ -28,9 +30,16 @@ export interface SaveDoctypeBody {
28
30
  * `links`, `primaryKey`, `source`, a consumer's own key — is carried through untouched.
29
31
  * - **Field-level keys are NOT this hop's job.** `fields` is replaced wholesale with what the
30
32
  * client sent, so a key dropped in the browser is dropped here too. Hop 3 owns that.
33
+ * - **`defaults` follows the field edits.** It is keyed by fieldname and the builder never shows it, so a renamed
34
+ * field's entry moves to its new name and a deleted field's entry goes with the field.
31
35
  *
32
36
  * @param existing - The parsed doctype already on disk, or `{}` when creating one
33
37
  * @param body - The builder's submission
34
38
  * @param requestedName - The slug the builder asked to save, used only as a `name` fallback
35
39
  */
36
40
  export declare function mergeSavedDoctype(existing: Record<string, unknown>, body: SaveDoctypeBody, requestedName: string): Record<string, unknown>;
41
+ /**
42
+ * Why the server would refuse to load this doctype, or `undefined` when it would load it. The save writes nothing the
43
+ * server's load check refuses: that check is `validateDoctype`, and the builder's own checks cover only parts of it.
44
+ */
45
+ export declare function saveRefusal(doctype: Record<string, unknown>): string | undefined;
@@ -1,4 +1,4 @@
1
- import { stripFieldKind } from "@stonecrop/schema";
1
+ import { flattenFields, normalizeFieldKind, stripFieldKind, validateDoctype } from "@stonecrop/schema";
2
2
  export function orderKeysByReference(next, reference) {
3
3
  if (!next || !reference) return next;
4
4
  const result = {};
@@ -29,6 +29,37 @@ export function mergeSavedDoctype(existing, body, requestedName) {
29
29
  if (typeof doctypeData.name !== "string" || doctypeData.name.length === 0) {
30
30
  doctypeData.name = requestedName;
31
31
  }
32
+ if (isRecord(existing.defaults)) {
33
+ doctypeData.defaults = carryFieldEdits(existing.defaults, existing.fields, body);
34
+ }
32
35
  delete doctypeData.schema;
33
36
  return doctypeData;
34
37
  }
38
+ function carryFieldEdits(defaults, fieldsOnDisk, body) {
39
+ const renamed = body.renamedFields ?? {};
40
+ const renamedTo = new Set(Object.values(renamed));
41
+ const before = recordKeys(fieldsOnDisk);
42
+ const after = recordKeys(body.fields);
43
+ const carried = {};
44
+ for (const [key, value] of Object.entries(defaults)) {
45
+ const target = Object.hasOwn(renamed, key) ? renamed[key] : void 0;
46
+ if (target !== void 0) carried[target] = value;
47
+ else if (renamedTo.has(key) || before.has(key) && !after.has(key)) continue;
48
+ else carried[key] = value;
49
+ }
50
+ return carried;
51
+ }
52
+ function recordKeys(fields) {
53
+ if (!Array.isArray(fields)) return /* @__PURE__ */ new Set();
54
+ const normalized = fields.map(normalizeFieldKind);
55
+ return new Set(flattenFields(normalized).map((f) => f.fieldname));
56
+ }
57
+ function isRecord(value) {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+ export function saveRefusal(doctype) {
61
+ const result = validateDoctype(doctype);
62
+ if (result.success) return void 0;
63
+ const problems = result.errors.map((e) => e.path.length > 0 ? `${e.path.join(".")}: ${e.message}` : e.message);
64
+ return `Not saved, because the server would refuse to load this doctype. ${problems.join("; ")}`;
65
+ }
@@ -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 { mergeSavedDoctype } from "./mergeDoctype.js";
6
+ import { mergeSavedDoctype, saveRefusal } 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") {
@@ -51,6 +51,10 @@ export default defineEventHandler(async (event) => {
51
51
  }
52
52
  }
53
53
  const doctypeData = mergeSavedDoctype(existing, body, requested);
54
+ const refusal = saveRefusal(doctypeData);
55
+ if (refusal) {
56
+ throw createError({ status: 422, message: refusal });
57
+ }
54
58
  try {
55
59
  await writeFile(filePath, JSON.stringify(doctypeData, null, " ") + "\n", "utf-8");
56
60
  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.39.0",
3
+ "version": "0.41.0",
4
4
  "description": "Nuxt module for Stonecrop",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -60,18 +60,18 @@
60
60
  "pathe": "^2.0.3",
61
61
  "pinia": "^3.0.4",
62
62
  "prompts": "^2.4.2",
63
- "@stonecrop/aform": "0.39.0",
64
- "@stonecrop/atable": "0.39.0",
65
- "@stonecrop/casl-middleware": "0.39.0",
66
- "@stonecrop/code-editor": "0.39.0",
67
- "@stonecrop/desktop": "0.39.0",
68
- "@stonecrop/graphql-middleware": "0.39.0",
69
- "@stonecrop/node-editor": "0.39.0",
70
- "@stonecrop/graphql-client": "0.39.0",
71
- "@stonecrop/schema": "0.39.0",
72
- "@stonecrop/stonecrop": "0.39.0",
73
- "@stonecrop/nuxt-grafserv": "0.39.0",
74
- "@stonecrop/themes": "0.39.0"
63
+ "@stonecrop/aform": "0.41.0",
64
+ "@stonecrop/atable": "0.41.0",
65
+ "@stonecrop/code-editor": "0.41.0",
66
+ "@stonecrop/graphql-client": "0.41.0",
67
+ "@stonecrop/casl-middleware": "0.41.0",
68
+ "@stonecrop/desktop": "0.41.0",
69
+ "@stonecrop/graphql-middleware": "0.41.0",
70
+ "@stonecrop/node-editor": "0.41.0",
71
+ "@stonecrop/nuxt-grafserv": "0.41.0",
72
+ "@stonecrop/stonecrop": "0.41.0",
73
+ "@stonecrop/schema": "0.41.0",
74
+ "@stonecrop/themes": "0.41.0"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@eslint/js": "^10.0.1",
package/src/cli/index.ts CHANGED
@@ -162,8 +162,8 @@ export async function runInstaller(options: InstallerOptions): Promise<void> {
162
162
 
163
163
  if (selectedFeatures.doctypes) {
164
164
  consola.info(`${stepNum}. Customize your doctypes:`)
165
- consola.info(` - doctypes/Project.json`)
166
- consola.info(` - doctypes/Task.json`)
165
+ consola.info(` - doctypes/project.json`)
166
+ consola.info(` - doctypes/task.json`)
167
167
  console.log()
168
168
  }
169
169
 
@@ -4,11 +4,22 @@
4
4
  */
5
5
 
6
6
  import { existsSync } from 'node:fs'
7
- import { mkdir, writeFile } from 'node:fs/promises'
7
+ import { mkdir, readdir, writeFile } from 'node:fs/promises'
8
8
  import { join } from 'pathe'
9
9
  import consola from 'consola'
10
10
  import { loadTemplate } from '../utils/templates'
11
11
 
12
+ const SAMPLE_DOCTYPES = ['project.json', 'task.json']
13
+
14
+ /**
15
+ * The file in `dir` named `fileName` in any letter case. An app scaffolded when the samples were `Project.json` and
16
+ * `Task.json` already has them, and a second copy under the new name would declare each doctype twice.
17
+ */
18
+ async function findInAnyCase(dir: string, fileName: string): Promise<string | undefined> {
19
+ const files = await readdir(dir).catch(() => [] as string[])
20
+ return files.find(file => file.toLowerCase() === fileName.toLowerCase())
21
+ }
22
+
12
23
  export interface DoctypesInstallerOptions {
13
24
  cwd: string
14
25
  }
@@ -30,24 +41,14 @@ export async function installDoctypes(options: DoctypesInstallerOptions): Promis
30
41
  consola.info('Created doctypes/ directory')
31
42
  }
32
43
 
33
- // Scaffold Project.json
34
- const projectPath = join(doctypesDir, 'Project.json')
35
- if (!existsSync(projectPath)) {
36
- const projectTemplate = await loadTemplate('Project.json')
37
- await writeFile(projectPath, projectTemplate, 'utf-8')
38
- consola.info('Created doctypes/Project.json')
39
- } else {
40
- consola.info('doctypes/Project.json already exists, skipping')
41
- }
42
-
43
- // Scaffold Task.json
44
- const taskPath = join(doctypesDir, 'Task.json')
45
- if (!existsSync(taskPath)) {
46
- const taskTemplate = await loadTemplate('Task.json')
47
- await writeFile(taskPath, taskTemplate, 'utf-8')
48
- consola.info('Created doctypes/Task.json')
49
- } else {
50
- consola.info('doctypes/Task.json already exists, skipping')
44
+ for (const fileName of SAMPLE_DOCTYPES) {
45
+ const existing = await findInAnyCase(doctypesDir, fileName)
46
+ if (existing) {
47
+ consola.info(`doctypes/${existing} already exists, skipping`)
48
+ continue
49
+ }
50
+ await writeFile(join(doctypesDir, fileName), await loadTemplate(fileName), 'utf-8')
51
+ consola.info(`Created doctypes/${fileName}`)
51
52
  }
52
53
 
53
54
  consola.success('Sample doctypes created successfully')
@@ -24,8 +24,7 @@
24
24
  "fieldname": "status",
25
25
  "label": "Status",
26
26
  "component": "ADropdown",
27
- "options": ["Active", "Archived"],
28
- "default": "Active"
27
+ "options": ["Active", "Archived"]
29
28
  },
30
29
  {
31
30
  "fieldname": "createdAt",
@@ -34,6 +33,9 @@
34
33
  "mode": "display"
35
34
  }
36
35
  ],
36
+ "defaults": {
37
+ "status": "Active"
38
+ },
37
39
  "workflow": {
38
40
  "states": ["Active", "Archived"],
39
41
  "actions": {
@@ -26,8 +26,7 @@
26
26
  "fieldname": "status",
27
27
  "label": "Status",
28
28
  "component": "ADropdown",
29
- "options": ["Todo", "In Progress", "Done"],
30
- "default": "Todo"
29
+ "options": ["Todo", "In Progress", "Done"]
31
30
  },
32
31
  {
33
32
  "fieldname": "description",
@@ -46,6 +45,9 @@
46
45
  "mode": "display"
47
46
  }
48
47
  ],
48
+ "defaults": {
49
+ "status": "Todo"
50
+ },
49
51
  "workflow": {
50
52
  "states": ["Todo", "In Progress", "Done"],
51
53
  "actions": {