@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.
- package/package.descriptor.mjs +322 -0
- package/package.json +22 -0
- package/src/client/index.js +3 -0
- package/src/server/CrudServiceProvider.js +11 -0
- package/src/server/actionIds.js +22 -0
- package/src/server/actions.js +152 -0
- package/src/server/registerRoutes.js +235 -0
- package/src/server/repository.js +162 -0
- package/src/server/service.js +96 -0
- package/src/shared/crud/crudModuleConfig.js +310 -0
- package/src/shared/crud/crudResource.js +191 -0
- package/src/shared/index.js +12 -0
- package/templates/migrations/crud_initial.cjs +42 -0
- package/templates/src/elements/CreateElement.vue +115 -0
- package/templates/src/elements/EditElement.vue +140 -0
- package/templates/src/elements/ListElement.vue +88 -0
- package/templates/src/elements/ViewElement.vue +126 -0
- package/templates/src/elements/clientSupport.js +41 -0
- package/templates/src/local-package/client/index.js +4 -0
- package/templates/src/local-package/package.descriptor.mjs +83 -0
- package/templates/src/local-package/package.json +14 -0
- package/templates/src/local-package/server/CrudServiceProvider.js +87 -0
- package/templates/src/local-package/server/actionIds.js +9 -0
- package/templates/src/local-package/server/actions.js +151 -0
- package/templates/src/local-package/server/diTokens.js +4 -0
- package/templates/src/local-package/server/registerRoutes.js +196 -0
- package/templates/src/local-package/server/repository.js +1 -0
- package/templates/src/local-package/server/service.js +96 -0
- package/templates/src/local-package/shared/crudResource.js +1 -0
- package/templates/src/local-package/shared/index.js +8 -0
- package/templates/src/local-package/shared/moduleConfig.js +169 -0
- package/templates/src/pages/admin/crud/[recordId]/edit.vue +7 -0
- package/templates/src/pages/admin/crud/[recordId]/index.vue +7 -0
- package/templates/src/pages/admin/crud/index.vue +7 -0
- package/templates/src/pages/admin/crud/new.vue +7 -0
- package/test/crudModuleConfig.test.js +225 -0
- package/test/crudResource.test.js +41 -0
- package/test/crudServerGuards.test.js +61 -0
- package/test/crudService.test.js +83 -0
- 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>
|