@jskit-ai/users-web 0.1.31 → 0.1.33

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.
@@ -1,10 +1,3 @@
1
- import {
2
- CLIENT_MODULE_ROUTER_TOKEN,
3
- CLIENT_MODULE_VUE_APP_TOKEN
4
- } from "@jskit-ai/kernel/client/moduleBootstrap";
5
- import { WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN } from "@jskit-ai/shell-web/client/placement";
6
- import { REALTIME_SOCKET_CLIENT_TOKEN } from "@jskit-ai/realtime/client/tokens";
7
- import { USERS_BOOTSTRAP_CHANGED_EVENT } from "@jskit-ai/users-core/shared/events/usersEvents";
8
1
  import {
9
2
  findWorkspaceBySlug,
10
3
  normalizeWorkspaceList,
@@ -20,8 +13,6 @@ import {
20
13
  } from "../lib/theme.js";
21
14
  import { createBootstrapPlacementRouteGuards } from "./bootstrapPlacementRouteGuards.js";
22
15
  import {
23
- BOOTSTRAP_PLACEMENT_SOURCE,
24
- USERS_WEB_BOOTSTRAP_PLACEMENT_RUNTIME_TOKEN,
25
16
  WORKSPACE_BOOTSTRAP_STATUS_ERROR,
26
17
  WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN,
27
18
  WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND,
@@ -44,7 +35,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
44
35
  if (!app || typeof app.has !== "function" || typeof app.make !== "function") {
45
36
  throw new Error("createBootstrapPlacementRuntime requires application has()/make().");
46
37
  }
47
- if (!app.has(WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN)) {
38
+ if (!app.has("runtime.web-placement.client")) {
48
39
  throw new Error("createBootstrapPlacementRuntime requires shell-web placement runtime.");
49
40
  }
50
41
  if (typeof fetchBootstrap !== "function") {
@@ -52,12 +43,12 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
52
43
  }
53
44
 
54
45
  const runtimeLogger = logger || createProviderLogger(app);
55
- const placementRuntime = app.make(WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN);
56
- const router = app.has(CLIENT_MODULE_ROUTER_TOKEN) ? app.make(CLIENT_MODULE_ROUTER_TOKEN) : null;
46
+ const placementRuntime = app.make("runtime.web-placement.client");
47
+ const router = app.has("jskit.client.router") ? app.make("jskit.client.router") : null;
57
48
  let vuetifyThemeController = resolveVuetifyThemeController(
58
- app.has(CLIENT_MODULE_VUE_APP_TOKEN) ? app.make(CLIENT_MODULE_VUE_APP_TOKEN) : null
49
+ app.has("jskit.client.vue.app") ? app.make("jskit.client.vue.app") : null
59
50
  );
60
- const socket = app.has(REALTIME_SOCKET_CLIENT_TOKEN) ? app.make(REALTIME_SOCKET_CLIENT_TOKEN) : null;
51
+ const socket = app.has("runtime.realtime.client.socket") ? app.make("runtime.realtime.client.socket") : null;
61
52
  const cleanup = [];
62
53
  let refreshQueue = Promise.resolve();
63
54
  let shutdownRequested = false;
@@ -78,7 +69,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
78
69
  routeGuards.shutdown();
79
70
  });
80
71
 
81
- function setWorkspaceBootstrapStatus(workspaceSlug = "", status = "", source = BOOTSTRAP_PLACEMENT_SOURCE) {
72
+ function setWorkspaceBootstrapStatus(workspaceSlug = "", status = "", source = "users-web.bootstrap-placement") {
82
73
  const workspaceSlugKey = normalizeWorkspaceSlugKey(workspaceSlug);
83
74
  const normalizedStatus = normalizeWorkspaceBootstrapStatus(status);
84
75
  if (!workspaceSlugKey || !normalizedStatus) {
@@ -103,7 +94,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
103
94
  const payload = Object.freeze({
104
95
  workspaceSlug: workspaceSlugKey,
105
96
  status: normalizedStatus,
106
- source: String(source || BOOTSTRAP_PLACEMENT_SOURCE).trim() || BOOTSTRAP_PLACEMENT_SOURCE
97
+ source: String(source || "users-web.bootstrap-placement").trim() || "users-web.bootstrap-placement"
107
98
  });
108
99
  for (const listener of workspaceBootstrapStatusListeners) {
109
100
  try {
@@ -131,7 +122,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
131
122
  };
132
123
  }
133
124
 
134
- function writePlacementContext(payload = {}, state = {}, source = BOOTSTRAP_PLACEMENT_SOURCE) {
125
+ function writePlacementContext(payload = {}, state = {}, source = "users-web.bootstrap-placement") {
135
126
  const availableWorkspaces = normalizeWorkspaceList(payload?.workspaces);
136
127
  const currentWorkspace = findWorkspaceBySlug(availableWorkspaces, state.workspaceSlug);
137
128
  const workspaceSettings =
@@ -162,7 +153,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
162
153
  applyWorkspaceColorFromPlacementContext("write");
163
154
  }
164
155
 
165
- function clearPlacementContext(source = BOOTSTRAP_PLACEMENT_SOURCE) {
156
+ function clearPlacementContext(source = "users-web.bootstrap-placement") {
166
157
  placementRuntime.setContext(
167
158
  {
168
159
  workspace: null,
@@ -186,11 +177,11 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
186
177
  if (vuetifyThemeController) {
187
178
  return vuetifyThemeController;
188
179
  }
189
- if (!app.has(CLIENT_MODULE_VUE_APP_TOKEN)) {
180
+ if (!app.has("jskit.client.vue.app")) {
190
181
  return null;
191
182
  }
192
183
 
193
- vuetifyThemeController = resolveVuetifyThemeController(app.make(CLIENT_MODULE_VUE_APP_TOKEN));
184
+ vuetifyThemeController = resolveVuetifyThemeController(app.make("jskit.client.vue.app"));
194
185
  return vuetifyThemeController;
195
186
  }
196
187
 
@@ -267,7 +258,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
267
258
  }
268
259
 
269
260
  const stateAtStart = resolveRouteState(placementRuntime, router);
270
- const source = `${BOOTSTRAP_PLACEMENT_SOURCE}.${String(reason || "manual").trim() || "manual"}`;
261
+ const source = `users-web.bootstrap-placement.${String(reason || "manual").trim() || "manual"}`;
271
262
  try {
272
263
  const payload = await fetchBootstrap(stateAtStart.workspaceSlug);
273
264
  const stateAtApply = resolveRouteState(placementRuntime, router);
@@ -433,10 +424,10 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
433
424
  const handleBootstrapChanged = () => {
434
425
  void queueRefresh("realtime");
435
426
  };
436
- socket.on(USERS_BOOTSTRAP_CHANGED_EVENT, handleBootstrapChanged);
427
+ socket.on("users.bootstrap.changed", handleBootstrapChanged);
437
428
  cleanup.push(() => {
438
429
  if (typeof socket.off === "function") {
439
- socket.off(USERS_BOOTSTRAP_CHANGED_EVENT, handleBootstrapChanged);
430
+ socket.off("users.bootstrap.changed", handleBootstrapChanged);
440
431
  }
441
432
  });
442
433
  }
@@ -463,7 +454,6 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
463
454
  }
464
455
 
465
456
  export {
466
- USERS_WEB_BOOTSTRAP_PLACEMENT_RUNTIME_TOKEN,
467
457
  WORKSPACE_BOOTSTRAP_STATUS_RESOLVED,
468
458
  WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND,
469
459
  WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN,
@@ -1,5 +1,3 @@
1
- const USERS_WEB_BOOTSTRAP_PLACEMENT_RUNTIME_TOKEN = "users.web.bootstrap-placement.runtime";
2
- const BOOTSTRAP_PLACEMENT_SOURCE = "users-web.bootstrap-placement";
3
1
  const WORKSPACE_BOOTSTRAP_STATUS_RESOLVED = "resolved";
4
2
  const WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND = "not_found";
5
3
  const WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN = "forbidden";
@@ -18,9 +16,7 @@ const WORKSPACE_BOOTSTRAP_STATUSES = new Set([
18
16
  ]);
19
17
 
20
18
  export {
21
- BOOTSTRAP_PLACEMENT_SOURCE,
22
19
  SHELL_GUARD_EVALUATOR_KEY,
23
- USERS_WEB_BOOTSTRAP_PLACEMENT_RUNTIME_TOKEN,
24
20
  WORKSPACE_BOOTSTRAP_STATUSES,
25
21
  WORKSPACE_BOOTSTRAP_STATUS_ERROR,
26
22
  WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN,
@@ -0,0 +1,75 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { ref } from "vue";
4
+ import { createAddEditUiRuntime } from "../src/client/composables/addEditUiRuntime.js";
5
+
6
+ test("createAddEditUiRuntime resolves api/list/cancel paths from route params", () => {
7
+ const runtime = createAddEditUiRuntime({
8
+ recordIdParam: "addressId",
9
+ routeParams: ref({
10
+ contactId: "7",
11
+ addressId: "42"
12
+ }),
13
+ routePath: ref("/contacts/7/addresses/new"),
14
+ apiUrlTemplate: "/crud/contacts/:contactId/addresses/:addressId",
15
+ viewUrlTemplate: "../:addressId",
16
+ listUrlTemplate: ".."
17
+ });
18
+
19
+ assert.equal(runtime.recordId.value, "42");
20
+ assert.equal(runtime.apiSuffix.value, "/crud/contacts/7/addresses/42");
21
+ assert.equal(runtime.listUrl.value, "/contacts/7/addresses");
22
+ assert.equal(runtime.cancelUrl.value, "/contacts/7/addresses/42");
23
+ assert.equal(runtime.resolveParams("../:addressId"), "/contacts/7/addresses/42");
24
+ });
25
+
26
+ test("createAddEditUiRuntime resolves view urls for saved payload ids with nested params", () => {
27
+ const runtime = createAddEditUiRuntime({
28
+ recordIdParam: "addressId",
29
+ routeParams: ref({
30
+ contactId: "7"
31
+ }),
32
+ viewUrlTemplate: "/contacts/:contactId/addresses/:addressId"
33
+ });
34
+
35
+ assert.equal(runtime.resolveSavedViewUrl({ id: 99 }), "/contacts/7/addresses/99");
36
+ });
37
+
38
+ test("createAddEditUiRuntime resolves edit-page relative list and cancel links", () => {
39
+ const runtime = createAddEditUiRuntime({
40
+ recordIdParam: "addressId",
41
+ routeParams: ref({
42
+ contactId: "7",
43
+ addressId: "42"
44
+ }),
45
+ routePath: ref("/contacts/7/addresses/42/edit"),
46
+ viewUrlTemplate: "..",
47
+ listUrlTemplate: "../.."
48
+ });
49
+
50
+ assert.equal(runtime.listUrl.value, "/contacts/7/addresses");
51
+ assert.equal(runtime.cancelUrl.value, "/contacts/7/addresses/42");
52
+ });
53
+
54
+ test("createAddEditUiRuntime supports custom saved-record selector", () => {
55
+ const runtime = createAddEditUiRuntime({
56
+ recordIdParam: "addressId",
57
+ routeParams: ref({
58
+ contactId: "7"
59
+ }),
60
+ viewUrlTemplate: "/contacts/:contactId/addresses/:addressId",
61
+ saveRecordIdSelector: (payload = {}) => payload.uuid
62
+ });
63
+
64
+ assert.equal(runtime.resolveSavedViewUrl({ uuid: "abc-123" }), "/contacts/7/addresses/abc-123");
65
+ });
66
+
67
+ test("createAddEditUiRuntime validates route parameter names", () => {
68
+ assert.throws(
69
+ () =>
70
+ createAddEditUiRuntime({
71
+ recordIdParam: "record-id"
72
+ }),
73
+ /recordIdParam "record-id" is invalid/
74
+ );
75
+ });
@@ -1,12 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import {
4
- CLIENT_MODULE_ROUTER_TOKEN,
5
- CLIENT_MODULE_VUE_APP_TOKEN
6
- } from "@jskit-ai/kernel/client/moduleBootstrap";
7
- import { WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN } from "@jskit-ai/shell-web/client/placement";
8
- import { REALTIME_SOCKET_CLIENT_TOKEN } from "@jskit-ai/realtime/client/tokens";
9
- import { USERS_BOOTSTRAP_CHANGED_EVENT } from "@jskit-ai/users-core/shared/events/usersEvents";
10
3
  import { resolveWorkspaceThemePalette } from "@jskit-ai/users-core/shared/settings";
11
4
  import { ThemeSymbol } from "vuetify/lib/composables/theme.js";
12
5
  import {
@@ -244,8 +237,8 @@ test("bootstrap placement runtime writes user/workspace/permissions into placeme
244
237
  const fetchCalls = [];
245
238
  const runtime = createBootstrapPlacementRuntime({
246
239
  app: createAppStub({
247
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
248
- [CLIENT_MODULE_ROUTER_TOKEN]: router
240
+ ["runtime.web-placement.client"]: placementRuntime,
241
+ ["jskit.client.router"]: router
249
242
  }),
250
243
  fetchBootstrap: async (workspaceSlug) => {
251
244
  fetchCalls.push(workspaceSlug);
@@ -304,8 +297,8 @@ test("bootstrap placement runtime resolves workspace slug from pathname when sur
304
297
  const fetchCalls = [];
305
298
  const runtime = createBootstrapPlacementRuntime({
306
299
  app: createAppStub({
307
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
308
- [CLIENT_MODULE_ROUTER_TOKEN]: router
300
+ ["runtime.web-placement.client"]: placementRuntime,
301
+ ["jskit.client.router"]: router
309
302
  }),
310
303
  fetchBootstrap: async (workspaceSlug) => {
311
304
  fetchCalls.push(workspaceSlug);
@@ -348,8 +341,8 @@ test("bootstrap placement runtime does not mutate placement auth context", async
348
341
  const router = createRouterStub("/w/acme/dashboard");
349
342
  const runtime = createBootstrapPlacementRuntime({
350
343
  app: createAppStub({
351
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
352
- [CLIENT_MODULE_ROUTER_TOKEN]: router
344
+ ["runtime.web-placement.client"]: placementRuntime,
345
+ ["jskit.client.router"]: router
353
346
  }),
354
347
  fetchBootstrap: async () => {
355
348
  return {
@@ -385,9 +378,9 @@ test("bootstrap placement runtime refetches on route changes and users.bootstrap
385
378
  const fetchCalls = [];
386
379
  const runtime = createBootstrapPlacementRuntime({
387
380
  app: createAppStub({
388
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
389
- [CLIENT_MODULE_ROUTER_TOKEN]: router,
390
- [REALTIME_SOCKET_CLIENT_TOKEN]: socket
381
+ ["runtime.web-placement.client"]: placementRuntime,
382
+ ["jskit.client.router"]: router,
383
+ ["runtime.realtime.client.socket"]: socket
391
384
  }),
392
385
  fetchBootstrap: async (workspaceSlug) => {
393
386
  fetchCalls.push(workspaceSlug);
@@ -424,7 +417,7 @@ test("bootstrap placement runtime refetches on route changes and users.bootstrap
424
417
  await flushTasks();
425
418
  assert.deepEqual(fetchCalls, ["acme", "zen"]);
426
419
 
427
- socket.emit(USERS_BOOTSTRAP_CHANGED_EVENT, {});
420
+ socket.emit("users.bootstrap.changed", {});
428
421
  await flushTasks();
429
422
  assert.deepEqual(fetchCalls, ["acme", "zen", "zen"]);
430
423
  });
@@ -435,8 +428,8 @@ test("bootstrap placement runtime refetches when auth context changes", async ()
435
428
  const fetchCalls = [];
436
429
  const runtime = createBootstrapPlacementRuntime({
437
430
  app: createAppStub({
438
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
439
- [CLIENT_MODULE_ROUTER_TOKEN]: router
431
+ ["runtime.web-placement.client"]: placementRuntime,
432
+ ["jskit.client.router"]: router
440
433
  }),
441
434
  fetchBootstrap: async (workspaceSlug) => {
442
435
  fetchCalls.push(workspaceSlug);
@@ -497,9 +490,9 @@ test("bootstrap placement runtime applies persisted theme preference for unauthe
497
490
  };
498
491
  const runtime = createBootstrapPlacementRuntime({
499
492
  app: createAppStub({
500
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
501
- [CLIENT_MODULE_ROUTER_TOKEN]: router,
502
- [CLIENT_MODULE_VUE_APP_TOKEN]: createVueAppWithThemeController(themeController)
493
+ ["runtime.web-placement.client"]: placementRuntime,
494
+ ["jskit.client.router"]: router,
495
+ ["jskit.client.vue.app"]: createVueAppWithThemeController(themeController)
503
496
  }),
504
497
  fetchBootstrap: async () => {
505
498
  return {
@@ -532,10 +525,10 @@ test("bootstrap placement runtime reapplies theme when bootstrap payload changes
532
525
  let fetchCount = 0;
533
526
  const runtime = createBootstrapPlacementRuntime({
534
527
  app: createAppStub({
535
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
536
- [CLIENT_MODULE_ROUTER_TOKEN]: router,
537
- [REALTIME_SOCKET_CLIENT_TOKEN]: socket,
538
- [CLIENT_MODULE_VUE_APP_TOKEN]: createVueAppWithThemeController(themeController)
528
+ ["runtime.web-placement.client"]: placementRuntime,
529
+ ["jskit.client.router"]: router,
530
+ ["runtime.realtime.client.socket"]: socket,
531
+ ["jskit.client.vue.app"]: createVueAppWithThemeController(themeController)
539
532
  }),
540
533
  fetchBootstrap: async (workspaceSlug) => {
541
534
  fetchCount += 1;
@@ -563,7 +556,7 @@ test("bootstrap placement runtime reapplies theme when bootstrap payload changes
563
556
  await runtime.initialize();
564
557
  assert.equal(themeController.global.name.value, "workspace-dark");
565
558
 
566
- socket.emit(USERS_BOOTSTRAP_CHANGED_EVENT, {});
559
+ socket.emit("users.bootstrap.changed", {});
567
560
  await flushTasks();
568
561
  assert.equal(themeController.global.name.value, "workspace-light");
569
562
  });
@@ -574,9 +567,9 @@ test("bootstrap placement runtime applies workspace palette via Vuetify workspac
574
567
  const themeController = createVuetifyThemeController("light");
575
568
  const runtime = createBootstrapPlacementRuntime({
576
569
  app: createAppStub({
577
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
578
- [CLIENT_MODULE_ROUTER_TOKEN]: router,
579
- [CLIENT_MODULE_VUE_APP_TOKEN]: createVueAppWithThemeController(themeController)
570
+ ["runtime.web-placement.client"]: placementRuntime,
571
+ ["jskit.client.router"]: router,
572
+ ["jskit.client.vue.app"]: createVueAppWithThemeController(themeController)
580
573
  }),
581
574
  fetchBootstrap: async (workspaceSlug) => {
582
575
  return {
@@ -646,8 +639,8 @@ test("bootstrap placement runtime marks workspace slug as not_found and clears w
646
639
 
647
640
  const runtime = createBootstrapPlacementRuntime({
648
641
  app: createAppStub({
649
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
650
- [CLIENT_MODULE_ROUTER_TOKEN]: createRouterStub("/w/acme/dashboard")
642
+ ["runtime.web-placement.client"]: placementRuntime,
643
+ ["jskit.client.router"]: createRouterStub("/w/acme/dashboard")
651
644
  }),
652
645
  fetchBootstrap: async () => {
653
646
  const error = new Error("Workspace not found.");
@@ -674,8 +667,8 @@ test("bootstrap placement runtime updates status per workspace slug across route
674
667
  const router = createRouterStub("/w/acme/dashboard");
675
668
  const runtime = createBootstrapPlacementRuntime({
676
669
  app: createAppStub({
677
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
678
- [CLIENT_MODULE_ROUTER_TOKEN]: router
670
+ ["runtime.web-placement.client"]: placementRuntime,
671
+ ["jskit.client.router"]: router
679
672
  }),
680
673
  fetchBootstrap: async (workspaceSlug) => {
681
674
  if (workspaceSlug === "zen") {
@@ -721,8 +714,8 @@ test("bootstrap placement runtime uses requestedWorkspace status and keeps globa
721
714
  const router = createRouterStub("/w/tonymobily");
722
715
  const runtime = createBootstrapPlacementRuntime({
723
716
  app: createAppStub({
724
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
725
- [CLIENT_MODULE_ROUTER_TOKEN]: router
717
+ ["runtime.web-placement.client"]: placementRuntime,
718
+ ["jskit.client.router"]: router
726
719
  }),
727
720
  fetchBootstrap: async () => {
728
721
  return {
@@ -763,8 +756,8 @@ test("bootstrap placement runtime uses requestedWorkspace=not_found without forc
763
756
  const router = createRouterStub("/w/missing");
764
757
  const runtime = createBootstrapPlacementRuntime({
765
758
  app: createAppStub({
766
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
767
- [CLIENT_MODULE_ROUTER_TOKEN]: router
759
+ ["runtime.web-placement.client"]: placementRuntime,
760
+ ["jskit.client.router"]: router
768
761
  }),
769
762
  fetchBootstrap: async () => {
770
763
  return {
@@ -811,8 +804,8 @@ test("bootstrap placement runtime guard wrapper preserves delegated deny outcome
811
804
 
812
805
  const runtime = createBootstrapPlacementRuntime({
813
806
  app: createAppStub({
814
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
815
- [CLIENT_MODULE_ROUTER_TOKEN]: router
807
+ ["runtime.web-placement.client"]: placementRuntime,
808
+ ["jskit.client.router"]: router
816
809
  }),
817
810
  fetchBootstrap: async () => {
818
811
  return {
@@ -854,8 +847,8 @@ test("bootstrap placement runtime guard wrapper blocks forbidden workspace route
854
847
 
855
848
  const runtime = createBootstrapPlacementRuntime({
856
849
  app: createAppStub({
857
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
858
- [CLIENT_MODULE_ROUTER_TOKEN]: router
850
+ ["runtime.web-placement.client"]: placementRuntime,
851
+ ["jskit.client.router"]: router
859
852
  }),
860
853
  fetchBootstrap: async () => {
861
854
  return {
@@ -899,8 +892,8 @@ test("bootstrap placement runtime guard wrapper redirects nested not_found route
899
892
  const router = createRouterStub("/w/acme/dashboard");
900
893
  const runtime = createBootstrapPlacementRuntime({
901
894
  app: createAppStub({
902
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
903
- [CLIENT_MODULE_ROUTER_TOKEN]: router
895
+ ["runtime.web-placement.client"]: placementRuntime,
896
+ ["jskit.client.router"]: router
904
897
  }),
905
898
  fetchBootstrap: async () => {
906
899
  const error = new Error("Not found");
@@ -957,8 +950,8 @@ test("bootstrap placement runtime redirects admin nested route to admin root whe
957
950
  const router = createRouterStub("/w/acme/admin/workspace/settings");
958
951
  const runtime = createBootstrapPlacementRuntime({
959
952
  app: createAppStub({
960
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
961
- [CLIENT_MODULE_ROUTER_TOKEN]: router
953
+ ["runtime.web-placement.client"]: placementRuntime,
954
+ ["jskit.client.router"]: router
962
955
  }),
963
956
  fetchBootstrap: async () => {
964
957
  const error = new Error("Not found");
@@ -977,8 +970,8 @@ test("bootstrap placement runtime redirects forbidden workspace route to workspa
977
970
  const router = createRouterStub("/w/acme/admin/workspace/settings?tab=general");
978
971
  const runtime = createBootstrapPlacementRuntime({
979
972
  app: createAppStub({
980
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
981
- [CLIENT_MODULE_ROUTER_TOKEN]: router
973
+ ["runtime.web-placement.client"]: placementRuntime,
974
+ ["jskit.client.router"]: router
982
975
  }),
983
976
  fetchBootstrap: async () => {
984
977
  const error = new Error("Forbidden");
@@ -1032,8 +1025,8 @@ test("bootstrap placement runtime enforces surface access policies after bootstr
1032
1025
  const router = createRouterStub("/console");
1033
1026
  const runtime = createBootstrapPlacementRuntime({
1034
1027
  app: createAppStub({
1035
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
1036
- [CLIENT_MODULE_ROUTER_TOKEN]: router
1028
+ ["runtime.web-placement.client"]: placementRuntime,
1029
+ ["jskit.client.router"]: router
1037
1030
  }),
1038
1031
  fetchBootstrap: async () => {
1039
1032
  return {
@@ -1059,8 +1052,8 @@ test("bootstrap placement runtime captures guard evaluator assignments after ini
1059
1052
  const router = createRouterStub("/w/acme/dashboard");
1060
1053
  const runtime = createBootstrapPlacementRuntime({
1061
1054
  app: createAppStub({
1062
- [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
1063
- [CLIENT_MODULE_ROUTER_TOKEN]: router
1055
+ ["runtime.web-placement.client"]: placementRuntime,
1056
+ ["jskit.client.router"]: router
1064
1057
  }),
1065
1058
  fetchBootstrap: async () => {
1066
1059
  return {
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { ref } from "vue";
4
+ import { createListUiRuntime } from "../src/client/composables/listUiRuntime.js";
5
+
6
+ test("createListUiRuntime resolves row keys and relative route templates from string record ids", () => {
7
+ const items = ref([{ uuid: "abc 123" }]);
8
+ const runtime = createListUiRuntime({
9
+ items,
10
+ isInitialLoading: ref(false),
11
+ recordIdParam: "contactId",
12
+ recordIdSelector: (item) => item.uuid,
13
+ routePath: ref("/w/acme/admin/contacts"),
14
+ viewUrlTemplate: "./:contactId",
15
+ editUrlTemplate: "./:contactId/edit"
16
+ });
17
+
18
+ assert.equal(runtime.actionColumnCount, 2);
19
+ assert.equal(runtime.resolveRowKey(items.value[0], 0), "abc 123");
20
+ assert.equal(runtime.resolveParams("./new"), "/w/acme/admin/contacts/new");
21
+ assert.equal(runtime.resolveViewUrl(items.value[0]), "/w/acme/admin/contacts/abc%20123");
22
+ assert.equal(runtime.resolveEditUrl(items.value[0]), "/w/acme/admin/contacts/abc%20123/edit");
23
+ });
24
+
25
+ test("createListUiRuntime resolves templates that depend on existing route params", () => {
26
+ const runtime = createListUiRuntime({
27
+ items: ref([{ id: 42 }]),
28
+ isInitialLoading: ref(false),
29
+ recordIdParam: "addressId",
30
+ recordIdSelector: (item) => item.id,
31
+ routeParams: ref({ contactId: "7" }),
32
+ viewUrlTemplate: "/contacts/:contactId/addresses/:addressId",
33
+ editUrlTemplate: "/contacts/:contactId/addresses/:addressId/edit"
34
+ });
35
+
36
+ assert.equal(runtime.resolveViewUrl({ id: 42 }), "/contacts/7/addresses/42");
37
+ assert.equal(runtime.resolveEditUrl({ id: 42 }), "/contacts/7/addresses/42/edit");
38
+ });
39
+
40
+ test("createListUiRuntime returns empty urls and fallback row keys when record id is missing", () => {
41
+ const runtime = createListUiRuntime({
42
+ items: ref([]),
43
+ isInitialLoading: ref(false),
44
+ recordIdParam: "recordId",
45
+ recordIdSelector: () => "",
46
+ viewUrlTemplate: "./:recordId",
47
+ editUrlTemplate: "./:recordId/edit"
48
+ });
49
+
50
+ assert.equal(runtime.resolveRowKey({}, 5), "row-5");
51
+ assert.equal(runtime.resolveViewUrl({}), "");
52
+ assert.equal(runtime.resolveEditUrl({}), "");
53
+ });
54
+
55
+ test("createListUiRuntime computes list skeleton state from loading and item count", () => {
56
+ const items = ref([]);
57
+ const isInitialLoading = ref(true);
58
+ const runtime = createListUiRuntime({
59
+ items,
60
+ isInitialLoading
61
+ });
62
+
63
+ assert.equal(runtime.showListSkeleton.value, true);
64
+ items.value = [{}];
65
+ assert.equal(runtime.showListSkeleton.value, false);
66
+ items.value = [];
67
+ isInitialLoading.value = false;
68
+ assert.equal(runtime.showListSkeleton.value, false);
69
+ });
70
+
71
+ test("createListUiRuntime validates route parameter names", () => {
72
+ assert.throws(
73
+ () =>
74
+ createListUiRuntime({
75
+ items: ref([]),
76
+ isInitialLoading: ref(false),
77
+ recordIdParam: "record-id"
78
+ }),
79
+ /recordIdParam "record-id" is invalid/
80
+ );
81
+ });
@@ -0,0 +1,141 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { reactive } from "vue";
4
+ import { Type } from "typebox";
5
+ import {
6
+ normalizeCrudFormFields,
7
+ createCrudFormModel,
8
+ buildCrudFormPayload,
9
+ applyCrudPayloadToForm,
10
+ resolveCrudFieldErrors,
11
+ parseCrudResourceOperationInput
12
+ } from "../src/client/composables/crudSchemaFormHelpers.js";
13
+
14
+ test("normalizeCrudFormFields trims keys, removes invalid entries, and deduplicates", () => {
15
+ const fields = normalizeCrudFormFields([
16
+ { key: " name ", type: "string" },
17
+ { key: "name", type: "string" },
18
+ { key: "", type: "string" },
19
+ null,
20
+ { key: "active", type: "boolean" }
21
+ ]);
22
+
23
+ assert.deepEqual(fields.map((field) => field.key), ["name", "active"]);
24
+ });
25
+
26
+ test("createCrudFormModel resolves defaults and supports explicit initial values", () => {
27
+ const model = createCrudFormModel([
28
+ { key: "name", type: "string" },
29
+ { key: "active", type: "boolean" },
30
+ { key: "role", type: "string", initialValue: "member" }
31
+ ]);
32
+
33
+ assert.deepEqual(model, {
34
+ name: "",
35
+ active: false,
36
+ role: "member"
37
+ });
38
+ });
39
+
40
+ test("buildCrudFormPayload normalizes booleans and numbers while skipping empty numeric values", () => {
41
+ const payload = buildCrudFormPayload(
42
+ [
43
+ { key: "name", type: "string" },
44
+ { key: "active", type: "boolean" },
45
+ { key: "age", type: "integer" },
46
+ { key: "score", type: "number" }
47
+ ],
48
+ {
49
+ name: "Ada",
50
+ active: 1,
51
+ age: "42",
52
+ score: ""
53
+ }
54
+ );
55
+
56
+ assert.deepEqual(payload, {
57
+ name: "Ada",
58
+ active: true,
59
+ age: 42
60
+ });
61
+ });
62
+
63
+ test("applyCrudPayloadToForm maps payload values into reactive form model", () => {
64
+ const form = reactive({
65
+ name: "",
66
+ active: false,
67
+ age: ""
68
+ });
69
+ applyCrudPayloadToForm(
70
+ [
71
+ { key: "name", type: "string" },
72
+ { key: "active", type: "boolean" },
73
+ { key: "age", type: "integer" }
74
+ ],
75
+ form,
76
+ {
77
+ name: "Grace",
78
+ active: 1,
79
+ age: 33
80
+ }
81
+ );
82
+
83
+ assert.deepEqual(form, {
84
+ name: "Grace",
85
+ active: true,
86
+ age: "33"
87
+ });
88
+ });
89
+
90
+ test("resolveCrudFieldErrors returns Vuetify-compatible error arrays", () => {
91
+ assert.deepEqual(resolveCrudFieldErrors({ name: "Name is required." }, "name"), ["Name is required."]);
92
+ assert.deepEqual(resolveCrudFieldErrors({ name: "Name is required." }, "email"), []);
93
+ });
94
+
95
+ test("parseCrudResourceOperationInput validates and normalizes operation body payloads", () => {
96
+ const resource = {
97
+ operations: {
98
+ create: {
99
+ bodyValidator: {
100
+ schema: Type.Object(
101
+ {
102
+ name: Type.String({ minLength: 1 }),
103
+ age: Type.Integer({ minimum: 1 })
104
+ },
105
+ { additionalProperties: false }
106
+ ),
107
+ normalize(payload = {}) {
108
+ return {
109
+ name: String(payload.name || "").trim(),
110
+ age: Number(payload.age)
111
+ };
112
+ }
113
+ }
114
+ }
115
+ }
116
+ };
117
+
118
+ const validResult = parseCrudResourceOperationInput({
119
+ resource,
120
+ operationName: "create",
121
+ rawPayload: {
122
+ name: " Ada ",
123
+ age: "2"
124
+ }
125
+ });
126
+ assert.equal(validResult.ok, true);
127
+ assert.deepEqual(validResult.value, {
128
+ name: "Ada",
129
+ age: 2
130
+ });
131
+
132
+ const invalidResult = parseCrudResourceOperationInput({
133
+ resource,
134
+ operationName: "create",
135
+ rawPayload: {
136
+ name: " ",
137
+ age: "0"
138
+ }
139
+ });
140
+ assert.equal(invalidResult.ok, false);
141
+ });