@jskit-ai/users-web 0.1.72 → 0.1.74

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 (28) hide show
  1. package/package.descriptor.mjs +107 -19
  2. package/package.json +8 -8
  3. package/src/client/account-settings/sections.js +14 -10
  4. package/{templates/src/components/account/settings → src/client/components}/AccountSettingsClientElement.vue +14 -41
  5. package/src/client/composables/crud/crudJsonApiTransportSupport.js +137 -0
  6. package/src/client/composables/crud/crudLookupFieldLabelSupport.js +13 -1
  7. package/src/client/composables/crud/crudLookupFieldRuntime.js +6 -0
  8. package/src/client/composables/crud/crudSchemaFormHelpers.js +30 -0
  9. package/src/client/composables/internal/crudListParentTitleSupport.js +4 -0
  10. package/src/client/composables/records/useCrudAddEdit.js +11 -1
  11. package/src/client/composables/records/useCrudList.js +4 -0
  12. package/src/client/composables/records/useCrudView.js +7 -0
  13. package/src/client/composables/runtime/addEditUiRuntime.js +7 -6
  14. package/src/client/composables/runtime/useListCore.js +8 -0
  15. package/src/client/composables/useCrudListFilterLookups.js +5 -0
  16. package/src/client/composables/useCrudListParentTitle.js +21 -1
  17. package/src/client/index.js +1 -0
  18. package/templates/src/components/account/settings/vibe-coding-todo.todo +20 -0
  19. package/templates/src/pages/account/index.vue +1 -1
  20. package/test/accountSettingsSections.test.js +16 -5
  21. package/test/addEditUiRuntime.test.js +17 -0
  22. package/test/crudJsonApiTransportSupport.test.js +166 -0
  23. package/test/crudLookupFieldRuntime.test.js +25 -0
  24. package/test/exportsContract.test.js +1 -0
  25. package/test/requestTransportOptions.test.js +35 -0
  26. package/test/settingsPlacementContract.test.js +153 -1
  27. package/test/useCrudAddEdit.test.js +50 -0
  28. package/test/useCrudListParentTitle.test.js +106 -0
@@ -80,6 +80,28 @@ test("users-web home tools widget exposes home-cog outlet", async () => {
80
80
  assert.match(source, /:default-link-component-token="HOME_COG_OUTLET\.defaultLinkComponentToken"/);
81
81
  });
82
82
 
83
+ test("users-web account page template uses the package-owned account settings host", async () => {
84
+ const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "pages", "account", "index.vue"), "utf8");
85
+
86
+ assert.match(
87
+ source,
88
+ /import AccountSettingsClientElement from "@jskit-ai\/users-web\/client\/components\/AccountSettingsClientElement";/
89
+ );
90
+ assert.doesNotMatch(source, /components\/account\/settings\/AccountSettingsClientElement\.vue/);
91
+ });
92
+
93
+ test("users-web package-owned account settings host is fully placement-backed", async () => {
94
+ const source = await readFile(
95
+ path.join(PACKAGE_DIR, "src", "client", "components", "AccountSettingsClientElement.vue"),
96
+ "utf8"
97
+ );
98
+
99
+ assert.match(source, /useAccountSettingsSections/);
100
+ assert.doesNotMatch(source, /AccountSettingsProfileSection/);
101
+ assert.doesNotMatch(source, /AccountSettingsPreferencesSection/);
102
+ assert.doesNotMatch(source, /AccountSettingsNotificationsSection/);
103
+ });
104
+
83
105
  test("users-web descriptor metadata advertises home cog outlet and standard home settings placements", () => {
84
106
  assert.deepEqual(
85
107
  readOutlets("home-cog:primary-menu"),
@@ -98,7 +120,7 @@ test("users-web descriptor metadata advertises home cog outlet and standard home
98
120
  {
99
121
  target: "account-settings:sections",
100
122
  surfaces: ["account"],
101
- source: "templates/src/components/account/settings/AccountSettingsClientElement.vue"
123
+ source: "src/client/components/AccountSettingsClientElement.vue"
102
124
  }
103
125
  ]
104
126
  );
@@ -130,6 +152,27 @@ test("users-web descriptor metadata advertises home cog outlet and standard home
130
152
  source: "mutations.text#users-web-home-tools-placement"
131
153
  });
132
154
  assert.equal(findContribution("users.home.settings.general"), null);
155
+ expectContribution("users.account.settings.profile", {
156
+ target: "account-settings:sections",
157
+ surfaces: ["account"],
158
+ order: 100,
159
+ componentToken: "local.main.account-settings.section.profile",
160
+ source: "mutations.text#users-web-account-settings-sections-placement"
161
+ });
162
+ expectContribution("users.account.settings.preferences", {
163
+ target: "account-settings:sections",
164
+ surfaces: ["account"],
165
+ order: 200,
166
+ componentToken: "local.main.account-settings.section.preferences",
167
+ source: "mutations.text#users-web-account-settings-sections-placement"
168
+ });
169
+ expectContribution("users.account.settings.notifications", {
170
+ target: "account-settings:sections",
171
+ surfaces: ["account"],
172
+ order: 300,
173
+ componentToken: "local.main.account-settings.section.notifications",
174
+ source: "mutations.text#users-web-account-settings-sections-placement"
175
+ });
133
176
 
134
177
  expectTextMutation("users-web-home-tools-placement", {
135
178
  reason: "Append users-web home tools widget and settings menu placements into app-owned placement registry.",
@@ -145,6 +188,22 @@ test("users-web descriptor metadata advertises home cog outlet and standard home
145
188
  'unscopedSuffix: "/settings"'
146
189
  ]
147
190
  });
191
+ expectTextMutation("users-web-account-settings-sections-placement", {
192
+ reason: "Append users-web account settings section placements into the app-owned placement registry.",
193
+ category: "users-web",
194
+ skipIfContains: 'id: "users.account.settings.profile"',
195
+ snippets: [
196
+ 'id: "users.account.settings.profile"',
197
+ 'componentToken: "local.main.account-settings.section.profile"',
198
+ 'value: "profile"',
199
+ 'id: "users.account.settings.preferences"',
200
+ 'componentToken: "local.main.account-settings.section.preferences"',
201
+ 'value: "preferences"',
202
+ 'id: "users.account.settings.notifications"',
203
+ 'componentToken: "local.main.account-settings.section.notifications"',
204
+ 'value: "notifications"'
205
+ ]
206
+ });
148
207
 
149
208
  expectTextMutation("users-web-profile-settings-placement", {
150
209
  reason: "Append users-web profile settings menu placement into app-owned placement registry.",
@@ -159,6 +218,99 @@ test("users-web descriptor metadata advertises home cog outlet and standard home
159
218
  ]
160
219
  });
161
220
 
221
+ assert.equal(findFileMutation("users-web-component-account-settings-root"), null);
162
222
  assert.equal(findFileMutation("users-web-component-account-settings-invites"), null);
223
+ assert.deepEqual(findFileMutation("users-web-component-account-settings-profile"), {
224
+ from: "templates/src/components/account/settings/AccountSettingsProfileSection.vue",
225
+ to: "src/components/account/settings/AccountSettingsProfileSection.vue",
226
+ reason: "Install app-owned account settings profile section scaffold.",
227
+ category: "users-web",
228
+ id: "users-web-component-account-settings-profile"
229
+ });
230
+ assert.deepEqual(findFileMutation("users-web-component-account-settings-preferences"), {
231
+ from: "templates/src/components/account/settings/AccountSettingsPreferencesSection.vue",
232
+ to: "src/components/account/settings/AccountSettingsPreferencesSection.vue",
233
+ reason: "Install app-owned account settings preferences section scaffold.",
234
+ category: "users-web",
235
+ id: "users-web-component-account-settings-preferences"
236
+ });
237
+ assert.deepEqual(findFileMutation("users-web-component-account-settings-notifications"), {
238
+ from: "templates/src/components/account/settings/AccountSettingsNotificationsSection.vue",
239
+ to: "src/components/account/settings/AccountSettingsNotificationsSection.vue",
240
+ reason: "Install app-owned account settings notifications section scaffold.",
241
+ category: "users-web",
242
+ id: "users-web-component-account-settings-notifications"
243
+ });
244
+ assert.deepEqual(findTextMutation("users-web-main-client-provider-account-settings-profile-import"), {
245
+ op: "append-text",
246
+ file: "packages/main/src/client/providers/MainClientProvider.js",
247
+ position: "top",
248
+ skipIfContains:
249
+ "import AccountSettingsProfileSection from \"/src/components/account/settings/AccountSettingsProfileSection.vue\";",
250
+ value: "import AccountSettingsProfileSection from \"/src/components/account/settings/AccountSettingsProfileSection.vue\";\n",
251
+ reason: "Bind the app-owned account profile settings section into local main client provider imports.",
252
+ category: "users-web",
253
+ id: "users-web-main-client-provider-account-settings-profile-import"
254
+ });
255
+ assert.deepEqual(findTextMutation("users-web-main-client-provider-account-settings-preferences-import"), {
256
+ op: "append-text",
257
+ file: "packages/main/src/client/providers/MainClientProvider.js",
258
+ position: "top",
259
+ skipIfContains:
260
+ "import AccountSettingsPreferencesSection from \"/src/components/account/settings/AccountSettingsPreferencesSection.vue\";",
261
+ value:
262
+ "import AccountSettingsPreferencesSection from \"/src/components/account/settings/AccountSettingsPreferencesSection.vue\";\n",
263
+ reason: "Bind the app-owned account preferences settings section into local main client provider imports.",
264
+ category: "users-web",
265
+ id: "users-web-main-client-provider-account-settings-preferences-import"
266
+ });
267
+ assert.deepEqual(findTextMutation("users-web-main-client-provider-account-settings-notifications-import"), {
268
+ op: "append-text",
269
+ file: "packages/main/src/client/providers/MainClientProvider.js",
270
+ position: "top",
271
+ skipIfContains:
272
+ "import AccountSettingsNotificationsSection from \"/src/components/account/settings/AccountSettingsNotificationsSection.vue\";",
273
+ value:
274
+ "import AccountSettingsNotificationsSection from \"/src/components/account/settings/AccountSettingsNotificationsSection.vue\";\n",
275
+ reason: "Bind the app-owned account notifications settings section into local main client provider imports.",
276
+ category: "users-web",
277
+ id: "users-web-main-client-provider-account-settings-notifications-import"
278
+ });
279
+ assert.deepEqual(findTextMutation("users-web-main-client-provider-account-settings-profile-register"), {
280
+ op: "append-text",
281
+ file: "packages/main/src/client/providers/MainClientProvider.js",
282
+ position: "bottom",
283
+ skipIfContains:
284
+ "registerMainClientComponent(\"local.main.account-settings.section.profile\", () => AccountSettingsProfileSection);",
285
+ value:
286
+ "\nregisterMainClientComponent(\"local.main.account-settings.section.profile\", () => AccountSettingsProfileSection);\n",
287
+ reason: "Bind the app-owned account profile settings section token into local main client provider registry.",
288
+ category: "users-web",
289
+ id: "users-web-main-client-provider-account-settings-profile-register"
290
+ });
291
+ assert.deepEqual(findTextMutation("users-web-main-client-provider-account-settings-preferences-register"), {
292
+ op: "append-text",
293
+ file: "packages/main/src/client/providers/MainClientProvider.js",
294
+ position: "bottom",
295
+ skipIfContains:
296
+ "registerMainClientComponent(\"local.main.account-settings.section.preferences\", () => AccountSettingsPreferencesSection);",
297
+ value:
298
+ "\nregisterMainClientComponent(\"local.main.account-settings.section.preferences\", () => AccountSettingsPreferencesSection);\n",
299
+ reason: "Bind the app-owned account preferences settings section token into local main client provider registry.",
300
+ category: "users-web",
301
+ id: "users-web-main-client-provider-account-settings-preferences-register"
302
+ });
303
+ assert.deepEqual(findTextMutation("users-web-main-client-provider-account-settings-notifications-register"), {
304
+ op: "append-text",
305
+ file: "packages/main/src/client/providers/MainClientProvider.js",
306
+ position: "bottom",
307
+ skipIfContains:
308
+ "registerMainClientComponent(\"local.main.account-settings.section.notifications\", () => AccountSettingsNotificationsSection);",
309
+ value:
310
+ "\nregisterMainClientComponent(\"local.main.account-settings.section.notifications\", () => AccountSettingsNotificationsSection);\n",
311
+ reason: "Bind the app-owned account notifications settings section token into local main client provider registry.",
312
+ category: "users-web",
313
+ id: "users-web-main-client-provider-account-settings-notifications-register"
314
+ });
163
315
 
164
316
  });
@@ -51,6 +51,22 @@ test("createCrudFormModel preserves explicit boolean defaults for nullable field
51
51
  });
52
52
  });
53
53
 
54
+ test("createCrudFormModel initializes nullable lookup fields as null", () => {
55
+ const model = createCrudFormModel([
56
+ {
57
+ key: "serviceId",
58
+ type: "string",
59
+ nullable: true,
60
+ component: "lookup",
61
+ relation: { kind: "lookup", namespace: "services" }
62
+ }
63
+ ]);
64
+
65
+ assert.deepEqual(model, {
66
+ serviceId: null
67
+ });
68
+ });
69
+
54
70
  test("buildCrudFormPayload normalizes booleans and numbers while skipping empty numeric values", () => {
55
71
  const payload = buildCrudFormPayload(
56
72
  [
@@ -114,6 +130,13 @@ test("buildCrudFormPayload serializes cleared nullable typed fields as null", ()
114
130
  [
115
131
  { key: "reviewed", type: "boolean", nullable: true },
116
132
  { key: "serviceId", type: "integer", nullable: true },
133
+ {
134
+ key: "selectedServiceId",
135
+ type: "string",
136
+ nullable: true,
137
+ component: "lookup",
138
+ relation: { kind: "lookup", namespace: "services" }
139
+ },
117
140
  { key: "fromDate", type: "string", format: "date", nullable: true },
118
141
  { key: "scheduledAt", type: "string", format: "date-time", nullable: true },
119
142
  { key: "fromTime", type: "string", format: "time", nullable: true }
@@ -121,6 +144,7 @@ test("buildCrudFormPayload serializes cleared nullable typed fields as null", ()
121
144
  {
122
145
  reviewed: null,
123
146
  serviceId: null,
147
+ selectedServiceId: "",
124
148
  fromDate: "",
125
149
  scheduledAt: "",
126
150
  fromTime: ""
@@ -130,6 +154,7 @@ test("buildCrudFormPayload serializes cleared nullable typed fields as null", ()
130
154
  assert.deepEqual(payload, {
131
155
  reviewed: null,
132
156
  serviceId: null,
157
+ selectedServiceId: null,
133
158
  fromDate: null,
134
159
  scheduledAt: null,
135
160
  fromTime: null
@@ -247,6 +272,31 @@ test("applyCrudPayloadToForm preserves nullable boolean payload values", () => {
247
272
  });
248
273
  });
249
274
 
275
+ test("applyCrudPayloadToForm preserves null lookup payload values", () => {
276
+ const fields = [
277
+ {
278
+ key: "serviceId",
279
+ type: "string",
280
+ nullable: true,
281
+ component: "lookup",
282
+ relation: { kind: "lookup", namespace: "services" }
283
+ }
284
+ ];
285
+ const form = reactive({
286
+ serviceId: "existing"
287
+ });
288
+
289
+ applyCrudPayloadToForm(fields, form, {
290
+ serviceId: null
291
+ });
292
+ assert.equal(form.serviceId, null);
293
+
294
+ applyCrudPayloadToForm(fields, form, {
295
+ serviceId: 42
296
+ });
297
+ assert.equal(form.serviceId, "42");
298
+ });
299
+
250
300
  test("resolveCrudRouteBoundFieldValues maps route params for route-bound form fields", () => {
251
301
  const values = resolveCrudRouteBoundFieldValues(
252
302
  [
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createSchema } from "json-rest-schema";
4
4
  import { resolveCrudListParentDescriptor, resolveCrudListParentRecordTitle, resolveCrudListParentTitleFromItems } from "../src/client/composables/internal/crudListParentTitleSupport.js";
5
+ import { useCrudListParentTitle } from "../src/client/composables/useCrudListParentTitle.js";
5
6
 
6
7
  const contactChildResource = Object.freeze({
7
8
  contract: {
@@ -106,6 +107,32 @@ test("resolveCrudListParentTitleFromItems uses the hydrated lookup label", () =>
106
107
  assert.equal(title, "Jessica Dickinson");
107
108
  });
108
109
 
110
+ test("resolveCrudListParentTitleFromItems ignores raw lookup ids when no hydrated label is present", () => {
111
+ const descriptor = resolveCrudListParentDescriptor({
112
+ resource: contactChildResource,
113
+ route: {
114
+ matched: [{ path: "/w/:workspaceSlug/admin/contacts/:contactId/availabilities" }],
115
+ params: {
116
+ workspaceSlug: "dogandgroom",
117
+ contactId: "538779"
118
+ }
119
+ },
120
+ recordIdParam: "availabilityRuleId"
121
+ });
122
+
123
+ const title = resolveCrudListParentTitleFromItems(
124
+ [
125
+ {
126
+ id: 1,
127
+ contactId: 538779
128
+ }
129
+ ],
130
+ descriptor
131
+ );
132
+
133
+ assert.equal(title, "");
134
+ });
135
+
109
136
  test("resolveCrudListParentRecordTitle falls back to entity label plus id", () => {
110
137
  const title = resolveCrudListParentRecordTitle(
111
138
  {
@@ -154,3 +181,82 @@ test("resolveCrudListParentDescriptor supports parentRouteParamKey aliases", ()
154
181
  assert.equal(descriptor?.fieldKey, "staffContactId");
155
182
  assert.equal(descriptor?.routeParamKey, "contactId");
156
183
  });
184
+
185
+ test("useCrudListParentTitle loads the parent record when child rows only expose the raw parent id", () => {
186
+ const runtime = useCrudListParentTitle({
187
+ listRuntime: {
188
+ items: [
189
+ {
190
+ id: 1,
191
+ contactId: 538779
192
+ }
193
+ ],
194
+ isInitialLoading: false,
195
+ loadError: ""
196
+ },
197
+ resource: contactChildResource,
198
+ recordIdParam: "availabilityRuleId",
199
+ route: {
200
+ matched: [{ path: "/w/:workspaceSlug/admin/contacts/:contactId/availabilities" }],
201
+ params: {
202
+ workspaceSlug: "dogandgroom",
203
+ contactId: "538779"
204
+ }
205
+ },
206
+ viewRuntimeFactory: () => ({
207
+ record: {
208
+ id: 538779,
209
+ firstName: "Jessica",
210
+ lastName: "Dickinson"
211
+ },
212
+ isLoading: false,
213
+ loadError: ""
214
+ })
215
+ });
216
+
217
+ assert.equal(runtime.shouldLoadParentRecord, true);
218
+ assert.equal(runtime.title, "Jessica Dickinson");
219
+ });
220
+
221
+ test("useCrudListParentTitle requests the parent through JSON:API record transport", () => {
222
+ let capturedTransport = null;
223
+
224
+ useCrudListParentTitle({
225
+ listRuntime: {
226
+ items: [
227
+ {
228
+ id: 1,
229
+ contactId: 538779
230
+ }
231
+ ],
232
+ isInitialLoading: false,
233
+ loadError: ""
234
+ },
235
+ resource: contactChildResource,
236
+ recordIdParam: "availabilityRuleId",
237
+ route: {
238
+ matched: [{ path: "/w/:workspaceSlug/admin/contacts/:contactId/availabilities" }],
239
+ params: {
240
+ workspaceSlug: "dogandgroom",
241
+ contactId: "538779"
242
+ }
243
+ },
244
+ viewRuntimeFactory: (options = {}) => {
245
+ capturedTransport = options.transport;
246
+ return {
247
+ record: {
248
+ id: 538779,
249
+ fullName: "Jessica Dickinson"
250
+ },
251
+ isLoading: false,
252
+ loadError: ""
253
+ };
254
+ }
255
+ });
256
+
257
+ assert.deepEqual(capturedTransport, {
258
+ kind: "jsonapi-resource",
259
+ responseType: "contacts",
260
+ responseKind: "record"
261
+ });
262
+ });