@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.
- package/package.descriptor.mjs +201 -0
- package/package.json +11 -0
- package/src/server/buildTemplateContext.js +537 -0
- package/templates/src/pages/admin/ui-generator/EditElement.vue +141 -0
- package/templates/src/pages/admin/ui-generator/ListElement.vue +103 -0
- package/templates/src/pages/admin/ui-generator/NewElement.vue +114 -0
- package/templates/src/pages/admin/ui-generator/ViewElement.vue +71 -0
- package/test/buildTemplateContext.test.js +331 -0
|
@@ -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
|
+
});
|