@stonecrop/nuxt 0.13.12 → 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.
- package/README.md +12 -19
- package/bin/init.mjs +4 -1
- package/dist/module.d.mts +10 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +91 -50
- package/dist/runtime/app/components/DocBuilderActionsPanel.d.vue.ts +59 -0
- package/dist/runtime/app/components/DocBuilderActionsPanel.vue +175 -0
- package/dist/runtime/app/components/DocBuilderActionsPanel.vue.d.ts +59 -0
- package/dist/runtime/app/components/DocBuilderFieldsPanel.d.vue.ts +11 -0
- package/dist/runtime/app/components/DocBuilderFieldsPanel.vue +353 -0
- package/dist/runtime/app/components/DocBuilderFieldsPanel.vue.d.ts +11 -0
- package/dist/runtime/app/components/docbuilderActions.d.ts +62 -0
- package/dist/runtime/app/components/docbuilderActions.js +87 -0
- package/dist/runtime/app/composables/useClientAction.d.ts +30 -0
- package/dist/runtime/app/composables/useClientAction.js +51 -0
- package/dist/runtime/app/pages/DocBuilderDetail.vue +116 -71
- package/dist/runtime/app/pages/DocBuilderIndex.vue +83 -22
- package/dist/runtime/server/api/docbuilder/[doctype].get.d.ts +6 -1
- package/dist/runtime/server/api/docbuilder/[doctype].get.js +15 -5
- package/dist/runtime/server/api/docbuilder/doctypes.get.d.ts +5 -1
- package/dist/runtime/server/api/docbuilder/doctypes.get.js +5 -3
- package/dist/runtime/server/api/docbuilder/mergeDoctype.d.ts +11 -0
- package/dist/runtime/server/api/docbuilder/mergeDoctype.js +12 -0
- package/dist/runtime/server/api/docbuilder/save.post.d.ts +4 -1
- package/dist/runtime/server/api/docbuilder/save.post.js +48 -11
- package/dist/runtime/server/api/docbuilder/validate.post.d.ts +2 -1
- package/dist/runtime/server/api/docbuilder/validate.post.js +1 -1
- package/package.json +31 -24
- package/templates/Project.json +4 -10
- package/templates/Task.json +10 -17
- package/templates/resolvers.ts +49 -44
- package/templates/schema.graphql +23 -15
- package/templates/stonecrop.ts +3 -69
|
@@ -1,114 +1,159 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="docbuilder-
|
|
3
|
-
<div v-if="loading"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
2
|
+
<div class="docbuilder-page">
|
|
3
|
+
<div v-if="loading" style="padding: 2rem; text-align: center">Loading...</div>
|
|
4
|
+
|
|
5
|
+
<div v-else>
|
|
6
|
+
<!-- Validation Panel -->
|
|
7
|
+
<div v-if="validationIssues.length > 0 && !warningsDismissed" class="validation-panel">
|
|
8
|
+
<div v-if="errorCount > 0" class="validation-errors">
|
|
9
|
+
<strong>⚠️ {{ errorCount }} Error(s) — Cannot Save</strong>
|
|
10
|
+
<ul>
|
|
11
|
+
<li v-for="(issue, idx) in validationIssues.filter((i) => i.severity === 'error')" :key="`err-${idx}`">
|
|
12
|
+
<code v-if="issue.fieldname">{{ issue.fieldname }}:</code> {{ issue.message }}
|
|
13
|
+
</li>
|
|
14
|
+
</ul>
|
|
15
|
+
</div>
|
|
16
|
+
<div v-if="warningCount > 0" class="validation-warnings">
|
|
17
|
+
<strong>⚡ {{ warningCount }} Warning(s)</strong>
|
|
18
|
+
<button class="dismiss-button" @click="warningsDismissed = true">Dismiss</button>
|
|
14
19
|
</div>
|
|
15
20
|
</div>
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
<AFieldset label="Workflow" :collapsible="true">
|
|
23
|
+
<div class="builder-workflow">
|
|
24
|
+
<StateEditor
|
|
25
|
+
v-if="workflowConfig && workflowConfig.states && workflowConfig.states.length > 0"
|
|
26
|
+
v-model="workflowConfig"
|
|
27
|
+
v-model:layout="layout"
|
|
28
|
+
node-container-class="node-editor" />
|
|
29
|
+
<div v-else class="empty-workflow">
|
|
30
|
+
<p class="empty-workflow-hint">No workflow yet. Name the first state to start building the workflow.</p>
|
|
31
|
+
<div class="empty-workflow-form">
|
|
32
|
+
<input v-model="newStateName" type="text" placeholder="e.g. Draft" @keyup.enter="seedWorkflow" />
|
|
33
|
+
<button class="btn-seed" type="button" :disabled="!newStateName.trim()" @click="seedWorkflow">
|
|
34
|
+
Add first state
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
22
38
|
</div>
|
|
23
|
-
|
|
24
|
-
<li v-for="(error, idx) in validationResult.errors" :key="idx">
|
|
25
|
-
<code>{{ error.path.join(".") || "root" }}</code
|
|
26
|
-
>: {{ error.message }}
|
|
27
|
-
</li>
|
|
28
|
-
</ul>
|
|
29
|
-
</div>
|
|
39
|
+
</AFieldset>
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
<AFieldset label="Actions" :collapsible="true">
|
|
42
|
+
<DocBuilderActionsPanel v-model="workflowConfig" />
|
|
43
|
+
</AFieldset>
|
|
44
|
+
|
|
45
|
+
<AFieldset label="Schema" :collapsible="true">
|
|
46
|
+
<DocBuilderFieldsPanel v-model="fields" />
|
|
47
|
+
</AFieldset>
|
|
48
|
+
|
|
49
|
+
<div v-if="saveMessage" class="builder-actions">
|
|
50
|
+
<span class="save-message" :class="saveMessage.type">{{ saveMessage.text }}</span>
|
|
35
51
|
</div>
|
|
36
52
|
|
|
37
|
-
|
|
38
|
-
<section class="fields-section">
|
|
39
|
-
<h2>Fields ({{ doctype?.schema?.length || 0 }})</h2>
|
|
40
|
-
<div v-for="field in doctype?.schema" :key="field.fieldname" class="field-item">
|
|
41
|
-
<div class="field-header">
|
|
42
|
-
<span class="field-name">{{ field.fieldname }}</span>
|
|
43
|
-
<span class="field-type">{{ field.fieldtype }}</span>
|
|
44
|
-
</div>
|
|
45
|
-
<div class="field-label">{{ field.label }}</div>
|
|
46
|
-
<div v-if="field.required || field.readOnly" class="field-badges">
|
|
47
|
-
<span v-if="field.required" class="badge">Required</span>
|
|
48
|
-
<span v-if="field.readOnly" class="badge">Read Only</span>
|
|
49
|
-
</div>
|
|
50
|
-
</div>
|
|
51
|
-
</section>
|
|
53
|
+
<ActionSet :elements="docbuilderActions" @action-click="handleAction" />
|
|
52
54
|
</div>
|
|
53
55
|
</div>
|
|
54
56
|
</template>
|
|
55
57
|
|
|
56
58
|
<script setup>
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
59
|
+
import { AFieldset } from "@stonecrop/aform";
|
|
60
|
+
import { StateEditor } from "@stonecrop/node-editor";
|
|
61
|
+
import { ActionSet } from "@stonecrop/desktop";
|
|
62
|
+
import { WorkflowMeta } from "@stonecrop/schema";
|
|
63
|
+
import { ref, watch, computed, onMounted } from "vue";
|
|
64
|
+
import { useRoute, useRouter } from "nuxt/app";
|
|
65
|
+
import DocBuilderActionsPanel from "../components/DocBuilderActionsPanel.vue";
|
|
66
|
+
import DocBuilderFieldsPanel from "../components/DocBuilderFieldsPanel.vue";
|
|
59
67
|
const route = useRoute();
|
|
68
|
+
const router = useRouter();
|
|
60
69
|
const doctypeName = computed(() => route.params.doctype);
|
|
61
|
-
const doctype = ref(null);
|
|
62
70
|
const loading = ref(true);
|
|
63
|
-
const validating = ref(false);
|
|
64
71
|
const saving = ref(false);
|
|
65
|
-
const
|
|
72
|
+
const warningsDismissed = ref(false);
|
|
66
73
|
const saveMessage = ref(null);
|
|
74
|
+
const fields = ref([]);
|
|
75
|
+
const workflowConfig = ref();
|
|
76
|
+
const layout = ref({});
|
|
77
|
+
const validationIssues = ref([]);
|
|
78
|
+
const errorCount = computed(() => validationIssues.value.filter((i) => i.severity === "error").length);
|
|
79
|
+
const warningCount = computed(() => validationIssues.value.filter((i) => i.severity === "warning").length);
|
|
67
80
|
onMounted(async () => {
|
|
68
81
|
try {
|
|
69
|
-
const data = await $fetch(`/api/docbuilder/${doctypeName.value}`);
|
|
70
|
-
|
|
82
|
+
const data = await $fetch(`/api/_stonecrop/docbuilder/${doctypeName.value}`);
|
|
83
|
+
fields.value = data.fields ?? [];
|
|
84
|
+
if (data.workflow) {
|
|
85
|
+
const { layout: savedLayout, ...topology } = data.workflow;
|
|
86
|
+
workflowConfig.value = topology;
|
|
87
|
+
if (savedLayout) layout.value = savedLayout;
|
|
88
|
+
}
|
|
71
89
|
} catch (error) {
|
|
72
90
|
console.error("Error loading doctype:", error);
|
|
73
91
|
} finally {
|
|
74
92
|
loading.value = false;
|
|
75
93
|
}
|
|
76
94
|
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
const newStateName = ref("Draft");
|
|
96
|
+
function seedWorkflow() {
|
|
97
|
+
const name = newStateName.value.trim();
|
|
98
|
+
if (!name) return;
|
|
99
|
+
workflowConfig.value = { states: [name], actions: {} };
|
|
100
|
+
}
|
|
101
|
+
function revalidate() {
|
|
102
|
+
const issues = [];
|
|
103
|
+
if (workflowConfig.value) {
|
|
104
|
+
const result = WorkflowMeta.safeParse(workflowConfig.value);
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
for (const issue of result.error.issues) {
|
|
107
|
+
issues.push({
|
|
108
|
+
severity: "error",
|
|
109
|
+
message: issue.message,
|
|
110
|
+
fieldname: issue.path.length > 0 ? issue.path.join(".") : void 0
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
93
114
|
}
|
|
115
|
+
validationIssues.value = issues;
|
|
116
|
+
if (issues.length > 0) warningsDismissed.value = false;
|
|
94
117
|
}
|
|
118
|
+
watch(workflowConfig, revalidate, { deep: true });
|
|
95
119
|
async function saveToDisk() {
|
|
120
|
+
if (errorCount.value > 0) return;
|
|
96
121
|
saving.value = true;
|
|
97
122
|
saveMessage.value = null;
|
|
98
123
|
try {
|
|
99
|
-
await $fetch("/api/docbuilder/save", {
|
|
124
|
+
await $fetch("/api/_stonecrop/docbuilder/save", {
|
|
100
125
|
method: "POST",
|
|
101
|
-
body: {
|
|
126
|
+
body: {
|
|
127
|
+
doctype: doctypeName.value,
|
|
128
|
+
fields: fields.value,
|
|
129
|
+
// Merge the author's node arrangement back into the workflow (WorkflowMeta.layout) so it
|
|
130
|
+
// persists. The layout ref is kept separate in memory (topology-only workflowConfig) and
|
|
131
|
+
// only rejoined here at the I/O boundary; omitted when empty to avoid churn on doctypes
|
|
132
|
+
// that were never manually arranged.
|
|
133
|
+
workflow: workflowConfig.value ? { ...workflowConfig.value, ...Object.keys(layout.value).length > 0 && { layout: layout.value } } : null
|
|
134
|
+
}
|
|
102
135
|
});
|
|
103
|
-
saveMessage.value = { type: "success", text: "
|
|
136
|
+
saveMessage.value = { type: "success", text: "Saved." };
|
|
104
137
|
} catch (error) {
|
|
105
|
-
saveMessage.value = { type: "error", text: error.message || "
|
|
138
|
+
saveMessage.value = { type: "error", text: error.message || "Save failed." };
|
|
106
139
|
} finally {
|
|
107
140
|
saving.value = false;
|
|
108
141
|
}
|
|
109
142
|
}
|
|
143
|
+
const docbuilderActions = computed(() => [
|
|
144
|
+
{
|
|
145
|
+
type: "button",
|
|
146
|
+
label: saving.value ? "Saving\u2026" : "Save",
|
|
147
|
+
action: saveToDisk,
|
|
148
|
+
disabled: saving.value || errorCount.value > 0
|
|
149
|
+
},
|
|
150
|
+
{ type: "button", label: "Back", action: () => void router.push("/docbuilder") }
|
|
151
|
+
]);
|
|
152
|
+
function handleAction(_label, action) {
|
|
153
|
+
if (action) void action();
|
|
154
|
+
}
|
|
110
155
|
</script>
|
|
111
156
|
|
|
112
157
|
<style scoped>
|
|
113
|
-
.docbuilder-
|
|
158
|
+
.docbuilder-page{background:var(--sc-form-background,#fff);box-sizing:border-box;min-height:100vh;padding:2rem}.builder-workflow{min-height:8rem;padding:.5em 1em}:deep(.node-editor){height:40vh;overflow:hidden;width:100%}.empty-workflow{padding:1rem 0}.empty-workflow-hint{color:#9ca3af;font-style:italic;margin:0 0 .75rem}.empty-workflow-form{align-items:center;display:flex;gap:.5rem}.empty-workflow-form input{border:1px solid var(--sc-gray-20,#d1d5db);border-radius:4px;font-family:inherit;font-size:.875rem;padding:.4em .6em}.btn-seed{background:var(--sc-blue-40,#3b82f6);border:none;border-radius:.4rem;color:#fff;cursor:pointer;font-size:.875rem;font-weight:500;padding:.45em 1em}.btn-seed:disabled{cursor:not-allowed;opacity:.5}.validation-panel{margin-bottom:1rem}.validation-errors{background:#fee2e2;border:1px solid #ef4444;border-radius:6px;color:#991b1b;margin-bottom:.5rem;padding:1rem}.validation-warnings{background:#fef9c3;border:1px solid #eab308;border-radius:6px;color:#713f12;padding:1rem}.dismiss-button{background:none;border:1px solid;border-radius:3px;cursor:pointer;font-size:.75rem;margin-left:1rem;padding:.125em .5em}.builder-actions{align-items:center;display:flex;gap:1rem;padding:1rem}.btn-primary{background:var(--sc-blue-40,#3b82f6);border:none;border-radius:.5rem;color:#fff;cursor:pointer;font-weight:500;padding:.5rem 1.5rem}.btn-primary:disabled{cursor:not-allowed;opacity:.6}.save-message{font-size:.875rem}.save-message.success{color:#065f46}.save-message.error{color:#991b1b}
|
|
114
159
|
</style>
|
|
@@ -1,44 +1,105 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="docbuilder-index
|
|
3
|
-
<div class="docbuilder-
|
|
4
|
-
<
|
|
5
|
-
|
|
2
|
+
<div class="docbuilder-index">
|
|
3
|
+
<div class="docbuilder-index-inner">
|
|
4
|
+
<div class="docbuilder-header">
|
|
5
|
+
<h1>DocType Builder</h1>
|
|
6
|
+
<p class="subtitle">Select a DocType to view and edit its schema</p>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="docbuilder-create">
|
|
9
|
+
<input
|
|
10
|
+
v-model="newName"
|
|
11
|
+
type="text"
|
|
12
|
+
placeholder="New doctype name (e.g. Invoice)"
|
|
13
|
+
:disabled="creating"
|
|
14
|
+
@keyup.enter="createDoctype" />
|
|
15
|
+
<button type="button" class="btn-create" :disabled="creating || !newName.trim()" @click="createDoctype">
|
|
16
|
+
{{ creating ? "Creating\u2026" : "+ New DocType" }}
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
<p v-if="createError" class="create-error">{{ createError }}</p>
|
|
20
|
+
<ClientOnly>
|
|
21
|
+
<div v-if="loading" class="loading">Loading doctypes...</div>
|
|
22
|
+
<p v-else-if="!doctypes.length" class="empty">No doctypes yet — create one above.</p>
|
|
23
|
+
<ATable v-else :columns="columns" :rows="doctypes" :config="config" @row:click="handleRowClick" />
|
|
24
|
+
</ClientOnly>
|
|
6
25
|
</div>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
<ATable v-else :columns="columns" :rows="doctypes" :config="config" @row-click="handleRowClick" />
|
|
10
|
-
</ClientOnly>
|
|
26
|
+
|
|
27
|
+
<ActionSet :elements="indexActions" @action-click="handleAction" />
|
|
11
28
|
</div>
|
|
12
29
|
</template>
|
|
13
30
|
|
|
14
31
|
<script setup>
|
|
15
|
-
import {
|
|
32
|
+
import { ActionSet } from "@stonecrop/desktop";
|
|
33
|
+
import { ref, computed, onMounted } from "vue";
|
|
16
34
|
import { useRouter } from "nuxt/app";
|
|
17
35
|
const router = useRouter();
|
|
18
36
|
const doctypes = ref([]);
|
|
19
37
|
const loading = ref(true);
|
|
38
|
+
async function loadDoctypes() {
|
|
39
|
+
try {
|
|
40
|
+
doctypes.value = await $fetch("/api/_stonecrop/docbuilder/doctypes");
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error("Error loading doctypes:", error);
|
|
43
|
+
} finally {
|
|
44
|
+
loading.value = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
onMounted(loadDoctypes);
|
|
20
48
|
const columns = [
|
|
21
|
-
{ label: "Name", name: "name",
|
|
22
|
-
{ label: "Fields", name: "fieldCount",
|
|
49
|
+
{ label: "Name", name: "name", component: "ATextInput", width: "20ch" },
|
|
50
|
+
{ label: "Fields", name: "fieldCount", component: "ANumericInput", width: "10ch" }
|
|
23
51
|
];
|
|
24
52
|
const config = {
|
|
25
|
-
view: "uncounted"
|
|
53
|
+
view: "uncounted",
|
|
54
|
+
clickable: true
|
|
26
55
|
};
|
|
27
|
-
|
|
56
|
+
function handleRowClick({ row }) {
|
|
57
|
+
void router.push(`/docbuilder/${row.slug}`);
|
|
58
|
+
}
|
|
59
|
+
const newName = ref("");
|
|
60
|
+
const createError = ref("");
|
|
61
|
+
const creating = ref(false);
|
|
62
|
+
const DOCTYPE_NAME = /^[A-Z][\w-]*$/i;
|
|
63
|
+
async function createDoctype() {
|
|
64
|
+
const name = newName.value.trim();
|
|
65
|
+
if (!name) return;
|
|
66
|
+
if (!DOCTYPE_NAME.test(name)) {
|
|
67
|
+
createError.value = "Start with a letter; use only letters, numbers, hyphens, or underscores.";
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const slug = name.toLowerCase();
|
|
71
|
+
if (doctypes.value.some((d) => String(d.slug).toLowerCase() === slug)) {
|
|
72
|
+
createError.value = `A doctype "${name}" already exists.`;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
createError.value = "";
|
|
76
|
+
creating.value = true;
|
|
28
77
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
78
|
+
await $fetch("/api/_stonecrop/docbuilder/save", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
body: { doctype: name, fields: [], create: true }
|
|
81
|
+
});
|
|
82
|
+
void router.push(`/docbuilder/${slug}`);
|
|
31
83
|
} catch (error) {
|
|
32
|
-
console.error("Error
|
|
33
|
-
|
|
34
|
-
|
|
84
|
+
console.error("Error creating doctype:", error);
|
|
85
|
+
const err = error;
|
|
86
|
+
if (err.statusCode === 409) {
|
|
87
|
+
createError.value = err.data?.message ?? `A doctype "${name}" already exists.`;
|
|
88
|
+
await loadDoctypes();
|
|
89
|
+
} else {
|
|
90
|
+
createError.value = "Failed to create doctype.";
|
|
91
|
+
}
|
|
92
|
+
creating.value = false;
|
|
35
93
|
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
router.push(
|
|
94
|
+
}
|
|
95
|
+
const indexActions = computed(() => [
|
|
96
|
+
{ type: "button", label: "Home", action: () => void router.push("/") }
|
|
97
|
+
]);
|
|
98
|
+
function handleAction(_label, action) {
|
|
99
|
+
if (action) void action();
|
|
39
100
|
}
|
|
40
101
|
</script>
|
|
41
102
|
|
|
42
103
|
<style scoped>
|
|
43
|
-
.docbuilder-index-
|
|
104
|
+
.docbuilder-index{background:var(--sc-form-background,#fff);box-sizing:border-box;min-height:100vh}.docbuilder-index-inner{margin:0 auto;max-width:1200px;padding:2rem}.docbuilder-header{padding:2rem 0 3rem;text-align:center}.docbuilder-header h1{font-size:2.5rem;font-weight:700;margin:0 0 1rem}.subtitle{color:#6b7280;font-size:1.125rem;margin:0}.empty,.loading{color:#6b7280;padding:2rem;text-align:center}.docbuilder-create{display:flex;gap:.5rem;margin-bottom:1rem}.docbuilder-create input{border:1px solid var(--sc-gray-20,#d1d5db);border-radius:4px;flex:1;font:inherit;padding:.5em .75em}.btn-create{background:var(--sc-blue-40,#3b82f6);border:none;border-radius:.4rem;color:#fff;cursor:pointer;font-weight:500;padding:.5em 1.25em;white-space:nowrap}.btn-create:disabled{cursor:not-allowed;opacity:.5}.create-error{color:#b91c1c;font-size:.875rem;margin:-.5rem 0 1rem}
|
|
44
105
|
</style>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { createError, defineEventHandler, getRouterParam
|
|
4
|
+
import { createError, defineEventHandler, getRouterParam } from "h3";
|
|
5
|
+
import { useRuntimeConfig } from "#imports";
|
|
5
6
|
export default defineEventHandler(async (event) => {
|
|
6
7
|
const doctype = getRouterParam(event, "doctype");
|
|
7
8
|
if (!doctype) {
|
|
@@ -12,13 +13,21 @@ export default defineEventHandler(async (event) => {
|
|
|
12
13
|
}
|
|
13
14
|
const config = useRuntimeConfig();
|
|
14
15
|
const doctypesDir = config.stonecrop?.doctypesDir || resolve(process.cwd(), "doctypes");
|
|
15
|
-
const
|
|
16
|
-
if (!
|
|
16
|
+
const exactPath = resolve(doctypesDir, `${doctype}.json`);
|
|
17
|
+
if (!exactPath.startsWith(doctypesDir + "/")) {
|
|
17
18
|
throw createError({
|
|
18
19
|
status: 400,
|
|
19
20
|
message: "Invalid doctype name"
|
|
20
21
|
});
|
|
21
22
|
}
|
|
23
|
+
let filePath = exactPath;
|
|
24
|
+
if (!existsSync(filePath)) {
|
|
25
|
+
const files = await readdir(doctypesDir).catch(() => []);
|
|
26
|
+
const match = files.find((f) => f.toLowerCase() === `${doctype.toLowerCase()}.json`);
|
|
27
|
+
if (match) {
|
|
28
|
+
filePath = resolve(doctypesDir, match);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
22
31
|
if (!existsSync(filePath)) {
|
|
23
32
|
throw createError({
|
|
24
33
|
status: 404,
|
|
@@ -31,7 +40,8 @@ export default defineEventHandler(async (event) => {
|
|
|
31
40
|
return {
|
|
32
41
|
name: doctype.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "),
|
|
33
42
|
slug: doctype,
|
|
34
|
-
|
|
43
|
+
fields: data.fields ?? [],
|
|
44
|
+
workflow: data.workflow ?? null
|
|
35
45
|
};
|
|
36
46
|
} catch (error) {
|
|
37
47
|
throw createError({
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { readFile, readdir } from "node:fs/promises";
|
|
3
3
|
import { extname, resolve } from "node:path";
|
|
4
|
-
import { defineEventHandler
|
|
4
|
+
import { defineEventHandler } from "h3";
|
|
5
|
+
import { useRuntimeConfig } from "#imports";
|
|
5
6
|
export default defineEventHandler(async (_event) => {
|
|
6
7
|
const config = useRuntimeConfig();
|
|
7
8
|
const doctypesDir = config.stonecrop?.doctypesDir || resolve(process.cwd(), "doctypes");
|
|
@@ -17,10 +18,11 @@ export default defineEventHandler(async (_event) => {
|
|
|
17
18
|
const content = await readFile(filePath, "utf-8");
|
|
18
19
|
const data = JSON.parse(content);
|
|
19
20
|
const name = file.replace(".json", "");
|
|
21
|
+
const slug = name.toLowerCase();
|
|
20
22
|
return {
|
|
21
23
|
name: name.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "),
|
|
22
|
-
slug
|
|
23
|
-
fieldCount:
|
|
24
|
+
slug,
|
|
25
|
+
fieldCount: data.fields?.length || 0
|
|
24
26
|
};
|
|
25
27
|
})
|
|
26
28
|
);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reorder `next`'s keys to follow `reference`'s key order, appending any keys not present in
|
|
3
|
+
* `reference` (in their original `next` order) and dropping reference-only keys.
|
|
4
|
+
*
|
|
5
|
+
* The docbuilder rebuilds the workflow.actions map on every graph edit (node_editor emits transition
|
|
6
|
+
* actions first, then stateless ones), which reshuffles a doctype's action order — e.g. a leading
|
|
7
|
+
* `save` command jumps below the transitions — producing a spurious diff on the first save even when
|
|
8
|
+
* only a node was dragged. Users can't reorder actions in the builder, so the on-disk order is always
|
|
9
|
+
* the intended one: re-imposing it keeps saves byte-stable while still honouring adds/removes.
|
|
10
|
+
*/
|
|
11
|
+
export declare function orderKeysByReference<T>(next: Record<string, T> | undefined, reference: Record<string, T> | undefined): Record<string, T> | undefined;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function orderKeysByReference(next, reference) {
|
|
2
|
+
if (!next || !reference) return next;
|
|
3
|
+
const result = {};
|
|
4
|
+
for (const key of Object.keys(reference)) {
|
|
5
|
+
const value = next[key];
|
|
6
|
+
if (value !== void 0) result[key] = value;
|
|
7
|
+
}
|
|
8
|
+
for (const [key, value] of Object.entries(next)) {
|
|
9
|
+
if (!Object.prototype.hasOwnProperty.call(result, key)) result[key] = value;
|
|
10
|
+
}
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
|
+
import { createError, defineEventHandler, readBody } from "h3";
|
|
5
|
+
import { useRuntimeConfig } from "#imports";
|
|
6
|
+
import { orderKeysByReference } from "./mergeDoctype.js";
|
|
4
7
|
export default defineEventHandler(async (event) => {
|
|
5
8
|
const body = await readBody(event);
|
|
6
9
|
if (!body.doctype || typeof body.doctype !== "string") {
|
|
@@ -9,28 +12,62 @@ export default defineEventHandler(async (event) => {
|
|
|
9
12
|
message: "Missing or invalid doctype name"
|
|
10
13
|
});
|
|
11
14
|
}
|
|
12
|
-
if (!body.
|
|
15
|
+
if (!body.fields || !Array.isArray(body.fields)) {
|
|
13
16
|
throw createError({
|
|
14
17
|
status: 400,
|
|
15
|
-
message: "Missing or invalid
|
|
18
|
+
message: "Missing or invalid fields array"
|
|
16
19
|
});
|
|
17
20
|
}
|
|
18
21
|
const config = useRuntimeConfig();
|
|
19
22
|
const doctypesDir = config.stonecrop?.doctypesDir || resolve(process.cwd(), "doctypes");
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
23
|
+
const requested = body.doctype;
|
|
24
|
+
const exactPath = resolve(doctypesDir, `${requested}.json`);
|
|
25
|
+
if (!exactPath.startsWith(doctypesDir + "/")) {
|
|
23
26
|
throw createError({
|
|
24
27
|
status: 400,
|
|
25
28
|
message: "Invalid doctype name"
|
|
26
29
|
});
|
|
27
30
|
}
|
|
31
|
+
let filePath = exactPath;
|
|
32
|
+
if (!existsSync(filePath)) {
|
|
33
|
+
const files = await readdir(doctypesDir).catch(() => []);
|
|
34
|
+
const match = files.find((f) => f.toLowerCase() === `${requested.toLowerCase()}.json`);
|
|
35
|
+
if (match) {
|
|
36
|
+
filePath = resolve(doctypesDir, match);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (body.create === true && existsSync(filePath)) {
|
|
40
|
+
throw createError({
|
|
41
|
+
status: 409,
|
|
42
|
+
message: `A doctype named "${requested}" already exists.`
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
let existing = {};
|
|
46
|
+
if (existsSync(filePath)) {
|
|
47
|
+
try {
|
|
48
|
+
const content = await readFile(filePath, "utf-8");
|
|
49
|
+
existing = JSON.parse(content);
|
|
50
|
+
} catch {
|
|
51
|
+
}
|
|
52
|
+
}
|
|
28
53
|
const doctypeData = {
|
|
29
|
-
|
|
54
|
+
...existing,
|
|
55
|
+
fields: body.fields
|
|
30
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;
|
|
31
68
|
try {
|
|
32
|
-
await writeFile(filePath, JSON.stringify(doctypeData, null, " "), "utf-8");
|
|
33
|
-
return { success: true, path: `doctypes/${
|
|
69
|
+
await writeFile(filePath, JSON.stringify(doctypeData, null, " ") + "\n", "utf-8");
|
|
70
|
+
return { success: true, path: `doctypes/${basename(filePath)}` };
|
|
34
71
|
} catch (error) {
|
|
35
72
|
throw createError({
|
|
36
73
|
status: 500,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { validateField } from "@stonecrop/schema";
|
|
2
|
-
import { createError, defineEventHandler, readBody } from "
|
|
2
|
+
import { createError, defineEventHandler, readBody } from "h3";
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
const body = await readBody(event);
|
|
5
5
|
if (!body.fields || !Array.isArray(body.fields)) {
|