@jskit-ai/http-web 0.1.78 → 0.1.79

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/http-web",
3
- "version": "0.1.78",
3
+ "version": "0.1.79",
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.175",
47
+ "@jskit-ai/resource-crud-core": "0.1.176",
48
48
  "@mdi/js": "^7.4.47"
49
49
  },
50
50
  "peerDependencies": {
51
- "@jskit-ai/http-runtime": "0.1.231",
52
- "@jskit-ai/kernel": "0.1.233",
53
- "@jskit-ai/realtime": "0.1.230",
54
- "@jskit-ai/shell-web": "0.1.237",
51
+ "@jskit-ai/http-runtime": "0.1.232",
52
+ "@jskit-ai/kernel": "0.1.234",
53
+ "@jskit-ai/realtime": "0.1.231",
54
+ "@jskit-ai/shell-web": "0.1.238",
55
55
  "@tanstack/vue-query": "^5.90.5",
56
56
  "vue": "^3.5.13",
57
57
  "vue-router": "^5.0.4",
@@ -63,6 +63,21 @@ filters and actions explicit without adding empty screen machinery elsewhere.
63
63
 
64
64
  ## Variation points
65
65
 
66
+ `useCrudAddEditScreen()` requires `addEditOptions.queryKeyFactory` even when
67
+ `readEnabled` is false. Include the resource, surface, route scope and record
68
+ id (or a distinct new-record marker) so form cache entries stay separate.
69
+ The New/Edit examples supply it; list/view screen wrappers derive their
70
+ standard keys from `resourceNamespace`.
71
+
72
+ Ownership and permission-bootstrap mode are different options. For a public
73
+ resource on a public surface, use `ownershipFilter: "public"` in form options
74
+ or lower-level request composables. List/view screen wrappers derive request
75
+ scope from the configured surface and do not expose this option. Keep the
76
+ default `access: "auto"` when there are no permission
77
+ requirements. `access` accepts only `auto`, `always`, or `never`; `public` is
78
+ not an access mode. `always` requires bootstrap permissions even when there
79
+ are no permission requirements. Do not add accounts to satisfy that override.
80
+
66
81
  Change routes, labels, visible fields, field components, filters, query params,
67
82
  realtime events, delete availability, and action definitions. Use the screen
68
83
  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",
@@ -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,83 @@ 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
+ await list.records.reload();
92
+ assert.deepEqual(list.records.items, [{ id: "1", title: "Public book" }]);
93
+ assert.deepEqual(Object.keys(resource.operations), ["list", "view", "create"]);
94
+ assert.equal(Object.hasOwn(resource.schema, "userId"), false);
95
+ } finally {
96
+ queryClient.clear();
97
+ }
98
+ });
99
+ const { default: BookNewPage } = await import("../patterns/crud-screen-set/example/books/BookNewPage.vue");
100
+ const { default: BookEditPage } = await import("../patterns/crud-screen-set/example/books/BookEditPage.vue");
101
+ hooks.deregister();
102
+
17
103
  test("CRUD screen pattern is thin app-owned source over shared public screens", async () => {
18
104
  const files = (await readdir(exampleRoot)).sort();
19
105
  const sources = await Promise.all(files.map((file) => readFile(path.join(exampleRoot, file), "utf8")));
@@ -58,3 +144,34 @@ test("CRUD screen pattern is thin app-owned source over shared public screens",
58
144
  }
59
145
  }
60
146
  });
147
+
148
+ test("published New/Edit page setup runs the real CRUD form composables with distinct cache keys", async () => {
149
+ for (const [Page, routePath, marker] of [[BookNewPage, "/books/new", "new"], [BookEditPage, "/books/42/edit", "42"]]) {
150
+ const router = createRouter({
151
+ history: createMemoryHistory(),
152
+ routes: [{ path: "/books/new", component: { render: () => h("div") } }, { path: "/books/:bookId/edit", component: { render: () => h("div") } }]
153
+ });
154
+ await router.push(routePath);
155
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
156
+ let screen;
157
+ const app = createSSRApp({
158
+ ...Page,
159
+ setup(props, context) {
160
+ screen = Page.setup(props, context).screen;
161
+ return () => h("div");
162
+ }
163
+ });
164
+ app.use(router);
165
+ app.use(VueQueryPlugin, { queryClient });
166
+ app.provide("jskit.shell-web.runtime.web-placement.client", {
167
+ getPlacements: () => [],
168
+ getContext: () => ({ surfaceConfig: { defaultSurfaceId: "app", enabledSurfaceIds: ["app"], surfacesById: { app: { id: "app", enabled: true, routeBase: "/", pagesRoot: "", requiresWorkspace: false } } } })
169
+ });
170
+ await renderToString(app);
171
+ assert.equal(screen.addEdit.loadError, "");
172
+ if (marker === "new") assert.equal(screen.addEdit.isSubmitDisabled, false);
173
+ const keys = queryClient.getQueryCache().getAll().map((query) => query.queryKey);
174
+ assert.equal(keys.some((key) => key[0] === "crud" && key[1] === "books" && key.includes(marker)), true);
175
+ queryClient.clear();
176
+ }
177
+ });