@jskit-ai/http-web 0.1.78 → 0.1.80
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.json +6 -6
- package/patterns/crud-screen-set/PATTERN.md +30 -0
- package/patterns/crud-screen-set/example/books/BookEditPage.vue +1 -0
- package/patterns/crud-screen-set/example/books/BookNewPage.vue +1 -0
- package/src/client/components/CrudListScreen.vue +1 -1
- package/test/crudScreenComponents.test.js +58 -0
- package/test/crudScreenPattern.test.js +118 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/http-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.80",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
"./client/support/contractGuards": "./src/client/support/contractGuards.js"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
47
|
+
"@jskit-ai/resource-crud-core": "0.1.177",
|
|
48
48
|
"@mdi/js": "^7.4.47"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
52
|
-
"@jskit-ai/kernel": "0.1.
|
|
53
|
-
"@jskit-ai/realtime": "0.1.
|
|
54
|
-
"@jskit-ai/shell-web": "0.1.
|
|
51
|
+
"@jskit-ai/http-runtime": "0.1.233",
|
|
52
|
+
"@jskit-ai/kernel": "0.1.235",
|
|
53
|
+
"@jskit-ai/realtime": "0.1.232",
|
|
54
|
+
"@jskit-ai/shell-web": "0.1.239",
|
|
55
55
|
"@tanstack/vue-query": "^5.90.5",
|
|
56
56
|
"vue": "^3.5.13",
|
|
57
57
|
"vue-router": "^5.0.4",
|
|
@@ -42,6 +42,13 @@ questionnaire.
|
|
|
42
42
|
- Resource-load errors stay inside the screen with retry. Command success and
|
|
43
43
|
failure use JSKIT's shared toast/snackbar feedback path.
|
|
44
44
|
- URLs are route templates or route objects resolved through the screen runtime.
|
|
45
|
+
- Browser URL templates include the configured surface prefix; unlike API
|
|
46
|
+
suffixes, `/new` and `/` are absolute browser paths. Use the complete route or
|
|
47
|
+
`paths.page()` for a link without record placeholders, and the owning CRUD
|
|
48
|
+
runtime's `resolveParams()` for record placeholders.
|
|
49
|
+
- `CrudListScreen` applies `createLabel` to all create actions. Its responsive
|
|
50
|
+
card and table layouts both remain in the DOM; Playwright text assertions
|
|
51
|
+
should filter to visible matches rather than match a hidden layout too.
|
|
45
52
|
- Product field components are ordinary application source.
|
|
46
53
|
- Query keys and placement sources describe the resource, not an authoring tool.
|
|
47
54
|
- No generated-file markers, overwrite contracts, receipts, or provenance remain.
|
|
@@ -63,6 +70,29 @@ filters and actions explicit without adding empty screen machinery elsewhere.
|
|
|
63
70
|
|
|
64
71
|
## Variation points
|
|
65
72
|
|
|
73
|
+
`useCrudAddEditScreen()` requires `addEditOptions.queryKeyFactory` even when
|
|
74
|
+
`readEnabled` is false. Include the resource, surface, route scope and record
|
|
75
|
+
id (or a distinct new-record marker) so form cache entries stay separate.
|
|
76
|
+
The New/Edit examples supply it; list/view screen wrappers derive their
|
|
77
|
+
standard keys from `resourceNamespace`.
|
|
78
|
+
|
|
79
|
+
Save navigation is configured in the top-level `saveSuccess` object. The default
|
|
80
|
+
prefers a configured record-view URL, then the list. A create-and-list variation
|
|
81
|
+
sets `navigateToView: false` and `listUrlTemplate` to its list route. Set both
|
|
82
|
+
`navigateToView` and `navigateToList` to `false` to stay on the form. A custom
|
|
83
|
+
`addEditOptions.onSaveSuccess` replaces default invalidation and navigation;
|
|
84
|
+
it is unnecessary for ordinary list navigation. The generic CRUD guide explains
|
|
85
|
+
this contract under **Save navigation**.
|
|
86
|
+
|
|
87
|
+
Ownership and permission-bootstrap mode are different options. For a public
|
|
88
|
+
resource on a public surface, use `ownershipFilter: "public"` in form options
|
|
89
|
+
or lower-level request composables. List/view screen wrappers derive request
|
|
90
|
+
scope from the configured surface and do not expose this option. Keep the
|
|
91
|
+
default `access: "auto"` when there are no permission
|
|
92
|
+
requirements. `access` accepts only `auto`, `always`, or `never`; `public` is
|
|
93
|
+
not an access mode. `always` requires bootstrap permissions even when there
|
|
94
|
+
are no permission requirements. Do not add accounts to satisfy that override.
|
|
95
|
+
|
|
66
96
|
Change routes, labels, visible fields, field components, filters, query params,
|
|
67
97
|
realtime events, delete availability, and action definitions. Use the screen
|
|
68
98
|
slots for domain-specific content. Use a lower-level request composable only
|
|
@@ -21,6 +21,7 @@ const screen = useCrudAddEditScreen({
|
|
|
21
21
|
addEditOptions: {
|
|
22
22
|
apiUrlTemplate: "/books/:bookId",
|
|
23
23
|
placementSource: "crud.books.edit",
|
|
24
|
+
queryKeyFactory: (surfaceId = "", scope = "") => ["crud", "books", surfaceId, scope, routeRecordId.value],
|
|
24
25
|
writeMethod: "PATCH",
|
|
25
26
|
requestRecoveryLabel: "Book",
|
|
26
27
|
fallbackLoadError: "Unable to load this book.",
|
|
@@ -15,6 +15,7 @@ const screen = useCrudAddEditScreen({
|
|
|
15
15
|
addEditOptions: {
|
|
16
16
|
apiSuffix: "/books",
|
|
17
17
|
placementSource: "crud.books.new",
|
|
18
|
+
queryKeyFactory: (surfaceId = "", scope = "") => ["crud", "books", surfaceId, scope, "new"],
|
|
18
19
|
readEnabled: false,
|
|
19
20
|
writeMethod: "POST",
|
|
20
21
|
recordIdParam: "bookId",
|
|
@@ -3,6 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
import test from "node:test";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { registerHooks } from "node:module";
|
|
8
|
+
import { compileScript, parse } from "@vue/compiler-sfc";
|
|
9
|
+
import { createSSRApp, defineComponent, h } from "vue";
|
|
10
|
+
import { renderToString } from "vue/server-renderer";
|
|
11
|
+
import { createMemoryHistory, createRouter } from "vue-router";
|
|
6
12
|
|
|
7
13
|
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
8
14
|
const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
|
|
@@ -15,6 +21,58 @@ async function readClientFile(...parts) {
|
|
|
15
21
|
return readFile(path.join(PACKAGE_DIR, "src", "client", ...parts), "utf8");
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
test("every CRUD list create action retains its configured label and destination", async () => {
|
|
25
|
+
const componentUrl = new URL("../src/client/components/CrudListScreen.vue", import.meta.url).href;
|
|
26
|
+
const hooks = registerHooks({
|
|
27
|
+
load(url, context, nextLoad) {
|
|
28
|
+
if (!url.endsWith(".vue")) return nextLoad(url, context);
|
|
29
|
+
if (url !== componentUrl) {
|
|
30
|
+
return { format: "module", shortCircuit: true, source: "export default { render: () => null };" };
|
|
31
|
+
}
|
|
32
|
+
const { descriptor } = parse(readFileSync(new URL(url), "utf8"));
|
|
33
|
+
return {
|
|
34
|
+
format: "module", shortCircuit: true,
|
|
35
|
+
source: compileScript(descriptor, { id: "crud-list-create-label", inlineTemplate: true }).content
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
let CrudListScreen;
|
|
40
|
+
try {
|
|
41
|
+
({ default: CrudListScreen } = await import(componentUrl));
|
|
42
|
+
} finally {
|
|
43
|
+
hooks.deregister();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const populated of [false, true]) {
|
|
47
|
+
for (const createLabel of [undefined, "Add book"]) {
|
|
48
|
+
const screen = {
|
|
49
|
+
records: {}, listPrimaryAction: "/books/new",
|
|
50
|
+
displayRows: populated ? [{ key: "1", recordKey: "1", record: { title: "Kindred" } }] : [],
|
|
51
|
+
resolveRecordTitle: (record) => record.title
|
|
52
|
+
};
|
|
53
|
+
const app = createSSRApp({ render: () => h(CrudListScreen, {
|
|
54
|
+
screen, ...(createLabel === undefined ? {} : { createLabel })
|
|
55
|
+
}) });
|
|
56
|
+
const router = createRouter({
|
|
57
|
+
history: createMemoryHistory(), routes: [{ path: "/books", component: { render: () => null } }]
|
|
58
|
+
});
|
|
59
|
+
await router.push("/books");
|
|
60
|
+
app.use(router);
|
|
61
|
+
app.component("VBtn", defineComponent({
|
|
62
|
+
props: { to: { type: [String, Object], default: undefined } },
|
|
63
|
+
setup: (props, { slots }) => () => h("a", { href: props.to }, slots.default?.())
|
|
64
|
+
}));
|
|
65
|
+
for (const name of ["VSheet", "VTable", "VTextField", "VSkeletonLoader", "VCheckboxBtn"]) {
|
|
66
|
+
app.component(name, defineComponent({ setup: (_props, { slots }) => () => h("div", slots.default?.()) }));
|
|
67
|
+
}
|
|
68
|
+
const html = await renderToString(app);
|
|
69
|
+
const labels = [...html.matchAll(/<a[^>]*href="\/books\/new"[^>]*>(.*?)<\/a>/gs)]
|
|
70
|
+
.map((match) => match[1].trim());
|
|
71
|
+
assert.deepEqual(labels, Array(populated ? 2 : 3).fill(createLabel ?? "New record"));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
18
76
|
test("CRUD screen components own list/view/form chrome centrally", async () => {
|
|
19
77
|
const listSource = await readComponent("CrudListScreen.vue");
|
|
20
78
|
const viewSource = await readComponent("CrudViewScreen.vue");
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { readdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { registerHooks } from "node:module";
|
|
4
6
|
import path from "node:path";
|
|
5
7
|
import test from "node:test";
|
|
6
8
|
import { promisify } from "node:util";
|
|
7
9
|
import { fileURLToPath } from "node:url";
|
|
8
10
|
import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc";
|
|
11
|
+
import { createSSRApp, h } from "vue";
|
|
12
|
+
import { renderToString } from "vue/server-renderer";
|
|
13
|
+
import { createMemoryHistory, createRouter } from "vue-router";
|
|
14
|
+
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
|
15
|
+
import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
|
|
16
|
+
import { useCrudAddEditScreen } from "../src/client/composables/useCrudAddEditScreen.js";
|
|
17
|
+
import { useCrudListScreen } from "../src/client/composables/useCrudListScreen.js";
|
|
9
18
|
|
|
10
19
|
const execFileAsync = promisify(execFile);
|
|
11
20
|
const patternRoot = path.resolve(
|
|
@@ -14,6 +23,84 @@ const patternRoot = path.resolve(
|
|
|
14
23
|
);
|
|
15
24
|
const exampleRoot = path.join(patternRoot, "example", "books");
|
|
16
25
|
|
|
26
|
+
const hooks = registerHooks({
|
|
27
|
+
resolve(specifier, context, nextResolve) {
|
|
28
|
+
if (specifier === "@local/books/shared") {
|
|
29
|
+
return { shortCircuit: true, url: new URL("../../resource-crud-core/patterns/resource-contract/example/bookResource.js", import.meta.url).href };
|
|
30
|
+
}
|
|
31
|
+
return nextResolve(specifier, context);
|
|
32
|
+
},
|
|
33
|
+
load(url, context, nextLoad) {
|
|
34
|
+
if (!url.endsWith(".vue")) return nextLoad(url, context);
|
|
35
|
+
const { descriptor } = parse(readFileSync(new URL(url), "utf8"));
|
|
36
|
+
return { format: "module", shortCircuit: true, source: compileScript(descriptor, { id: url }).content };
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the human guide's public create/list configuration works without bootstrap permissions", async () => {
|
|
41
|
+
const guide = await readFile(new URL("../../agent-docs/site/guide/framework/crud-operations.md", import.meta.url), "utf8");
|
|
42
|
+
const snippets = Array.from(guide.matchAll(/```js\n([\s\S]*?)\n```/gu), (match) => match[1]);
|
|
43
|
+
const resource = new Function("defineCrudResource", `${snippets.find((source) => source.startsWith("const bookResource"))}\nreturn bookResource;`)(defineCrudResource);
|
|
44
|
+
const options = new Function(`return ({${snippets.find((source) => source.startsWith("addEditOptions:"))}});`)();
|
|
45
|
+
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: "/books/:bookId?", component: { render: () => h("div") } }] });
|
|
46
|
+
await router.push("/books/new");
|
|
47
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
48
|
+
const writes = [];
|
|
49
|
+
const saved = [];
|
|
50
|
+
const client = {
|
|
51
|
+
async request(path, requestOptions) {
|
|
52
|
+
assert.equal(path, "/api/books");
|
|
53
|
+
assert.equal(requestOptions.csrf, false);
|
|
54
|
+
if (requestOptions.method === "GET") return { items: [...saved], nextCursor: null };
|
|
55
|
+
writes.push(requestOptions.body);
|
|
56
|
+
const record = { id: "1", ...requestOptions.body };
|
|
57
|
+
saved.push(record);
|
|
58
|
+
return record;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
let form;
|
|
62
|
+
let list;
|
|
63
|
+
const app = createSSRApp({
|
|
64
|
+
setup() {
|
|
65
|
+
form = useCrudAddEditScreen({
|
|
66
|
+
...options,
|
|
67
|
+
resource,
|
|
68
|
+
operationName: "create",
|
|
69
|
+
formFields: [{ key: "title", type: "string" }],
|
|
70
|
+
addEditOptions: { ...options.addEditOptions, client }
|
|
71
|
+
});
|
|
72
|
+
list = useCrudListScreen({ resource, resourceNamespace: "books", apiSuffix: "/books", readEnabled: false, client });
|
|
73
|
+
return () => h("div");
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
app.use(router);
|
|
77
|
+
app.use(VueQueryPlugin, { queryClient });
|
|
78
|
+
app.provide("jskit.shell-web.runtime.web-placement.client", {
|
|
79
|
+
getPlacements: () => [],
|
|
80
|
+
getContext: () => ({ surfaceConfig: { defaultSurfaceId: "home", enabledSurfaceIds: ["home"], surfacesById: { home: { id: "home", enabled: true, routeBase: "/", pagesRoot: "", requiresWorkspace: false } } } })
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
await renderToString(app);
|
|
84
|
+
assert.equal(form.addEdit.loadError, "");
|
|
85
|
+
assert.equal(form.addEdit.isSubmitDisabled, false);
|
|
86
|
+
await form.addEdit.submit();
|
|
87
|
+
assert.equal(writes.length, 0);
|
|
88
|
+
form.formState.title = "Public book";
|
|
89
|
+
await form.addEdit.submit();
|
|
90
|
+
assert.deepEqual(writes, [{ title: "Public book" }]);
|
|
91
|
+
assert.equal(router.currentRoute.value.path, "/books");
|
|
92
|
+
await list.records.reload();
|
|
93
|
+
assert.deepEqual(list.records.items, [{ id: "1", title: "Public book" }]);
|
|
94
|
+
assert.deepEqual(Object.keys(resource.operations), ["list", "view", "create"]);
|
|
95
|
+
assert.equal(Object.hasOwn(resource.schema, "userId"), false);
|
|
96
|
+
} finally {
|
|
97
|
+
queryClient.clear();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const { default: BookNewPage } = await import("../patterns/crud-screen-set/example/books/BookNewPage.vue");
|
|
101
|
+
const { default: BookEditPage } = await import("../patterns/crud-screen-set/example/books/BookEditPage.vue");
|
|
102
|
+
hooks.deregister();
|
|
103
|
+
|
|
17
104
|
test("CRUD screen pattern is thin app-owned source over shared public screens", async () => {
|
|
18
105
|
const files = (await readdir(exampleRoot)).sort();
|
|
19
106
|
const sources = await Promise.all(files.map((file) => readFile(path.join(exampleRoot, file), "utf8")));
|
|
@@ -58,3 +145,34 @@ test("CRUD screen pattern is thin app-owned source over shared public screens",
|
|
|
58
145
|
}
|
|
59
146
|
}
|
|
60
147
|
});
|
|
148
|
+
|
|
149
|
+
test("published New/Edit page setup runs the real CRUD form composables with distinct cache keys", async () => {
|
|
150
|
+
for (const [Page, routePath, marker] of [[BookNewPage, "/books/new", "new"], [BookEditPage, "/books/42/edit", "42"]]) {
|
|
151
|
+
const router = createRouter({
|
|
152
|
+
history: createMemoryHistory(),
|
|
153
|
+
routes: [{ path: "/books/new", component: { render: () => h("div") } }, { path: "/books/:bookId/edit", component: { render: () => h("div") } }]
|
|
154
|
+
});
|
|
155
|
+
await router.push(routePath);
|
|
156
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
157
|
+
let screen;
|
|
158
|
+
const app = createSSRApp({
|
|
159
|
+
...Page,
|
|
160
|
+
setup(props, context) {
|
|
161
|
+
screen = Page.setup(props, context).screen;
|
|
162
|
+
return () => h("div");
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
app.use(router);
|
|
166
|
+
app.use(VueQueryPlugin, { queryClient });
|
|
167
|
+
app.provide("jskit.shell-web.runtime.web-placement.client", {
|
|
168
|
+
getPlacements: () => [],
|
|
169
|
+
getContext: () => ({ surfaceConfig: { defaultSurfaceId: "app", enabledSurfaceIds: ["app"], surfacesById: { app: { id: "app", enabled: true, routeBase: "/", pagesRoot: "", requiresWorkspace: false } } } })
|
|
170
|
+
});
|
|
171
|
+
await renderToString(app);
|
|
172
|
+
assert.equal(screen.addEdit.loadError, "");
|
|
173
|
+
if (marker === "new") assert.equal(screen.addEdit.isSubmitDisabled, false);
|
|
174
|
+
const keys = queryClient.getQueryCache().getAll().map((query) => query.queryKey);
|
|
175
|
+
assert.equal(keys.some((key) => key[0] === "crud" && key[1] === "books" && key.includes(marker)), true);
|
|
176
|
+
queryClient.clear();
|
|
177
|
+
}
|
|
178
|
+
});
|