@jskit-ai/users-web 0.1.80 → 0.1.82

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 (38) hide show
  1. package/package.descriptor.mjs +137 -21
  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/components/UsersHomeToolsWidget.vue +0 -1
  12. package/src/client/composables/records/useAddEdit.js +23 -2
  13. package/src/client/composables/records/useCrudList.js +5 -1
  14. package/src/client/composables/records/useView.js +1 -0
  15. package/src/client/composables/runtime/operationUiHelpers.js +7 -3
  16. package/src/client/composables/runtime/useEndpointResource.js +12 -2
  17. package/src/client/composables/runtime/useUiFeedback.js +4 -2
  18. package/src/client/composables/support/resourceLoadStateHelpers.js +33 -1
  19. package/src/client/composables/useAccountSettingsRuntime.js +10 -1
  20. package/src/client/composables/useCrudAddEditScreen.js +88 -0
  21. package/src/client/composables/useCrudListBulkActions.js +147 -0
  22. package/src/client/composables/useCrudListScreen.js +107 -0
  23. package/src/client/composables/useCrudViewScreen.js +67 -0
  24. package/src/client/composables/usePagedCollection.js +6 -1
  25. package/src/client/filters.js +15 -0
  26. package/src/client/index.js +5 -0
  27. package/src/shared/toolsOutletContracts.js +0 -4
  28. package/templates/src/components/account/settings/AccountSettingsNotificationsSection.vue +34 -8
  29. package/templates/src/components/account/settings/AccountSettingsPreferencesSection.vue +34 -8
  30. package/templates/src/components/account/settings/AccountSettingsProfileSection.vue +34 -8
  31. package/test/crudListBulkActionSurface.test.js +27 -0
  32. package/test/crudListFilterSurface.test.js +45 -0
  33. package/test/crudScreenComponents.test.js +62 -0
  34. package/test/errorIntentContract.test.js +31 -0
  35. package/test/exportsContract.test.js +11 -0
  36. package/test/resourceLoadStateHelpers.test.js +35 -1
  37. package/test/settingsPlacementContract.test.js +146 -14
  38. package/test/useCrudListBulkActions.test.js +65 -0
@@ -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));
@@ -16,6 +17,19 @@ function readOutlets(host = "") {
16
17
  : [];
17
18
  }
18
19
 
20
+ function findTopology(id, owner = "") {
21
+ const placements = descriptor?.metadata?.ui?.placements?.topology?.placements;
22
+ const normalizedId = String(id || "").trim();
23
+ const normalizedOwner = String(owner || "").trim();
24
+ return Array.isArray(placements)
25
+ ? placements.find((entry) => {
26
+ const entryId = String(entry?.id || "").trim();
27
+ const entryOwner = String(entry?.owner || "").trim();
28
+ return entryId === normalizedId && entryOwner === normalizedOwner;
29
+ }) || null
30
+ : null;
31
+ }
32
+
19
33
  function findContribution(id) {
20
34
  const contributions = descriptor?.metadata?.ui?.placements?.contributions;
21
35
  return Array.isArray(contributions)
@@ -77,7 +91,8 @@ test("users-web home tools widget exposes home-cog outlet", async () => {
77
91
  assert.match(source, /import \{ HOME_COG_OUTLET \} from "\.\.\/\.\.\/shared\/toolsOutletContracts\.js";/);
78
92
  assert.match(source, /<ShellOutletMenuWidget/);
79
93
  assert.match(source, /:target="HOME_COG_OUTLET\.target"/);
80
- assert.match(source, /:default-link-component-token="HOME_COG_OUTLET\.defaultLinkComponentToken"/);
94
+ assert.match(source, /:aria-label="HOME_COG_OUTLET\.ariaLabel"/);
95
+ assert.doesNotMatch(source, /default-link-component-token/);
81
96
  });
82
97
 
83
98
  test("users-web account page template uses the package-owned account settings host", async () => {
@@ -96,19 +111,78 @@ test("users-web package-owned account settings host is fully placement-backed",
96
111
  "utf8"
97
112
  );
98
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
+ });
99
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/);
100
133
  assert.doesNotMatch(source, /AccountSettingsProfileSection/);
101
134
  assert.doesNotMatch(source, /AccountSettingsPreferencesSection/);
102
135
  assert.doesNotMatch(source, /AccountSettingsNotificationsSection/);
103
136
  });
104
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
+
105
180
  test("users-web descriptor metadata advertises home cog outlet and standard home settings placements", () => {
106
181
  assert.deepEqual(
107
182
  readOutlets("home-cog:primary-menu"),
108
183
  [
109
184
  {
110
185
  target: "home-cog:primary-menu",
111
- defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
112
186
  surfaces: ["home"],
113
187
  source: "src/client/components/UsersHomeToolsWidget.vue"
114
188
  }
@@ -124,18 +198,61 @@ test("users-web descriptor metadata advertises home cog outlet and standard home
124
198
  }
125
199
  ]
126
200
  );
201
+ assert.deepEqual(findTopology("home.tools-menu"), {
202
+ id: "home.tools-menu",
203
+ description: "Home surface tools menu actions.",
204
+ surfaces: ["home"],
205
+ variants: {
206
+ compact: {
207
+ outlet: "home-cog:primary-menu",
208
+ renderers: {
209
+ link: "local.main.ui.surface-aware-menu-link-item"
210
+ }
211
+ },
212
+ medium: {
213
+ outlet: "home-cog:primary-menu",
214
+ renderers: {
215
+ link: "local.main.ui.surface-aware-menu-link-item"
216
+ }
217
+ },
218
+ expanded: {
219
+ outlet: "home-cog:primary-menu",
220
+ renderers: {
221
+ link: "local.main.ui.surface-aware-menu-link-item"
222
+ }
223
+ }
224
+ }
225
+ });
226
+ assert.deepEqual(findTopology("settings.sections", "account-settings"), {
227
+ id: "settings.sections",
228
+ owner: "account-settings",
229
+ description: "Account settings content sections.",
230
+ surfaces: ["account"],
231
+ variants: {
232
+ compact: {
233
+ outlet: "account-settings:sections"
234
+ },
235
+ medium: {
236
+ outlet: "account-settings:sections"
237
+ },
238
+ expanded: {
239
+ outlet: "account-settings:sections"
240
+ }
241
+ }
242
+ });
127
243
 
128
244
  expectContribution("users.profile.menu.settings", {
129
- target: "auth-profile-menu:primary-menu",
245
+ target: "auth.profile-menu",
246
+ kind: "link",
130
247
  surfaces: ["*"],
131
248
  order: 500,
132
- componentToken: "auth.web.profile.menu.link-item",
133
249
  when: "auth.authenticated === true",
134
250
  source: "mutations.text#users-web-profile-settings-placement"
135
251
  });
136
252
 
137
253
  expectContribution("users.home.tools.widget", {
138
- target: "shell-layout:top-right",
254
+ target: "shell.status",
255
+ kind: "component",
139
256
  surfaces: ["home"],
140
257
  order: 900,
141
258
  componentToken: "users.web.home.tools.widget",
@@ -144,30 +261,36 @@ test("users-web descriptor metadata advertises home cog outlet and standard home
144
261
  });
145
262
 
146
263
  expectContribution("users.home.menu.settings", {
147
- target: "home-cog:primary-menu",
264
+ target: "home.tools-menu",
265
+ kind: "link",
148
266
  surfaces: ["home"],
149
267
  order: 100,
150
- componentToken: "local.main.ui.surface-aware-menu-link-item",
151
268
  when: "auth.authenticated === true",
152
269
  source: "mutations.text#users-web-home-tools-placement"
153
270
  });
154
271
  assert.equal(findContribution("users.home.settings.general"), null);
155
272
  expectContribution("users.account.settings.profile", {
156
- target: "account-settings:sections",
273
+ target: "settings.sections",
274
+ owner: "account-settings",
275
+ kind: "component",
157
276
  surfaces: ["account"],
158
277
  order: 100,
159
278
  componentToken: "local.main.account-settings.section.profile",
160
279
  source: "mutations.text#users-web-account-settings-sections-placement"
161
280
  });
162
281
  expectContribution("users.account.settings.preferences", {
163
- target: "account-settings:sections",
282
+ target: "settings.sections",
283
+ owner: "account-settings",
284
+ kind: "component",
164
285
  surfaces: ["account"],
165
286
  order: 200,
166
287
  componentToken: "local.main.account-settings.section.preferences",
167
288
  source: "mutations.text#users-web-account-settings-sections-placement"
168
289
  });
169
290
  expectContribution("users.account.settings.notifications", {
170
- target: "account-settings:sections",
291
+ target: "settings.sections",
292
+ owner: "account-settings",
293
+ kind: "component",
171
294
  surfaces: ["account"],
172
295
  order: 300,
173
296
  componentToken: "local.main.account-settings.section.notifications",
@@ -182,18 +305,23 @@ test("users-web descriptor metadata advertises home cog outlet and standard home
182
305
  'id: "users.home.tools.widget"',
183
306
  'componentToken: "users.web.home.tools.widget"',
184
307
  'id: "users.home.menu.settings"',
185
- 'target: "home-cog:primary-menu"',
186
- 'componentToken: "local.main.ui.surface-aware-menu-link-item"',
308
+ 'target: "home.tools-menu"',
309
+ 'kind: "link"',
187
310
  'scopedSuffix: "/settings"',
188
311
  'unscopedSuffix: "/settings"'
189
312
  ]
190
313
  });
314
+ assert.equal(findTextMutation("users-web-home-tools-topology")?.file, "src/placementTopology.js");
315
+ assert.match(findTextMutation("users-web-home-tools-topology")?.value || "", /id: "home\.tools-menu"/);
316
+ assert.match(findTextMutation("users-web-home-tools-topology")?.value || "", /outlet: "home-cog:primary-menu"/);
191
317
  expectTextMutation("users-web-account-settings-sections-placement", {
192
318
  reason: "Append users-web account settings section placements into the app-owned placement registry.",
193
319
  category: "users-web",
194
320
  skipIfContains: 'id: "users.account.settings.profile"',
195
321
  snippets: [
196
322
  'id: "users.account.settings.profile"',
323
+ 'target: "settings.sections"',
324
+ 'owner: "account-settings"',
197
325
  'componentToken: "local.main.account-settings.section.profile"',
198
326
  'value: "profile"',
199
327
  'id: "users.account.settings.preferences"',
@@ -204,6 +332,10 @@ test("users-web descriptor metadata advertises home cog outlet and standard home
204
332
  'value: "notifications"'
205
333
  ]
206
334
  });
335
+ assert.equal(findTextMutation("users-web-account-settings-topology")?.file, "src/placementTopology.js");
336
+ assert.match(findTextMutation("users-web-account-settings-topology")?.value || "", /id: "settings\.sections"/);
337
+ assert.match(findTextMutation("users-web-account-settings-topology")?.value || "", /owner: "account-settings"/);
338
+ assert.match(findTextMutation("users-web-account-settings-topology")?.value || "", /outlet: "account-settings:sections"/);
207
339
 
208
340
  expectTextMutation("users-web-profile-settings-placement", {
209
341
  reason: "Append users-web profile settings menu placement into app-owned placement registry.",
@@ -211,8 +343,8 @@ test("users-web descriptor metadata advertises home cog outlet and standard home
211
343
  skipIfContains: 'id: "users.profile.menu.settings"',
212
344
  snippets: [
213
345
  'id: "users.profile.menu.settings"',
214
- 'target: "auth-profile-menu:primary-menu"',
215
- 'componentToken: "auth.web.profile.menu.link-item"',
346
+ 'target: "auth.profile-menu"',
347
+ 'kind: "link"',
216
348
  'label: "Settings"',
217
349
  'to: "/account"'
218
350
  ]
@@ -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
+ });