@jskit-ai/users-web 0.1.81 → 0.1.83

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 +51 -9
  2. package/package.json +21 -10
  3. package/src/client/bulkActions.js +47 -0
  4. package/src/client/components/AccountSettingsClientElement.vue +69 -24
  5. package/src/client/components/CrudAddEditScreen.vue +186 -0
  6. package/src/client/components/CrudListBulkActionSurface.vue +126 -0
  7. package/src/client/components/CrudListFilterSurface.vue +377 -0
  8. package/src/client/components/CrudListScreen.vue +434 -0
  9. package/src/client/components/CrudViewScreen.vue +186 -0
  10. package/src/client/components/ProfileClientElement.vue +19 -12
  11. package/src/client/composables/records/useAddEdit.js +23 -2
  12. package/src/client/composables/records/useCrudAddEdit.js +8 -0
  13. package/src/client/composables/records/useCrudList.js +11 -1
  14. package/src/client/composables/records/useCrudView.js +1 -0
  15. package/src/client/composables/records/useView.js +9 -2
  16. package/src/client/composables/runtime/operationUiHelpers.js +7 -3
  17. package/src/client/composables/runtime/useEndpointResource.js +20 -2
  18. package/src/client/composables/runtime/useUiFeedback.js +4 -2
  19. package/src/client/composables/support/resourceLoadStateHelpers.js +33 -1
  20. package/src/client/composables/useAccountSettingsRuntime.js +10 -1
  21. package/src/client/composables/useCrudAddEditScreen.js +88 -0
  22. package/src/client/composables/useCrudListBulkActions.js +147 -0
  23. package/src/client/composables/useCrudListScreen.js +107 -0
  24. package/src/client/composables/useCrudViewScreen.js +67 -0
  25. package/src/client/composables/usePagedCollection.js +6 -1
  26. package/src/client/composables/useRealtimeQueryInvalidation.js +26 -0
  27. package/src/client/filters.js +15 -0
  28. package/src/client/index.js +5 -0
  29. package/templates/src/components/account/settings/AccountSettingsNotificationsSection.vue +34 -8
  30. package/templates/src/components/account/settings/AccountSettingsPreferencesSection.vue +34 -8
  31. package/templates/src/components/account/settings/AccountSettingsProfileSection.vue +34 -8
  32. package/test/crudListBulkActionSurface.test.js +27 -0
  33. package/test/crudListFilterSurface.test.js +45 -0
  34. package/test/crudScreenComponents.test.js +62 -0
  35. package/test/errorIntentContract.test.js +31 -0
  36. package/test/exportsContract.test.js +11 -0
  37. package/test/requestTransportOptions.test.js +110 -1
  38. package/test/resourceLoadStateHelpers.test.js +35 -1
  39. package/test/settingsPlacementContract.test.js +61 -0
  40. package/test/useCrudListBulkActions.test.js +65 -0
@@ -1,11 +1,18 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
4
+ import { createSSRApp, h } from "vue";
5
+ import { renderToString } from "vue/server-renderer";
3
6
 
4
7
  import {
5
8
  buildEndpointReadRequestOptions,
6
- buildEndpointWriteRequestOptions
9
+ buildEndpointWriteRequestOptions,
10
+ useEndpointResource
7
11
  } from "../src/client/composables/runtime/useEndpointResource.js";
8
12
  import { buildListRequestOptions } from "../src/client/composables/runtime/useListCore.js";
13
+ import {
14
+ resolveOperationRealtimeOptions
15
+ } from "../src/client/composables/useRealtimeQueryInvalidation.js";
9
16
 
10
17
  test("endpoint read request options include transport only when provided", () => {
11
18
  assert.deepEqual(
@@ -140,3 +147,105 @@ test("list request options preserve explicit limit values", () => {
140
147
  }
141
148
  );
142
149
  });
150
+
151
+ test("operation realtime options use fallback events unless explicitly overridden", () => {
152
+ const fallbackRealtime = {
153
+ events: ["contacts.record.changed"]
154
+ };
155
+
156
+ assert.deepEqual(
157
+ resolveOperationRealtimeOptions({
158
+ fallbackRealtime
159
+ }),
160
+ {
161
+ events: ["contacts.record.changed"]
162
+ }
163
+ );
164
+
165
+ assert.deepEqual(
166
+ resolveOperationRealtimeOptions({
167
+ realtime: {
168
+ enabled: false
169
+ },
170
+ fallbackRealtime
171
+ }),
172
+ {
173
+ events: ["contacts.record.changed"],
174
+ enabled: false
175
+ }
176
+ );
177
+
178
+ assert.deepEqual(
179
+ resolveOperationRealtimeOptions({
180
+ realtime: {
181
+ event: "contacts.custom.changed"
182
+ },
183
+ fallbackRealtime
184
+ }),
185
+ {
186
+ event: "contacts.custom.changed"
187
+ }
188
+ );
189
+
190
+ assert.equal(
191
+ resolveOperationRealtimeOptions({
192
+ realtime: false,
193
+ fallbackRealtime
194
+ }),
195
+ null
196
+ );
197
+ });
198
+
199
+ test("endpoint resources invalidate their query key from configured realtime events", async () => {
200
+ const queryClient = new QueryClient();
201
+ const invalidations = [];
202
+ const socketHandlers = new Map();
203
+ const socket = {
204
+ on(eventName, handler) {
205
+ socketHandlers.set(eventName, handler);
206
+ },
207
+ off(eventName, handler) {
208
+ if (socketHandlers.get(eventName) === handler) {
209
+ socketHandlers.delete(eventName);
210
+ }
211
+ }
212
+ };
213
+ queryClient.invalidateQueries = async (options = {}) => {
214
+ invalidations.push(options);
215
+ };
216
+ const app = createSSRApp({
217
+ setup() {
218
+ useEndpointResource({
219
+ queryKey: ["today-workout-detail", "/api/today/workouts/2026-05-06"],
220
+ path: "/api/today/workouts/2026-05-06",
221
+ client: {
222
+ async request() {
223
+ return {};
224
+ }
225
+ },
226
+ realtime: {
227
+ event: "workout_set_logs.record.changed"
228
+ }
229
+ });
230
+ return () => h("div");
231
+ }
232
+ });
233
+ app.use(VueQueryPlugin, {
234
+ queryClient
235
+ });
236
+ app.provide("jskit.realtime.runtime.client.socket", socket);
237
+ await renderToString(app);
238
+
239
+ const handler = socketHandlers.get("workout_set_logs.record.changed");
240
+ assert.equal(typeof handler, "function");
241
+ handler({
242
+ entityId: "42"
243
+ });
244
+ await new Promise((resolve) => setImmediate(resolve));
245
+
246
+ assert.deepEqual(invalidations, [
247
+ {
248
+ queryKey: ["today-workout-detail", "/api/today/workouts/2026-05-06"]
249
+ }
250
+ ]);
251
+ });
@@ -1,7 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { ref } from "vue";
4
- import { hasResolvedQueryData } from "../src/client/composables/support/resourceLoadStateHelpers.js";
4
+ import {
5
+ hasResolvedQueryData,
6
+ mergeQueryMeta
7
+ } from "../src/client/composables/support/resourceLoadStateHelpers.js";
5
8
 
6
9
  test("hasResolvedQueryData returns true when the query succeeded", () => {
7
10
  const query = {
@@ -37,3 +40,34 @@ test("hasResolvedQueryData returns false when query and payload are unresolved",
37
40
  data: ref(null)
38
41
  }), false);
39
42
  });
43
+
44
+ test("mergeQueryMeta preserves caller metadata while adding JSKIT refresh policy", () => {
45
+ assert.deepEqual(
46
+ mergeQueryMeta(
47
+ {
48
+ staleTime: 1000,
49
+ meta: {
50
+ owner: "contacts",
51
+ jskit: {
52
+ feature: "list"
53
+ }
54
+ }
55
+ },
56
+ {
57
+ jskit: {
58
+ refreshOnPull: true
59
+ }
60
+ }
61
+ ),
62
+ {
63
+ staleTime: 1000,
64
+ meta: {
65
+ owner: "contacts",
66
+ jskit: {
67
+ feature: "list",
68
+ refreshOnPull: true
69
+ }
70
+ }
71
+ }
72
+ );
73
+ });
@@ -3,6 +3,7 @@ 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 { assertGeneratedUiSourceContract } from "@jskit-ai/kernel/shared/support/generatedUiContract";
6
7
  import descriptor from "../package.descriptor.mjs";
7
8
 
8
9
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
@@ -110,12 +111,72 @@ test("users-web package-owned account settings host is fully placement-backed",
110
111
  "utf8"
111
112
  );
112
113
 
114
+ assertGeneratedUiSourceContract(source, {
115
+ forbidCardShell: true,
116
+ sourceName: "AccountSettingsClientElement.vue",
117
+ requiredPatterns: [
118
+ {
119
+ id: "account-settings-header",
120
+ pattern: /settings-panel__header/,
121
+ message: "Account settings host needs a direct settings panel header."
122
+ },
123
+ {
124
+ id: "account-settings-sections",
125
+ pattern: /useAccountSettingsSections/,
126
+ message: "Account settings host must remain placement-backed."
127
+ }
128
+ ]
129
+ });
113
130
  assert.match(source, /useAccountSettingsSections/);
131
+ assert.match(source, /settings-panel__header/);
132
+ assert.doesNotMatch(source, /<v-card\b|v-card-title|v-card-subtitle/);
114
133
  assert.doesNotMatch(source, /AccountSettingsProfileSection/);
115
134
  assert.doesNotMatch(source, /AccountSettingsPreferencesSection/);
116
135
  assert.doesNotMatch(source, /AccountSettingsNotificationsSection/);
117
136
  });
118
137
 
138
+ test("users-web profile form element uses a direct panel instead of card scaffolding", async () => {
139
+ const source = await readFile(path.join(PACKAGE_DIR, "src", "client", "components", "ProfileClientElement.vue"), "utf8");
140
+
141
+ assertGeneratedUiSourceContract(source, {
142
+ forbidCardShell: true,
143
+ sourceName: "ProfileClientElement.vue",
144
+ requiredPatterns: [
145
+ {
146
+ id: "profile-panel-body",
147
+ pattern: /profile-client-panel__body/,
148
+ message: "Profile editor needs a direct panel body."
149
+ }
150
+ ]
151
+ });
152
+ assert.match(source, /profile-client-panel__body/);
153
+ assert.doesNotMatch(source, /<v-card\b|v-card-title|v-card-subtitle|v-card-text|v-card-item/);
154
+ });
155
+
156
+ test("users-web account settings section templates use direct settings panels", async () => {
157
+ for (const relativePath of [
158
+ path.join("templates", "src", "components", "account", "settings", "AccountSettingsProfileSection.vue"),
159
+ path.join("templates", "src", "components", "account", "settings", "AccountSettingsPreferencesSection.vue"),
160
+ path.join("templates", "src", "components", "account", "settings", "AccountSettingsNotificationsSection.vue")
161
+ ]) {
162
+ const source = await readFile(path.join(PACKAGE_DIR, relativePath), "utf8");
163
+
164
+ assertGeneratedUiSourceContract(source, {
165
+ forbidCardShell: true,
166
+ sourceName: relativePath,
167
+ requiredPatterns: [
168
+ {
169
+ id: "account-settings-section",
170
+ pattern: /account-settings-section/,
171
+ message: "Account settings sections need the direct section panel primitive."
172
+ }
173
+ ]
174
+ });
175
+ assert.match(source, /account-settings-section/);
176
+ assert.doesNotMatch(source, /<v-card\b|v-card-title|v-card-subtitle/);
177
+ }
178
+ });
179
+
119
180
  test("users-web descriptor metadata advertises home cog outlet and standard home settings placements", () => {
120
181
  assert.deepEqual(
121
182
  readOutlets("home-cog:primary-menu"),
@@ -0,0 +1,65 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { nextTick } from "vue";
4
+
5
+ test("useCrudListBulkActions manages selection and executes action context", async () => {
6
+ const { defineCrudListBulkActions } = await import("@jskit-ai/users-web/client/bulkActions");
7
+ const { useCrudListBulkActions } = await import("@jskit-ai/users-web/client/composables/useCrudListBulkActions");
8
+ const calls = [];
9
+ const actions = defineCrudListBulkActions([
10
+ {
11
+ key: "archive",
12
+ label: "Archive",
13
+ async run(context) {
14
+ calls.push(context);
15
+ context.clearSelection();
16
+ }
17
+ }
18
+ ]);
19
+ const runtime = useCrudListBulkActions(actions, {
20
+ resolveRecordId: (record) => record.id,
21
+ resolveContext: () => ({
22
+ reload: "reload-token"
23
+ })
24
+ });
25
+
26
+ assert.equal(runtime.hasActions.value, true);
27
+ assert.equal(runtime.hasSelection.value, false);
28
+
29
+ runtime.setRecordSelected({ id: "10", label: "A" }, 0, true);
30
+ runtime.setRecordSelected({ id: "11", label: "B" }, 1, true);
31
+ await nextTick();
32
+
33
+ assert.deepEqual(runtime.selectedIds.value, ["10", "11"]);
34
+ assert.equal(runtime.selectedCount.value, 2);
35
+ assert.equal(runtime.allVisibleSelected([{ id: "10" }, { id: "11" }]), true);
36
+ assert.equal(runtime.someVisibleSelected([{ id: "10" }, { id: "12" }]), true);
37
+
38
+ await runtime.execute("archive");
39
+
40
+ assert.equal(calls.length, 1);
41
+ assert.deepEqual(calls[0].selectedIds, ["10", "11"]);
42
+ assert.deepEqual(calls[0].ids, ["10", "11"]);
43
+ assert.equal(calls[0].reload, "reload-token");
44
+ assert.equal(runtime.selectedCount.value, 0);
45
+ });
46
+
47
+ test("defineCrudListBulkActions skips malformed and duplicate actions", async () => {
48
+ const { defineCrudListBulkActions } = await import("@jskit-ai/users-web/client/bulkActions");
49
+
50
+ const actions = defineCrudListBulkActions([
51
+ null,
52
+ { key: "archive", label: "Archive" },
53
+ { key: "archive", label: "Archive again" },
54
+ { key: "missing-label" },
55
+ { label: "Generated key" }
56
+ ]);
57
+
58
+ assert.deepEqual(
59
+ actions.map((action) => [action.key, action.label, action.color, action.variant]),
60
+ [
61
+ ["archive", "Archive", "primary", "tonal"],
62
+ ["action-5", "Generated key", "primary", "tonal"]
63
+ ]
64
+ );
65
+ });