@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 +1 -1
- package/dist/runtime/app/components/DocBuilderFieldsPanel.vue +4 -8
- package/dist/runtime/app/components/docbuilderFields.d.ts +8 -0
- package/dist/runtime/app/components/docbuilderFields.js +26 -0
- package/dist/runtime/app/pages/DocBuilderDetail.vue +10 -3
- package/dist/runtime/server/api/docbuilder/mergeDoctype.d.ts +9 -0
- package/dist/runtime/server/api/docbuilder/mergeDoctype.js +32 -1
- package/dist/runtime/server/api/docbuilder/save.post.js +5 -1
- package/package.json +13 -13
- package/src/cli/index.ts +2 -2
- package/src/cli/installers/doctypes.ts +20 -19
- package/templates/{Project.json → project.json} +4 -2
- package/templates/{Task.json → task.json} +4 -2
package/dist/module.json
CHANGED
|
@@ -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(
|
|
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 =
|
|
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:
|
|
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.
|
|
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.
|
|
64
|
-
"@stonecrop/atable": "0.
|
|
65
|
-
"@stonecrop/
|
|
66
|
-
"@stonecrop/
|
|
67
|
-
"@stonecrop/
|
|
68
|
-
"@stonecrop/
|
|
69
|
-
"@stonecrop/
|
|
70
|
-
"@stonecrop/
|
|
71
|
-
"@stonecrop/
|
|
72
|
-
"@stonecrop/stonecrop": "0.
|
|
73
|
-
"@stonecrop/
|
|
74
|
-
"@stonecrop/themes": "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/
|
|
166
|
-
consola.info(` - doctypes/
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
consola.info(
|
|
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": {
|