@jskit-ai/crud 0.1.4

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.
Files changed (40) hide show
  1. package/package.descriptor.mjs +322 -0
  2. package/package.json +22 -0
  3. package/src/client/index.js +3 -0
  4. package/src/server/CrudServiceProvider.js +11 -0
  5. package/src/server/actionIds.js +22 -0
  6. package/src/server/actions.js +152 -0
  7. package/src/server/registerRoutes.js +235 -0
  8. package/src/server/repository.js +162 -0
  9. package/src/server/service.js +96 -0
  10. package/src/shared/crud/crudModuleConfig.js +310 -0
  11. package/src/shared/crud/crudResource.js +191 -0
  12. package/src/shared/index.js +12 -0
  13. package/templates/migrations/crud_initial.cjs +42 -0
  14. package/templates/src/elements/CreateElement.vue +115 -0
  15. package/templates/src/elements/EditElement.vue +140 -0
  16. package/templates/src/elements/ListElement.vue +88 -0
  17. package/templates/src/elements/ViewElement.vue +126 -0
  18. package/templates/src/elements/clientSupport.js +41 -0
  19. package/templates/src/local-package/client/index.js +4 -0
  20. package/templates/src/local-package/package.descriptor.mjs +83 -0
  21. package/templates/src/local-package/package.json +14 -0
  22. package/templates/src/local-package/server/CrudServiceProvider.js +87 -0
  23. package/templates/src/local-package/server/actionIds.js +9 -0
  24. package/templates/src/local-package/server/actions.js +151 -0
  25. package/templates/src/local-package/server/diTokens.js +4 -0
  26. package/templates/src/local-package/server/registerRoutes.js +196 -0
  27. package/templates/src/local-package/server/repository.js +1 -0
  28. package/templates/src/local-package/server/service.js +96 -0
  29. package/templates/src/local-package/shared/crudResource.js +1 -0
  30. package/templates/src/local-package/shared/index.js +8 -0
  31. package/templates/src/local-package/shared/moduleConfig.js +169 -0
  32. package/templates/src/pages/admin/crud/[recordId]/edit.vue +7 -0
  33. package/templates/src/pages/admin/crud/[recordId]/index.vue +7 -0
  34. package/templates/src/pages/admin/crud/index.vue +7 -0
  35. package/templates/src/pages/admin/crud/new.vue +7 -0
  36. package/test/crudModuleConfig.test.js +225 -0
  37. package/test/crudResource.test.js +41 -0
  38. package/test/crudServerGuards.test.js +61 -0
  39. package/test/crudService.test.js +83 -0
  40. package/test/routeInputContracts.test.js +211 -0
@@ -0,0 +1,310 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
3
+ import {
4
+ resolveApiBasePath
5
+ } from "@jskit-ai/users-core/shared/support/usersApiPaths";
6
+ import {
7
+ USERS_ROUTE_VISIBILITY_LEVELS,
8
+ normalizeScopedRouteVisibility,
9
+ isWorkspaceVisibility
10
+ } from "@jskit-ai/users-core/shared/support/usersVisibility";
11
+
12
+ const DEFAULT_OWNERSHIP_FILTER = "workspace";
13
+ const CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO = "auto";
14
+ const CRUD_REQUESTED_OWNERSHIP_FILTER_SET = new Set([
15
+ ...USERS_ROUTE_VISIBILITY_LEVELS,
16
+ CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO
17
+ ]);
18
+ const CRUD_MODULE_ID = "crud";
19
+
20
+ function asRecord(value) {
21
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
22
+ return {};
23
+ }
24
+
25
+ return value;
26
+ }
27
+
28
+ function normalizeCrudNamespace(value) {
29
+ return normalizeText(value)
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9-]+/g, "-")
32
+ .replace(/-+/g, "-")
33
+ .replace(/^-+|-+$/g, "");
34
+ }
35
+
36
+ function normalizeCrudOwnershipFilter(value, { fallback = DEFAULT_OWNERSHIP_FILTER } = {}) {
37
+ return normalizeScopedRouteVisibility(value, { fallback });
38
+ }
39
+
40
+ function normalizeCrudRequestedOwnershipFilter(value, { fallback = CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO } = {}) {
41
+ const normalized = normalizeText(value).toLowerCase();
42
+ if (CRUD_REQUESTED_OWNERSHIP_FILTER_SET.has(normalized)) {
43
+ return normalized;
44
+ }
45
+
46
+ const normalizedFallback = normalizeText(fallback).toLowerCase();
47
+ if (CRUD_REQUESTED_OWNERSHIP_FILTER_SET.has(normalizedFallback)) {
48
+ return normalizedFallback;
49
+ }
50
+
51
+ return CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO;
52
+ }
53
+
54
+ function requireCrudNamespace(namespace, { context = "CRUD config" } = {}) {
55
+ const normalizedNamespace = normalizeCrudNamespace(namespace);
56
+ if (!normalizedNamespace) {
57
+ throw new TypeError(`${context} requires a non-empty namespace.`);
58
+ }
59
+
60
+ return normalizedNamespace;
61
+ }
62
+
63
+ function resolveCrudNamespacePath(namespace = "") {
64
+ const normalizedNamespace = requireCrudNamespace(namespace, {
65
+ context: "resolveCrudNamespacePath"
66
+ });
67
+ return `/${normalizedNamespace}`;
68
+ }
69
+
70
+ function resolveCrudRelativePath(namespace = "") {
71
+ return resolveCrudNamespacePath(namespace);
72
+ }
73
+
74
+ function normalizeCrudRelativePath(relativePath = "", { context = "resolveCrudSurfacePolicy" } = {}) {
75
+ const normalizedPath = normalizeText(relativePath);
76
+ if (!normalizedPath) {
77
+ throw new TypeError(`${context} requires a non-empty relativePath.`);
78
+ }
79
+
80
+ const withLeadingSlash = normalizedPath.startsWith("/") ? normalizedPath : `/${normalizedPath}`;
81
+ const compacted = withLeadingSlash.replace(/\/{2,}/g, "/");
82
+ return compacted === "/" ? "/" : compacted.replace(/\/+$/, "") || "/";
83
+ }
84
+
85
+ function resolveCrudApiBasePath({ namespace = "", surfaceRequiresWorkspace = false } = {}) {
86
+ const relativePath = resolveCrudRelativePath(namespace);
87
+ return resolveApiBasePath({
88
+ surfaceRequiresWorkspace: surfaceRequiresWorkspace === true,
89
+ relativePath
90
+ });
91
+ }
92
+
93
+ function resolveCrudTableName(namespace = "") {
94
+ const normalizedNamespace = requireCrudNamespace(namespace, {
95
+ context: "resolveCrudTableName"
96
+ });
97
+ return `crud_${normalizedNamespace.replace(/-/g, "_")}`;
98
+ }
99
+
100
+ function resolveCrudTokenPart(namespace = "") {
101
+ const normalizedNamespace = requireCrudNamespace(namespace, {
102
+ context: "resolveCrudTokenPart"
103
+ });
104
+ return normalizedNamespace.replace(/-/g, "_");
105
+ }
106
+
107
+ function resolveCrudActionIdPrefix(namespace = "") {
108
+ const tokenPart = resolveCrudTokenPart(namespace);
109
+ return `crud.${tokenPart}`;
110
+ }
111
+
112
+ function resolveCrudContributorId(namespace = "") {
113
+ const tokenPart = resolveCrudTokenPart(namespace);
114
+ return `crud.${tokenPart}`;
115
+ }
116
+
117
+ function resolveCrudDomain(namespace = "") {
118
+ return "crud";
119
+ }
120
+
121
+ function resolveCrudToken(namespace = "", suffix = "") {
122
+ const contributorId = resolveCrudContributorId(namespace);
123
+ return suffix ? `${contributorId}.${suffix}` : contributorId;
124
+ }
125
+
126
+ function resolveCrudConfig(source = {}) {
127
+ const settings = source && typeof source === "object" && !Array.isArray(source) ? source : {};
128
+ const namespace = requireCrudNamespace(settings.namespace, {
129
+ context: "resolveCrudConfig"
130
+ });
131
+ const ownershipFilter = normalizeCrudOwnershipFilter(settings.ownershipFilter);
132
+
133
+ return Object.freeze({
134
+ namespace,
135
+ ownershipFilter,
136
+ workspaceScoped: isWorkspaceVisibility(ownershipFilter),
137
+ namespacePath: resolveCrudNamespacePath(namespace),
138
+ relativePath: resolveCrudRelativePath(namespace),
139
+ apiBasePath: resolveCrudApiBasePath({ namespace }),
140
+ tableName: resolveCrudTableName(namespace),
141
+ actionIdPrefix: resolveCrudActionIdPrefix(namespace),
142
+ contributorId: resolveCrudContributorId(namespace),
143
+ domain: resolveCrudDomain(namespace),
144
+ repositoryToken: resolveCrudToken(namespace, "repository"),
145
+ serviceToken: resolveCrudToken(namespace, "service")
146
+ });
147
+ }
148
+
149
+ function normalizeSurfaceDefinitions(sourceDefinitions = {}) {
150
+ const definitions = asRecord(sourceDefinitions);
151
+ const normalized = {};
152
+
153
+ for (const [key, value] of Object.entries(definitions)) {
154
+ const definition = asRecord(value);
155
+ const surfaceId = normalizeSurfaceId(definition.id || key);
156
+ if (!surfaceId) {
157
+ continue;
158
+ }
159
+
160
+ normalized[surfaceId] = Object.freeze({
161
+ ...definition,
162
+ id: surfaceId,
163
+ enabled: definition.enabled !== false,
164
+ requiresAuth: definition.requiresAuth === true,
165
+ requiresWorkspace: definition.requiresWorkspace === true
166
+ });
167
+ }
168
+
169
+ return Object.freeze(normalized);
170
+ }
171
+
172
+ function resolveOwnershipFilterFromSurfaceDefinition(definition = {}) {
173
+ if (definition.requiresWorkspace === true) {
174
+ return "workspace";
175
+ }
176
+ if (definition.requiresAuth === true) {
177
+ return "user";
178
+ }
179
+ return "public";
180
+ }
181
+
182
+ function resolveCrudSurfacePolicy(
183
+ sourceConfig = {},
184
+ {
185
+ surfaceDefinitions = {},
186
+ defaultSurfaceId = "",
187
+ context = "resolveCrudSurfacePolicy"
188
+ } = {}
189
+ ) {
190
+ const config = asRecord(sourceConfig);
191
+ const normalizedDefinitions = normalizeSurfaceDefinitions(surfaceDefinitions);
192
+ const requestedSurfaceId = normalizeSurfaceId(config.surface);
193
+ const fallbackSurfaceId = normalizeSurfaceId(defaultSurfaceId);
194
+ const surfaceId = requestedSurfaceId || fallbackSurfaceId;
195
+ if (!surfaceId) {
196
+ throw new Error(`${context} requires surface or defaultSurfaceId.`);
197
+ }
198
+
199
+ const surfaceDefinition = normalizedDefinitions[surfaceId];
200
+ if (!surfaceDefinition) {
201
+ throw new Error(`${context} cannot resolve surface "${surfaceId}".`);
202
+ }
203
+ if (surfaceDefinition.enabled === false) {
204
+ throw new Error(`${context} surface "${surfaceId}" is disabled.`);
205
+ }
206
+
207
+ const requestedOwnershipFilter = normalizeCrudRequestedOwnershipFilter(config.ownershipFilter);
208
+ const ownershipFilter =
209
+ requestedOwnershipFilter === CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO
210
+ ? resolveOwnershipFilterFromSurfaceDefinition(surfaceDefinition)
211
+ : normalizeScopedRouteVisibility(requestedOwnershipFilter, {
212
+ fallback: "public"
213
+ });
214
+
215
+ if (isWorkspaceVisibility(ownershipFilter) && surfaceDefinition.requiresWorkspace !== true) {
216
+ throw new Error(
217
+ `${context} ownershipFilter "${ownershipFilter}" requires a workspace-enabled surface.`
218
+ );
219
+ }
220
+
221
+ const relativePath = normalizeCrudRelativePath(config.relativePath || resolveCrudRelativePath(config.namespace), {
222
+ context
223
+ });
224
+
225
+ return Object.freeze({
226
+ surfaceId,
227
+ ownershipFilter,
228
+ requestedOwnershipFilter,
229
+ workspaceScoped: isWorkspaceVisibility(ownershipFilter),
230
+ relativePath,
231
+ surfaceDefinition
232
+ });
233
+ }
234
+
235
+ function resolveCrudSurfacePolicyFromAppConfig(sourceConfig = {}, appConfig = {}, options = {}) {
236
+ const config = asRecord(appConfig);
237
+ return resolveCrudSurfacePolicy(sourceConfig, {
238
+ ...asRecord(options),
239
+ surfaceDefinitions: config.surfaceDefinitions,
240
+ defaultSurfaceId: config.surfaceDefaultId
241
+ });
242
+ }
243
+
244
+ function resolveCrudConfigsFromModules(modulesSource = {}) {
245
+ const modules = modulesSource && typeof modulesSource === "object" && !Array.isArray(modulesSource)
246
+ ? modulesSource
247
+ : {};
248
+ const configs = [];
249
+ const seenContributorIds = new Set();
250
+
251
+ for (const moduleConfig of Object.values(modules)) {
252
+ const source = moduleConfig && typeof moduleConfig === "object" && !Array.isArray(moduleConfig)
253
+ ? moduleConfig
254
+ : {};
255
+
256
+ if (normalizeText(source.module).toLowerCase() !== CRUD_MODULE_ID) {
257
+ continue;
258
+ }
259
+
260
+ const resolved = resolveCrudConfig(source);
261
+ if (seenContributorIds.has(resolved.contributorId)) {
262
+ throw new Error(`Duplicate CRUD namespace in config.modules: "${resolved.namespace}".`);
263
+ }
264
+ seenContributorIds.add(resolved.contributorId);
265
+ configs.push(resolved);
266
+ }
267
+
268
+ return configs;
269
+ }
270
+
271
+ function resolveCrudConfigFromModules(modulesSource = {}, options = {}) {
272
+ const configs = resolveCrudConfigsFromModules(modulesSource);
273
+ const hasNamespace = Object.hasOwn(options, "namespace");
274
+ if (hasNamespace) {
275
+ const normalizedNamespace = requireCrudNamespace(options.namespace, {
276
+ context: "resolveCrudConfigFromModules"
277
+ });
278
+ return configs.find((entry) => entry.namespace === normalizedNamespace) || null;
279
+ }
280
+
281
+ if (configs.length === 1) {
282
+ return configs[0];
283
+ }
284
+
285
+ return null;
286
+ }
287
+
288
+ export {
289
+ CRUD_MODULE_ID,
290
+ DEFAULT_OWNERSHIP_FILTER,
291
+ CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO,
292
+ normalizeCrudNamespace,
293
+ normalizeCrudOwnershipFilter,
294
+ normalizeCrudRequestedOwnershipFilter,
295
+ isWorkspaceVisibility,
296
+ requireCrudNamespace,
297
+ resolveCrudNamespacePath,
298
+ resolveCrudRelativePath,
299
+ normalizeCrudRelativePath,
300
+ resolveCrudApiBasePath,
301
+ resolveCrudTableName,
302
+ resolveCrudActionIdPrefix,
303
+ resolveCrudContributorId,
304
+ resolveCrudDomain,
305
+ resolveCrudConfig,
306
+ resolveCrudSurfacePolicy,
307
+ resolveCrudSurfacePolicyFromAppConfig,
308
+ resolveCrudConfigsFromModules,
309
+ resolveCrudConfigFromModules
310
+ };
@@ -0,0 +1,191 @@
1
+ import { Type } from "typebox";
2
+ import {
3
+ toIsoString,
4
+ toDatabaseDateTimeUtc
5
+ } from "@jskit-ai/database-runtime/shared";
6
+ import {
7
+ normalizeObjectInput,
8
+ createCursorListValidator
9
+ } from "@jskit-ai/kernel/shared/validators";
10
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
11
+
12
+ function normalizeNumberField(value, { fieldLabel = "Number field" } = {}) {
13
+ const normalized = Number(value);
14
+ if (!Number.isFinite(normalized)) {
15
+ throw new TypeError(`${fieldLabel} must be a valid number.`);
16
+ }
17
+
18
+ return normalized;
19
+ }
20
+
21
+ function normalizeDateTimeField(value, { fieldLabel = "Date field" } = {}) {
22
+ try {
23
+ return toIsoString(value);
24
+ } catch {
25
+ throw new TypeError(`${fieldLabel} must be a valid date/time.`);
26
+ }
27
+ }
28
+
29
+ function normalizeDatabaseDateTimeField(value, { fieldLabel = "Date field" } = {}) {
30
+ try {
31
+ return toDatabaseDateTimeUtc(value);
32
+ } catch {
33
+ throw new TypeError(`${fieldLabel} must be a valid date/time.`);
34
+ }
35
+ }
36
+
37
+ function normalizeRecordInput(payload = {}) {
38
+ const source = normalizeObjectInput(payload);
39
+ const normalized = {};
40
+
41
+ if (Object.hasOwn(source, "textField")) {
42
+ normalized.textField = normalizeText(source.textField);
43
+ }
44
+
45
+ if (Object.hasOwn(source, "dateField")) {
46
+ normalized.dateField = normalizeDatabaseDateTimeField(source.dateField, {
47
+ fieldLabel: "Date field"
48
+ });
49
+ }
50
+
51
+ if (Object.hasOwn(source, "numberField")) {
52
+ normalized.numberField = normalizeNumberField(source.numberField, {
53
+ fieldLabel: "Number field"
54
+ });
55
+ }
56
+
57
+ return normalized;
58
+ }
59
+
60
+ function normalizeRecordOutput(payload = {}) {
61
+ const source = normalizeObjectInput(payload);
62
+
63
+ return {
64
+ id: Number(source.id),
65
+ textField: normalizeText(source.textField),
66
+ dateField: normalizeDateTimeField(source.dateField, {
67
+ fieldLabel: "Date field"
68
+ }),
69
+ numberField: normalizeNumberField(source.numberField, {
70
+ fieldLabel: "Number field"
71
+ }),
72
+ createdAt: normalizeDateTimeField(source.createdAt, {
73
+ fieldLabel: "Created at"
74
+ }),
75
+ updatedAt: normalizeDateTimeField(source.updatedAt, {
76
+ fieldLabel: "Updated at"
77
+ })
78
+ };
79
+ }
80
+
81
+ const recordOutputSchema = Type.Object(
82
+ {
83
+ id: Type.Integer({ minimum: 1 }),
84
+ textField: Type.String({ minLength: 1, maxLength: 160 }),
85
+ dateField: Type.String({ minLength: 1 }),
86
+ numberField: Type.Number(),
87
+ createdAt: Type.String({ minLength: 1 }),
88
+ updatedAt: Type.String({ minLength: 1 })
89
+ },
90
+ { additionalProperties: false }
91
+ );
92
+
93
+ const recordBodySchema = Type.Object(
94
+ {
95
+ textField: Type.String({
96
+ minLength: 1,
97
+ maxLength: 160,
98
+ messages: {
99
+ required: "Text field is required.",
100
+ minLength: "Text field is required.",
101
+ maxLength: "Text field must be at most 160 characters.",
102
+ default: "Text field is required."
103
+ }
104
+ }),
105
+ dateField: Type.String({
106
+ minLength: 1,
107
+ messages: {
108
+ required: "Date field is required.",
109
+ minLength: "Date field is required.",
110
+ default: "Date field is required."
111
+ }
112
+ }),
113
+ numberField: Type.Number({
114
+ messages: {
115
+ required: "Number field is required.",
116
+ default: "Number field must be a valid number."
117
+ }
118
+ })
119
+ },
120
+ {
121
+ additionalProperties: false,
122
+ messages: {
123
+ additionalProperties: "Unexpected field.",
124
+ default: "Invalid value."
125
+ }
126
+ }
127
+ );
128
+
129
+ const recordOutputValidator = Object.freeze({
130
+ schema: recordOutputSchema,
131
+ normalize: normalizeRecordOutput
132
+ });
133
+
134
+ const crudResource = {
135
+ resource: "crud",
136
+ messages: {
137
+ validation: "Fix invalid CRUD values and try again.",
138
+ saveSuccess: "Record saved.",
139
+ saveError: "Unable to save record.",
140
+ deleteSuccess: "Record deleted.",
141
+ deleteError: "Unable to delete record."
142
+ },
143
+ operations: {
144
+ list: {
145
+ method: "GET",
146
+ outputValidator: createCursorListValidator(recordOutputValidator)
147
+ },
148
+ view: {
149
+ method: "GET",
150
+ outputValidator: recordOutputValidator
151
+ },
152
+ create: {
153
+ method: "POST",
154
+ bodyValidator: {
155
+ schema: recordBodySchema,
156
+ normalize: normalizeRecordInput
157
+ },
158
+ outputValidator: recordOutputValidator
159
+ },
160
+ patch: {
161
+ method: "PATCH",
162
+ bodyValidator: {
163
+ schema: Type.Partial(recordBodySchema, { additionalProperties: false }),
164
+ normalize: normalizeRecordInput
165
+ },
166
+ outputValidator: recordOutputValidator
167
+ },
168
+ delete: {
169
+ method: "DELETE",
170
+ outputValidator: {
171
+ schema: Type.Object(
172
+ {
173
+ id: Type.Integer({ minimum: 1 }),
174
+ deleted: Type.Literal(true)
175
+ },
176
+ { additionalProperties: false }
177
+ ),
178
+ normalize(payload = {}) {
179
+ const source = normalizeObjectInput(payload);
180
+
181
+ return {
182
+ id: Number(source.id),
183
+ deleted: true
184
+ };
185
+ }
186
+ }
187
+ }
188
+ }
189
+ };
190
+
191
+ export { crudResource };
@@ -0,0 +1,12 @@
1
+ export { crudResource } from "./crud/crudResource.js";
2
+ export {
3
+ CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO,
4
+ resolveCrudConfig,
5
+ resolveCrudSurfacePolicy,
6
+ resolveCrudSurfacePolicyFromAppConfig,
7
+ resolveCrudRelativePath,
8
+ normalizeCrudNamespace,
9
+ normalizeCrudRequestedOwnershipFilter,
10
+ normalizeCrudOwnershipFilter,
11
+ isWorkspaceVisibility
12
+ } from "./crud/crudModuleConfig.js";
@@ -0,0 +1,42 @@
1
+ // JSKIT_MIGRATION_ID: crud_initial_${option:namespace}
2
+
3
+ const RAW_NAMESPACE = "${option:namespace}";
4
+
5
+ function resolveTableName() {
6
+ const normalizedNamespace = String(RAW_NAMESPACE || "")
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9-]+/g, "-")
10
+ .replace(/-+/g, "-")
11
+ .replace(/^-+|-+$/g, "");
12
+
13
+ if (!normalizedNamespace) {
14
+ throw new Error("crud_initial migration requires option:namespace.");
15
+ }
16
+
17
+ return "crud_" + normalizedNamespace.replace(/-/g, "_");
18
+ }
19
+
20
+ const TABLE_NAME = resolveTableName();
21
+
22
+ exports.up = async function up(knex) {
23
+ const hasCrudTable = await knex.schema.hasTable(TABLE_NAME);
24
+ if (hasCrudTable) {
25
+ return;
26
+ }
27
+
28
+ await knex.schema.createTable(TABLE_NAME, (table) => {
29
+ table.increments("id").unsigned().primary();
30
+ table.integer("workspace_owner_id").unsigned().nullable().index();
31
+ table.integer("user_owner_id").unsigned().nullable().index();
32
+ table.string("text_field", 160).notNullable();
33
+ table.timestamp("date_field").notNullable();
34
+ table.double("number_field").notNullable();
35
+ table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
36
+ table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
37
+ });
38
+ };
39
+
40
+ exports.down = async function down(knex) {
41
+ await knex.schema.dropTableIfExists(TABLE_NAME);
42
+ };
@@ -0,0 +1,115 @@
1
+ <template>
2
+ <section class="crud-create-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 variant="text" :to="listPath">Cancel</v-btn>
12
+ <v-btn
13
+ color="primary"
14
+ :loading="addEdit.isSaving"
15
+ :disabled="addEdit.isInitialLoading || addEdit.isRefetching || !addEdit.canSave"
16
+ @click="addEdit.submit"
17
+ >
18
+ Save ${option:namespace|singular|default(record)}
19
+ </v-btn>
20
+ </div>
21
+ </v-card-item>
22
+ <v-divider />
23
+ <v-card-text class="pt-4">
24
+ <v-form v-if="!addEdit.loadError" @submit.prevent="addEdit.submit" novalidate>
25
+ <v-row>
26
+ <v-col cols="12" md="6">
27
+ <v-text-field
28
+ v-model="recordForm.textField"
29
+ label="Text field"
30
+ variant="outlined"
31
+ density="comfortable"
32
+ maxlength="160"
33
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
34
+ :error-messages="addEdit.fieldErrors.textField ? [addEdit.fieldErrors.textField] : []"
35
+ />
36
+ </v-col>
37
+ <v-col cols="12" md="6">
38
+ <v-text-field
39
+ v-model="recordForm.dateField"
40
+ label="Date field"
41
+ type="date"
42
+ variant="outlined"
43
+ density="comfortable"
44
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
45
+ :error-messages="addEdit.fieldErrors.dateField ? [addEdit.fieldErrors.dateField] : []"
46
+ />
47
+ </v-col>
48
+ <v-col cols="12" md="6">
49
+ <v-text-field
50
+ v-model="recordForm.numberField"
51
+ label="Number field"
52
+ type="number"
53
+ variant="outlined"
54
+ density="comfortable"
55
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
56
+ :error-messages="addEdit.fieldErrors.numberField ? [addEdit.fieldErrors.numberField] : []"
57
+ />
58
+ </v-col>
59
+ </v-row>
60
+ </v-form>
61
+ </v-card-text>
62
+ </v-card>
63
+ </section>
64
+ </template>
65
+
66
+ <script setup>
67
+ import { reactive } from "vue";
68
+ import { validateOperationSection } from "@jskit-ai/http-runtime/shared/validators/operationValidation";
69
+ import { useAddEdit } from "@jskit-ai/users-web/client/composables/useAddEdit";
70
+ import {
71
+ crudResource,
72
+ useCrudCreateRuntime,
73
+ useCrudModulePolicyRuntime
74
+ } from "./clientSupport.js";
75
+
76
+ const {
77
+ listPath,
78
+ apiSuffix,
79
+ createQueryKey,
80
+ invalidateAndGoView
81
+ } = useCrudCreateRuntime();
82
+ const { ownershipFilter, surfaceId } = useCrudModulePolicyRuntime();
83
+ const recordForm = reactive({
84
+ textField: "",
85
+ dateField: "",
86
+ numberField: ""
87
+ });
88
+
89
+ const addEdit = useAddEdit({
90
+ ownershipFilter,
91
+ surfaceId,
92
+ resource: crudResource,
93
+ apiSuffix,
94
+ queryKeyFactory: createQueryKey,
95
+ readEnabled: false,
96
+ writeMethod: "POST",
97
+ fallbackSaveError: "Unable to save record.",
98
+ fieldErrorKeys: ["textField", "dateField", "numberField"],
99
+ model: recordForm,
100
+ parseInput: (rawPayload) =>
101
+ validateOperationSection({
102
+ operation: crudResource.operations.create,
103
+ section: "bodyValidator",
104
+ value: rawPayload
105
+ }),
106
+ buildRawPayload: (model) => ({
107
+ textField: model.textField,
108
+ dateField: model.dateField,
109
+ numberField: model.numberField
110
+ }),
111
+ onSaveSuccess: async (payload, { queryClient }) => {
112
+ await invalidateAndGoView(queryClient, payload?.id);
113
+ }
114
+ });
115
+ </script>