@jskit-ai/crud-ui-generator 0.1.1

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.
@@ -0,0 +1,103 @@
1
+ <template>
2
+ <section class="ui-generator-list-element d-flex flex-column ga-4">
3
+ <v-card rounded="lg" elevation="1" border>
4
+ <v-card-item>
5
+ <div class="d-flex align-center ga-3 flex-wrap w-100">
6
+ <div>
7
+ <v-card-title class="px-0">${option:namespace|plural|pascal}</v-card-title>
8
+ <v-card-subtitle class="px-0">Manage ${option:namespace|plural|default(records)}.</v-card-subtitle>
9
+ </div>
10
+ <v-spacer />
11
+ <v-btn variant="outlined" :loading="records.isFetching.value" @click="records.reload">Refresh</v-btn>
12
+ <v-btn v-if="UI_NEW_URL" color="primary" :to="records.resolveParams(UI_NEW_URL)">New ${option:namespace|singular|default(record)}</v-btn>
13
+ </div>
14
+ </v-card-item>
15
+ <v-divider />
16
+ <v-card-text class="pt-4">
17
+ <template v-if="records.showListSkeleton.value">
18
+ <v-skeleton-loader type="text@2, list-item-two-line@5" />
19
+ </template>
20
+ <template v-else>
21
+ <v-progress-linear v-if="records.isRefetching.value" indeterminate class="mb-3" />
22
+
23
+ <div v-if="records.items.value.length < 1" class="text-center py-6 text-medium-emphasis">
24
+ No records yet.
25
+ </div>
26
+ <v-table v-else density="comfortable">
27
+ <thead>
28
+ <tr>
29
+ __JSKIT_UI_LIST_HEADER_COLUMNS__
30
+ <th v-if="UI_VIEW_URL" class="text-right">Open</th>
31
+ <th v-if="UI_EDIT_URL" class="text-right">Edit</th>
32
+ </tr>
33
+ </thead>
34
+ <tbody>
35
+ <tr v-for="(record, index) in records.items.value" :key="records.resolveRowKey(record, index)">
36
+ __JSKIT_UI_LIST_ROW_COLUMNS__
37
+ <td v-if="UI_VIEW_URL" class="text-right">
38
+ <v-btn
39
+ size="small"
40
+ variant="text"
41
+ :to="records.resolveViewUrl(record)"
42
+ :disabled="!records.resolveViewUrl(record)"
43
+ >
44
+ Open
45
+ </v-btn>
46
+ </td>
47
+ <td v-if="UI_EDIT_URL" class="text-right">
48
+ <v-btn
49
+ size="small"
50
+ variant="text"
51
+ :to="records.resolveEditUrl(record)"
52
+ :disabled="!records.resolveEditUrl(record)"
53
+ >
54
+ Edit
55
+ </v-btn>
56
+ </td>
57
+ </tr>
58
+ </tbody>
59
+ </v-table>
60
+
61
+ <div v-if="records.hasMore.value" class="d-flex justify-center pt-4">
62
+ <v-btn variant="text" :loading="records.isLoadingMore.value" @click="records.loadMore">Load more</v-btn>
63
+ </div>
64
+ </template>
65
+ </v-card-text>
66
+ </v-card>
67
+ </section>
68
+ </template>
69
+
70
+ <script setup>
71
+ import { useList } from "@jskit-ai/users-web/client/composables/useList";
72
+
73
+ const UI_OPERATION_ADAPTER = null;
74
+ const UI_RECORD_ID_PARAM = "${option:id-param|trim}";
75
+ const UI_LIST_API_URL = "${option:api-path|trim}";
76
+ const UI_VIEW_URL = __JSKIT_UI_HAS_VIEW_ROUTE__ ? `./:${UI_RECORD_ID_PARAM}` : "";
77
+ const UI_EDIT_URL = __JSKIT_UI_HAS_EDIT_ROUTE__ ? `./:${UI_RECORD_ID_PARAM}/edit` : "";
78
+ const UI_NEW_URL = __JSKIT_UI_HAS_NEW_ROUTE__ ? "./new" : "";
79
+ const UI_RECORD_CHANGED_EVENT = __JSKIT_UI_RECORD_CHANGED_EVENT__;
80
+
81
+ const records = useList({
82
+ adapter: UI_OPERATION_ADAPTER || undefined,
83
+ apiSuffix: UI_LIST_API_URL,
84
+ queryKeyFactory: (surfaceId = "", workspaceSlug = "") => [
85
+ "ui-generator",
86
+ "${option:namespace|kebab}",
87
+ "list",
88
+ String(surfaceId || ""),
89
+ String(workspaceSlug || "")
90
+ ],
91
+ placementSource: "ui-generator.${option:namespace|kebab}.list",
92
+ fallbackLoadError: "Unable to load records.",
93
+ recordIdParam: UI_RECORD_ID_PARAM,
94
+ recordIdSelector: (item = {}) => __JSKIT_UI_LIST_RECORD_ID_EXPR__,
95
+ viewUrlTemplate: UI_VIEW_URL,
96
+ editUrlTemplate: UI_EDIT_URL,
97
+ realtime: UI_RECORD_CHANGED_EVENT
98
+ ? {
99
+ event: UI_RECORD_CHANGED_EVENT
100
+ }
101
+ : null
102
+ });
103
+ </script>
@@ -0,0 +1,114 @@
1
+ <template>
2
+ <section class="ui-generator-new-element d-flex flex-column ga-4">
3
+ <v-card rounded="lg" elevation="1" border>
4
+ <v-card-item>
5
+ <div class="d-flex align-center ga-3 flex-wrap w-100">
6
+ <div>
7
+ <v-card-title class="px-0">New ${option:namespace|singular|pascal|default(Record)}</v-card-title>
8
+ <v-card-subtitle class="px-0">Create a new ${option:namespace|singular|default(record)}.</v-card-subtitle>
9
+ </div>
10
+ <v-spacer />
11
+ <v-btn v-if="UI_LIST_URL" variant="text" :to="formRuntime.addEdit.resolveParams(UI_LIST_URL)">Cancel</v-btn>
12
+ <v-btn
13
+ color="primary"
14
+ :loading="formRuntime.addEdit.isSaving"
15
+ :disabled="
16
+ formRuntime.addEdit.isInitialLoading ||
17
+ formRuntime.addEdit.isRefetching ||
18
+ !formRuntime.addEdit.canSave
19
+ "
20
+ @click="formRuntime.addEdit.submit"
21
+ >
22
+ Save ${option:namespace|singular|default(record)}
23
+ </v-btn>
24
+ </div>
25
+ </v-card-item>
26
+ <v-divider />
27
+ <v-card-text class="pt-4">
28
+ <p v-if="formRuntime.addEdit.loadError" class="text-body-2 text-medium-emphasis mb-0">
29
+ {{ formRuntime.addEdit.loadError }}
30
+ </p>
31
+ <v-form v-else @submit.prevent="formRuntime.addEdit.submit" novalidate>
32
+ <v-row>
33
+ <v-col v-for="field in formRuntime.formFields" :key="field.key" cols="12" md="6">
34
+ <v-switch
35
+ v-if="field.component === 'switch'"
36
+ v-model="formRuntime.form[field.key]"
37
+ :label="field.label"
38
+ color="primary"
39
+ hide-details="auto"
40
+ :disabled="
41
+ !formRuntime.addEdit.canSave ||
42
+ formRuntime.addEdit.isSaving ||
43
+ formRuntime.addEdit.isRefetching
44
+ "
45
+ :error-messages="formRuntime.resolveFieldErrors(field.key)"
46
+ />
47
+ <v-text-field
48
+ v-else
49
+ v-model="formRuntime.form[field.key]"
50
+ :label="field.label"
51
+ :type="field.inputType"
52
+ variant="outlined"
53
+ density="comfortable"
54
+ :maxlength="field.maxLength || undefined"
55
+ :readonly="
56
+ !formRuntime.addEdit.canSave ||
57
+ formRuntime.addEdit.isSaving ||
58
+ formRuntime.addEdit.isRefetching
59
+ "
60
+ :error-messages="formRuntime.resolveFieldErrors(field.key)"
61
+ />
62
+ </v-col>
63
+ </v-row>
64
+ </v-form>
65
+ </v-card-text>
66
+ </v-card>
67
+ </section>
68
+ </template>
69
+
70
+ <script setup>
71
+ import { useCrudSchemaForm } from "@jskit-ai/users-web/client/composables/useCrudSchemaForm";
72
+ import { ${option:resource-export|trim} as uiResource } from "/${option:resource-file|trim}";
73
+
74
+ const UI_OPERATION_ADAPTER = null;
75
+ const UI_RECORD_ID_PARAM = "${option:id-param|trim}";
76
+ const UI_CREATE_API_URL = "${option:api-path|trim}";
77
+ const UI_LIST_URL = __JSKIT_UI_HAS_LIST_ROUTE__ ? ".." : "";
78
+ const UI_VIEW_URL = __JSKIT_UI_HAS_VIEW_ROUTE__ ? `../:${UI_RECORD_ID_PARAM}` : "";
79
+ const UI_RECORD_CHANGED_EVENT = __JSKIT_UI_RECORD_CHANGED_EVENT__;
80
+ const UI_CREATE_FORM_FIELDS = Object.freeze(__JSKIT_UI_CREATE_FORM_FIELDS__);
81
+
82
+ const formRuntime = useCrudSchemaForm({
83
+ resource: uiResource,
84
+ operationName: "create",
85
+ formFields: UI_CREATE_FORM_FIELDS,
86
+ addEditOptions: {
87
+ adapter: UI_OPERATION_ADAPTER || undefined,
88
+ apiSuffix: UI_CREATE_API_URL,
89
+ queryKeyFactory: (surfaceId = "", workspaceSlug = "") => [
90
+ "ui-generator",
91
+ "${option:namespace|kebab}",
92
+ "create",
93
+ String(surfaceId || ""),
94
+ String(workspaceSlug || "")
95
+ ],
96
+ placementSource: "ui-generator.${option:namespace|kebab}.new",
97
+ readEnabled: false,
98
+ writeMethod: "POST",
99
+ fallbackSaveError: "Unable to save record.",
100
+ recordIdParam: UI_RECORD_ID_PARAM,
101
+ viewUrlTemplate: UI_VIEW_URL,
102
+ listUrlTemplate: UI_LIST_URL,
103
+ realtime: UI_RECORD_CHANGED_EVENT
104
+ ? {
105
+ event: UI_RECORD_CHANGED_EVENT
106
+ }
107
+ : null
108
+ },
109
+ saveSuccess: {
110
+ invalidateQueryKey: ["ui-generator", "${option:namespace|kebab}"],
111
+ listUrlTemplate: UI_LIST_URL
112
+ }
113
+ });
114
+ </script>
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <section class="ui-generator-view-element d-flex flex-column ga-4">
3
+ <v-card rounded="lg" elevation="1" border>
4
+ <v-card-item>
5
+ <div class="d-flex align-center ga-3 flex-wrap w-100">
6
+ <div>
7
+ <v-card-title class="px-0">${option:namespace|singular|pascal|default(Record)}</v-card-title>
8
+ <v-card-subtitle class="px-0">View and manage this ${option:namespace|singular|default(record)}.</v-card-subtitle>
9
+ </div>
10
+ <v-spacer />
11
+ <v-btn v-if="UI_LIST_URL" variant="text" :to="view.resolveParams(UI_LIST_URL)">Back to ${option:namespace|plural|default(records)}</v-btn>
12
+ <v-btn v-if="UI_EDIT_URL" color="primary" variant="outlined" :to="view.resolveParams(UI_EDIT_URL)">Edit</v-btn>
13
+ </div>
14
+ </v-card-item>
15
+ <v-divider />
16
+ <v-card-text class="pt-4">
17
+ <div v-if="view.loadError.value || view.isNotFound.value" class="text-body-2 text-medium-emphasis py-2">
18
+ Record unavailable.
19
+ </div>
20
+
21
+ <template v-else-if="view.isLoading.value">
22
+ <v-skeleton-loader type="text@2, list-item-two-line@5" />
23
+ </template>
24
+
25
+ <template v-else>
26
+ <v-progress-linear v-if="view.isRefetching.value" indeterminate class="mb-4" />
27
+ <v-row>
28
+ __JSKIT_UI_VIEW_COLUMNS__
29
+ </v-row>
30
+ </template>
31
+ </v-card-text>
32
+ </v-card>
33
+ </section>
34
+ </template>
35
+
36
+ <script setup>
37
+ import { useView } from "@jskit-ai/users-web/client/composables/useView";
38
+
39
+ const UI_OPERATION_ADAPTER = null;
40
+ const UI_RECORD_ID_PARAM = "${option:id-param|trim}";
41
+ const UI_API_BASE_URL = "${option:api-path|trim}";
42
+ const UI_VIEW_API_URL = `${UI_API_BASE_URL}/:${UI_RECORD_ID_PARAM}`;
43
+ const UI_LIST_URL = __JSKIT_UI_HAS_LIST_ROUTE__ ? ".." : "";
44
+ const UI_EDIT_URL = __JSKIT_UI_HAS_EDIT_ROUTE__ ? "./edit" : "";
45
+ const UI_RECORD_CHANGED_EVENT = __JSKIT_UI_RECORD_CHANGED_EVENT__;
46
+
47
+ const view = useView({
48
+ adapter: UI_OPERATION_ADAPTER || undefined,
49
+ apiUrlTemplate: UI_VIEW_API_URL,
50
+ recordIdParam: UI_RECORD_ID_PARAM,
51
+ includeRecordIdInQueryKey: true,
52
+ queryKeyFactory: (surfaceId = "", workspaceSlug = "") => [
53
+ "ui-generator",
54
+ "${option:namespace|kebab}",
55
+ "view",
56
+ String(surfaceId || ""),
57
+ String(workspaceSlug || "")
58
+ ],
59
+ placementSource: "ui-generator.${option:namespace|kebab}.view",
60
+ fallbackLoadError: "Unable to load record.",
61
+ notFoundMessage: "Record not found.",
62
+ listUrlTemplate: UI_LIST_URL,
63
+ editUrlTemplate: UI_EDIT_URL,
64
+ realtime: UI_RECORD_CHANGED_EVENT
65
+ ? {
66
+ event: UI_RECORD_CHANGED_EVENT
67
+ }
68
+ : null
69
+ });
70
+
71
+ </script>
@@ -0,0 +1,331 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { buildUiTemplateContext } from "../src/server/buildTemplateContext.js";
7
+
8
+ async function withTempApp(run) {
9
+ const appRoot = await mkdtemp(path.join(tmpdir(), "ui-generator-app-"));
10
+ try {
11
+ return await run(appRoot);
12
+ } finally {
13
+ await rm(appRoot, { recursive: true, force: true });
14
+ }
15
+ }
16
+
17
+ async function writeResource(appRoot, relativeFile, source) {
18
+ const absoluteFile = path.join(appRoot, relativeFile);
19
+ await mkdir(path.dirname(absoluteFile), { recursive: true });
20
+ await writeFile(absoluteFile, source, "utf8");
21
+ }
22
+
23
+ const FULL_RESOURCE_SOURCE = `const customerRecordSchema = {
24
+ type: "object",
25
+ properties: {
26
+ id: { type: "integer" },
27
+ firstName: { type: "string" },
28
+ email: { type: "string" },
29
+ vip: { type: "boolean" },
30
+ updatedAt: { type: "string", format: "date-time" }
31
+ },
32
+ additionalProperties: false
33
+ };
34
+
35
+ const customerBodySchema = {
36
+ type: "object",
37
+ properties: {
38
+ firstName: { type: "string", maxLength: 120 },
39
+ email: { type: "string", maxLength: 160 },
40
+ vip: { type: "boolean" }
41
+ },
42
+ additionalProperties: false
43
+ };
44
+
45
+ const customerResource = {
46
+ operations: {
47
+ list: {
48
+ outputValidator: {
49
+ schema: {
50
+ type: "object",
51
+ properties: {
52
+ items: {
53
+ type: "array",
54
+ items: customerRecordSchema
55
+ },
56
+ nextCursor: { type: ["string", "null"] }
57
+ },
58
+ additionalProperties: false
59
+ }
60
+ }
61
+ },
62
+ view: {
63
+ outputValidator: {
64
+ schema: customerRecordSchema
65
+ }
66
+ },
67
+ create: {
68
+ bodyValidator: {
69
+ schema: customerBodySchema
70
+ },
71
+ outputValidator: {
72
+ schema: customerRecordSchema
73
+ }
74
+ },
75
+ patch: {
76
+ bodyValidator: {
77
+ schema: customerBodySchema
78
+ },
79
+ outputValidator: {
80
+ schema: customerRecordSchema
81
+ }
82
+ }
83
+ }
84
+ };
85
+
86
+ export { customerResource };
87
+ `;
88
+
89
+ test("buildUiTemplateContext derives list/view/new/edit placeholders from resource validators", async () => {
90
+ await withTempApp(async (appRoot) => {
91
+ const resourceFile = "packages/customers/src/shared/customerResource.js";
92
+ await writeResource(appRoot, resourceFile, FULL_RESOURCE_SOURCE);
93
+
94
+ const context = await buildUiTemplateContext({
95
+ appRoot,
96
+ options: {
97
+ namespace: "customers-ui",
98
+ "api-path": "/crud/customers",
99
+ operations: "list,view,new,edit",
100
+ "resource-file": resourceFile,
101
+ "resource-export": "customerResource"
102
+ }
103
+ });
104
+
105
+ assert.match(context.__JSKIT_UI_LIST_HEADER_COLUMNS__, /<th>First Name<\/th>/);
106
+ assert.match(context.__JSKIT_UI_LIST_ROW_COLUMNS__, /record\.updatedAt/);
107
+ assert.match(context.__JSKIT_UI_VIEW_COLUMNS__, /view\.record\.value\?\.vip/);
108
+ assert.equal(context.__JSKIT_UI_LIST_RECORD_ID_EXPR__, "item.id");
109
+ assert.equal(context.__JSKIT_UI_RECORD_CHANGED_EVENT__, "\"customers.record.changed\"");
110
+ assert.equal(context.__JSKIT_UI_HAS_LIST_ROUTE__, "true");
111
+ assert.equal(context.__JSKIT_UI_HAS_VIEW_ROUTE__, "true");
112
+ assert.equal(context.__JSKIT_UI_HAS_NEW_ROUTE__, "true");
113
+ assert.equal(context.__JSKIT_UI_HAS_EDIT_ROUTE__, "true");
114
+
115
+ const createFields = JSON.parse(context.__JSKIT_UI_CREATE_FORM_FIELDS__);
116
+ const editFields = JSON.parse(context.__JSKIT_UI_EDIT_FORM_FIELDS__);
117
+ assert.deepEqual(
118
+ createFields.map((field) => field.key),
119
+ ["firstName", "email", "vip"]
120
+ );
121
+ assert.deepEqual(
122
+ editFields.map((field) => field.key),
123
+ ["firstName", "email", "vip"]
124
+ );
125
+ assert.equal(createFields[0].inputType, "text");
126
+ assert.equal(createFields[0].maxLength, 120);
127
+ assert.equal(createFields[2].component, "switch");
128
+ });
129
+ });
130
+
131
+ test('buildUiTemplateContext derives "resource-export" default from resource-file basename', async () => {
132
+ await withTempApp(async (appRoot) => {
133
+ const resourceFile = "packages/customers/src/shared/customerResource.js";
134
+ await writeResource(appRoot, resourceFile, FULL_RESOURCE_SOURCE);
135
+
136
+ const context = await buildUiTemplateContext({
137
+ appRoot,
138
+ options: {
139
+ namespace: "customers-ui",
140
+ "api-path": "/crud/customers",
141
+ operations: "list,view",
142
+ "resource-file": resourceFile
143
+ }
144
+ });
145
+
146
+ assert.equal(context.__JSKIT_UI_RECORD_CHANGED_EVENT__, "\"customers.record.changed\"");
147
+ assert.equal(context.__JSKIT_UI_HAS_LIST_ROUTE__, "true");
148
+ assert.equal(context.__JSKIT_UI_HAS_VIEW_ROUTE__, "true");
149
+ });
150
+ });
151
+
152
+ test("buildUiTemplateContext filters rendered fields when display-fields is provided", async () => {
153
+ await withTempApp(async (appRoot) => {
154
+ const resourceFile = "packages/customers/src/shared/customerResource.js";
155
+ await writeResource(appRoot, resourceFile, FULL_RESOURCE_SOURCE);
156
+
157
+ const context = await buildUiTemplateContext({
158
+ appRoot,
159
+ options: {
160
+ namespace: "customers-ui",
161
+ "api-path": "/crud/customers",
162
+ operations: "list,view,new,edit",
163
+ "resource-file": resourceFile,
164
+ "resource-export": "customerResource",
165
+ "display-fields": "firstName,email"
166
+ }
167
+ });
168
+
169
+ assert.match(context.__JSKIT_UI_LIST_HEADER_COLUMNS__, /<th>First Name<\/th>/);
170
+ assert.match(context.__JSKIT_UI_LIST_HEADER_COLUMNS__, /<th>Email<\/th>/);
171
+ assert.doesNotMatch(context.__JSKIT_UI_LIST_HEADER_COLUMNS__, /<th>Id<\/th>/);
172
+ assert.match(context.__JSKIT_UI_VIEW_COLUMNS__, /view\.record\.value\?\.firstName/);
173
+ assert.match(context.__JSKIT_UI_VIEW_COLUMNS__, /view\.record\.value\?\.email/);
174
+ assert.doesNotMatch(context.__JSKIT_UI_VIEW_COLUMNS__, /view\.record\.value\?\.vip/);
175
+
176
+ const createFields = JSON.parse(context.__JSKIT_UI_CREATE_FORM_FIELDS__);
177
+ const editFields = JSON.parse(context.__JSKIT_UI_EDIT_FORM_FIELDS__);
178
+ assert.deepEqual(
179
+ createFields.map((field) => field.key),
180
+ ["firstName", "email"]
181
+ );
182
+ assert.deepEqual(
183
+ editFields.map((field) => field.key),
184
+ ["firstName", "email"]
185
+ );
186
+ });
187
+ });
188
+
189
+ test("buildUiTemplateContext fails when display-fields includes unknown keys", async () => {
190
+ await withTempApp(async (appRoot) => {
191
+ const resourceFile = "packages/customers/src/shared/customerResource.js";
192
+ await writeResource(appRoot, resourceFile, FULL_RESOURCE_SOURCE);
193
+
194
+ await assert.rejects(
195
+ () =>
196
+ buildUiTemplateContext({
197
+ appRoot,
198
+ options: {
199
+ namespace: "customers-ui",
200
+ "api-path": "/crud/customers",
201
+ operations: "list,view,new,edit",
202
+ "resource-file": resourceFile,
203
+ "resource-export": "customerResource",
204
+ "display-fields": "firstName,unknownField"
205
+ }
206
+ }),
207
+ /display-fields" includes unsupported field\(s\)/
208
+ );
209
+ });
210
+ });
211
+
212
+ test("buildUiTemplateContext supports list-only resources when operations=list", async () => {
213
+ await withTempApp(async (appRoot) => {
214
+ const resourceFile = "packages/customers/src/shared/listOnlyResource.js";
215
+ await writeResource(
216
+ appRoot,
217
+ resourceFile,
218
+ `const listOnlyResource = {
219
+ operations: {
220
+ list: {
221
+ outputValidator: {
222
+ schema: {
223
+ type: "object",
224
+ properties: {
225
+ items: {
226
+ type: "array",
227
+ items: {
228
+ type: "object",
229
+ properties: {
230
+ id: { type: "integer" },
231
+ fullName: { type: "string" }
232
+ }
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }
240
+ };
241
+
242
+ export { listOnlyResource };
243
+ `
244
+ );
245
+
246
+ const context = await buildUiTemplateContext({
247
+ appRoot,
248
+ options: {
249
+ namespace: "customers-ui",
250
+ "api-path": "/crud/customers",
251
+ operations: "list",
252
+ "resource-file": resourceFile,
253
+ "resource-export": "listOnlyResource"
254
+ }
255
+ });
256
+
257
+ assert.equal(context.__JSKIT_UI_HAS_LIST_ROUTE__, "true");
258
+ assert.equal(context.__JSKIT_UI_HAS_VIEW_ROUTE__, "false");
259
+ assert.equal(context.__JSKIT_UI_HAS_NEW_ROUTE__, "false");
260
+ assert.equal(context.__JSKIT_UI_HAS_EDIT_ROUTE__, "false");
261
+ assert.equal(context.__JSKIT_UI_CREATE_FORM_FIELDS__, "[]");
262
+ assert.equal(context.__JSKIT_UI_EDIT_FORM_FIELDS__, "[]");
263
+ });
264
+ });
265
+
266
+ test("buildUiTemplateContext supports view-only resources when operations=view", async () => {
267
+ await withTempApp(async (appRoot) => {
268
+ const resourceFile = "packages/customers/src/shared/viewOnlyResource.js";
269
+ await writeResource(
270
+ appRoot,
271
+ resourceFile,
272
+ `const viewOnlyResource = {
273
+ operations: {
274
+ view: {
275
+ outputValidator: {
276
+ schema: {
277
+ type: "object",
278
+ properties: {
279
+ id: { type: "integer" },
280
+ fullName: { type: "string" }
281
+ }
282
+ }
283
+ }
284
+ }
285
+ }
286
+ };
287
+
288
+ export { viewOnlyResource };
289
+ `
290
+ );
291
+
292
+ const context = await buildUiTemplateContext({
293
+ appRoot,
294
+ options: {
295
+ namespace: "customers-ui",
296
+ "api-path": "/crud/customers",
297
+ operations: "view",
298
+ "resource-file": resourceFile,
299
+ "resource-export": "viewOnlyResource"
300
+ }
301
+ });
302
+
303
+ assert.equal(context.__JSKIT_UI_HAS_LIST_ROUTE__, "false");
304
+ assert.equal(context.__JSKIT_UI_HAS_VIEW_ROUTE__, "true");
305
+ assert.equal(context.__JSKIT_UI_HAS_NEW_ROUTE__, "false");
306
+ assert.equal(context.__JSKIT_UI_HAS_EDIT_ROUTE__, "false");
307
+ assert.equal(context.__JSKIT_UI_RECORD_CHANGED_EVENT__, "\"customers.record.changed\"");
308
+ });
309
+ });
310
+
311
+ test("buildUiTemplateContext fails when operations option is invalid", async () => {
312
+ await withTempApp(async (appRoot) => {
313
+ const resourceFile = "packages/customers/src/shared/customerResource.js";
314
+ await writeResource(appRoot, resourceFile, FULL_RESOURCE_SOURCE);
315
+
316
+ await assert.rejects(
317
+ () =>
318
+ buildUiTemplateContext({
319
+ appRoot,
320
+ options: {
321
+ namespace: "customers-ui",
322
+ "api-path": "/crud/customers",
323
+ operations: "create",
324
+ "resource-file": resourceFile,
325
+ "resource-export": "customerResource"
326
+ }
327
+ }),
328
+ /operations" supports only: list, view, new, edit/
329
+ );
330
+ });
331
+ });