@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
@@ -1,6 +1,7 @@
1
1
  import { computed, proxyRefs, reactive, watch } from "vue";
2
2
  import { useRoute, useRouter } from "vue-router";
3
3
  import { asPlainObject } from "../support/scopeHelpers.js";
4
+ import { resolveCrudJsonApiTransport } from "../crud/crudJsonApiTransportSupport.js";
4
5
  import { useAddEdit } from "./useAddEdit.js";
5
6
  import {
6
7
  resolveCrudBoundValues,
@@ -54,6 +55,15 @@ function useCrudAddEdit({
54
55
  const route = useRoute();
55
56
  const normalizedFields = normalizeCrudFormFields(formFields);
56
57
  const normalizedAddEditOptions = asPlainObject(addEditOptions);
58
+ const resolvedResource = normalizedAddEditOptions.resource || resource;
59
+ const resolvedTransport = resolveCrudJsonApiTransport(
60
+ normalizedAddEditOptions.transport,
61
+ resolvedResource,
62
+ {
63
+ mode: "add-edit",
64
+ operationName
65
+ }
66
+ );
57
67
  const saveSuccessOptions = normalizeSaveSuccessOptions(saveSuccess);
58
68
  const defaultFieldErrorKeys = normalizedFields.map((field) => field.key);
59
69
  const providedFieldErrorKeys = normalizeFieldErrorKeys(normalizedAddEditOptions.fieldErrorKeys);
@@ -101,7 +111,6 @@ function useCrudAddEdit({
101
111
  ? normalizedAddEditOptions.onSaveSuccess
102
112
  : null;
103
113
  const shouldApplyDefaultMapPayload = normalizedAddEditOptions.readEnabled !== false;
104
- const resolvedResource = normalizedAddEditOptions.resource || resource;
105
114
  const resolvedInput = inputOverride || resolvedResource?.operations?.[operationName]?.body || null;
106
115
 
107
116
  function resolveBuildRawPayload(model = {}, context = {}) {
@@ -170,6 +179,7 @@ function useCrudAddEdit({
170
179
  const addEdit = useAddEdit({
171
180
  ...normalizedAddEditOptions,
172
181
  resource: resolvedResource,
182
+ transport: resolvedTransport,
173
183
  model: form,
174
184
  fieldErrorKeys,
175
185
  input: resolvedInput,
@@ -1,5 +1,6 @@
1
1
  import { computed, unref } from "vue";
2
2
  import { useRoute } from "vue-router";
3
+ import { resolveCrudJsonApiTransport } from "../crud/crudJsonApiTransportSupport.js";
3
4
  import { resolveLookupFieldDisplayValue } from "../crud/crudLookupFieldLabelSupport.js";
4
5
  import { resolveCrudBoundValues } from "../crud/crudBindingSupport.js";
5
6
  import { resolveCrudListParentDescriptor } from "../internal/crudListParentTitleSupport.js";
@@ -65,6 +66,9 @@ function useCrudList({
65
66
  });
66
67
  const records = useList({
67
68
  ...listOptions,
69
+ transport: resolveCrudJsonApiTransport(listOptions.transport, resource, {
70
+ mode: "list"
71
+ }),
68
72
  recordIdParam,
69
73
  requestQueryParams(context = {}) {
70
74
  const baseRequestQueryParams = resolveRequestQueryParamsInput(requestQueryParams, context);
@@ -1,5 +1,8 @@
1
1
  import { computed } from "vue";
2
2
  import { useRoute } from "vue-router";
3
+ import {
4
+ resolveCrudJsonApiTransport
5
+ } from "../crud/crudJsonApiTransportSupport.js";
3
6
  import {
4
7
  resolveLookupFieldDisplayValue,
5
8
  resolveRecordTitle
@@ -9,6 +12,7 @@ import { asPlainObject } from "../support/scopeHelpers.js";
9
12
  import { useView } from "./useView.js";
10
13
 
11
14
  function useCrudView({
15
+ resource = null,
12
16
  paramBinding = null,
13
17
  route = null,
14
18
  ...viewOptions
@@ -25,6 +29,9 @@ function useCrudView({
25
29
  });
26
30
  const view = useView({
27
31
  ...viewOptions,
32
+ transport: resolveCrudJsonApiTransport(viewOptions.transport, resource, {
33
+ mode: "view"
34
+ }),
28
35
  routeParams: boundRouteParams
29
36
  });
30
37
  view.resolveFieldDisplay = resolveLookupFieldDisplayValue;
@@ -57,19 +57,20 @@ function createAddEditUiRuntime({
57
57
  ...currentRouteParams,
58
58
  ...asPlainObject(extraParams)
59
59
  };
60
+ const currentRouteRecordId = toResolvedRecordId({
61
+ routeParams: currentRouteParams,
62
+ recordIdParam: normalizedRecordIdParam,
63
+ routeRecordId
64
+ });
60
65
  const resolvedRecordId = toRouteParamValue(sourceParams[normalizedRecordIdParam]) ||
61
- toResolvedRecordId({
62
- routeParams: currentRouteParams,
63
- recordIdParam: normalizedRecordIdParam,
64
- routeRecordId
65
- });
66
+ currentRouteRecordId;
66
67
  sourceParams[normalizedRecordIdParam] = resolvedRecordId;
67
68
  const currentPathname = resolveScopedRoutePathname({
68
69
  currentPathname: routePath,
69
70
  params: currentRouteParams,
70
71
  orderedParamNames: routeParamNames,
71
72
  anchorParamName: normalizedRecordIdParam,
72
- anchorParamValue: resolvedRecordId,
73
+ anchorParamValue: currentRouteRecordId,
73
74
  anchorMode: "after"
74
75
  });
75
76
 
@@ -4,6 +4,8 @@ import { asPlainObject } from "../support/scopeHelpers.js";
4
4
  import { resolveEnabledRef, resolveTextRef } from "../support/refValueHelpers.js";
5
5
  import { usePagedCollection } from "../usePagedCollection.js";
6
6
 
7
+ const DEFAULT_LIST_LIMIT = 20;
8
+
7
9
  function buildListRequestOptions({
8
10
  requestOptions = null,
9
11
  transport = null,
@@ -18,6 +20,12 @@ function buildListRequestOptions({
18
20
  resolvedOptions.query && typeof resolvedOptions.query === "object" && !Array.isArray(resolvedOptions.query)
19
21
  ? { ...resolvedOptions.query }
20
22
  : {};
23
+ if (
24
+ !Object.hasOwn(sourceQuery, "limit") &&
25
+ !Object.hasOwn(sourceQuery, "page[limit]")
26
+ ) {
27
+ sourceQuery.limit = DEFAULT_LIST_LIMIT;
28
+ }
21
29
  if (pageParam !== null && pageParam !== undefined && pageParam !== "") {
22
30
  sourceQuery.cursor = String(pageParam);
23
31
  }
@@ -15,6 +15,7 @@ import {
15
15
  mergeSelectedLookupOptions,
16
16
  resolveLookupOptionLabel
17
17
  } from "./internal/crudListFilterLookupSupport.js";
18
+ import { inferCrudLookupJsonApiTransport } from "./crud/crudJsonApiTransportSupport.js";
18
19
 
19
20
  function useCrudListFilterLookups(
20
21
  definitions = {},
@@ -46,10 +47,14 @@ function useCrudListFilterLookups(
46
47
  if (!filter.lookup?.apiSuffix) {
47
48
  continue;
48
49
  }
50
+ const transport = inferCrudLookupJsonApiTransport({
51
+ apiPath: filter.lookup.apiSuffix
52
+ });
49
53
 
50
54
  const runtime = useList({
51
55
  adapter: adapter || undefined,
52
56
  apiSuffix: filter.lookup.apiSuffix,
57
+ ...(transport ? { transport } : {}),
53
58
  queryKeyFactory: (surfaceId = "", scopeParamValue = "") => [
54
59
  ...normalizedQueryKeyPrefix,
55
60
  filter.key,
@@ -1,6 +1,7 @@
1
1
  import { computed, proxyRefs } from "vue";
2
2
  import { useRoute } from "vue-router";
3
3
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { inferCrudLookupJsonApiTransport } from "./crud/crudJsonApiTransportSupport.js";
4
5
  import {
5
6
  resolveRouteParamsSource,
6
7
  toRouteParamValue
@@ -59,6 +60,19 @@ function useCrudListParentTitle({
59
60
  });
60
61
 
61
62
  const initialParentDescriptor = parentDescriptor.value || {};
63
+ const parentTransport = (() => {
64
+ const baseTransport = inferCrudLookupJsonApiTransport({
65
+ namespace: initialParentDescriptor.relationNamespace
66
+ });
67
+ if (!baseTransport) {
68
+ return null;
69
+ }
70
+
71
+ return Object.freeze({
72
+ ...baseTransport,
73
+ responseKind: "record"
74
+ });
75
+ })();
62
76
  const normalizedQueryKeyPrefix = normalizeQueryKeyPrefix(queryKeyPrefix);
63
77
  const shouldLoadParentRecord = computed(() => {
64
78
  const descriptor = parentDescriptor.value;
@@ -70,12 +84,18 @@ function useCrudListParentTitle({
70
84
  }
71
85
 
72
86
  const items = Array.isArray(listRuntime?.items) ? listRuntime.items : [];
73
- return items.length < 1;
87
+ if (items.length < 1) {
88
+ return true;
89
+ }
90
+
91
+ const listTitle = resolveCrudListParentTitleFromItems(items, descriptor);
92
+ return !listTitle;
74
93
  });
75
94
 
76
95
  const parentView = viewRuntimeFactory({
77
96
  adapter,
78
97
  apiUrlTemplate: normalizeText(initialParentDescriptor.apiUrlTemplate),
98
+ transport: parentTransport,
79
99
  readEnabled: shouldLoadParentRecord,
80
100
  recordIdParam: normalizeText(initialParentDescriptor.routeParamKey) || "recordId",
81
101
  includeRecordIdInQueryKey: true,
@@ -1,6 +1,7 @@
1
1
  import { UsersWebClientProvider } from "./providers/UsersWebClientProvider.js";
2
2
 
3
3
  export { UsersWebClientProvider } from "./providers/UsersWebClientProvider.js";
4
+ export { default as AccountSettingsClientElement } from "./components/AccountSettingsClientElement.vue";
4
5
 
5
6
  const clientProviders = Object.freeze([UsersWebClientProvider]);
6
7
 
@@ -0,0 +1,20 @@
1
+ This note was split.
2
+
3
+ Use these repo-level docs instead:
4
+
5
+ - `docs/jskit-product-lanes-rulebook.md`
6
+ - `docs/jskit-product-lanes-workboard.md`
7
+
8
+ Why:
9
+
10
+ - the previous file had become a giant mixed artifact
11
+ - the durable product direction now lives in the rulebook
12
+ - the prioritized execution plan now lives in the workboard
13
+ - this template-path file is intentionally reduced to a pointer so the catch-all handoff does not keep living inside a shipped template location
14
+
15
+ If a future agent needs to continue the work:
16
+
17
+ 1. read `AGENTS.md`
18
+ 2. read the JSKIT pattern/workflow files named in the rulebook
19
+ 3. use the rulebook for architecture and product direction
20
+ 4. use the workboard for prioritization and execution planning
@@ -13,5 +13,5 @@
13
13
  </template>
14
14
 
15
15
  <script setup>
16
- import AccountSettingsClientElement from "../../components/account/settings/AccountSettingsClientElement.vue";
16
+ import AccountSettingsClientElement from "@jskit-ai/users-web/client/components/AccountSettingsClientElement";
17
17
  </script>
@@ -16,12 +16,12 @@ test("resolveAccountSettingsSections normalizes, deduplicates, and sorts placeme
16
16
 
17
17
  const resolved = resolveAccountSettingsSections([
18
18
  {
19
- id: "users.notifications.duplicate",
19
+ id: "users.profile.duplicate",
20
20
  order: 999,
21
21
  component: SectionA,
22
22
  props: {
23
- value: "notifications",
24
- title: "Ignore duplicate"
23
+ value: "profile",
24
+ title: "Profile duplicate"
25
25
  }
26
26
  },
27
27
  {
@@ -37,10 +37,20 @@ test("resolveAccountSettingsSections normalizes, deduplicates, and sorts placeme
37
37
  id: "invalid.missing-component",
38
38
  order: 250,
39
39
  props: {
40
- value: "profile",
40
+ value: "broken",
41
41
  title: "Broken missing component"
42
42
  }
43
43
  },
44
+ {
45
+ id: "users.profile",
46
+ order: 100,
47
+ component: SectionB,
48
+ props: {
49
+ value: "profile",
50
+ title: "Profile",
51
+ usesSharedRuntime: true
52
+ }
53
+ },
44
54
  {
45
55
  id: "security.section",
46
56
  order: 350,
@@ -70,8 +80,9 @@ test("resolveAccountSettingsSections normalizes, deduplicates, and sorts placeme
70
80
  usesSharedRuntime: entry.usesSharedRuntime
71
81
  })),
72
82
  [
83
+ { value: "invites", title: "Second duplicate", order: 100, usesSharedRuntime: false },
84
+ { value: "profile", title: "Profile", order: 100, usesSharedRuntime: true },
73
85
  { value: "security", title: "Security", order: 350, usesSharedRuntime: true },
74
- { value: "invites", title: "Invites", order: 400, usesSharedRuntime: false }
75
86
  ]
76
87
  );
77
88
  });
@@ -35,6 +35,23 @@ test("createAddEditUiRuntime resolves view urls for saved payload ids with neste
35
35
  assert.equal(runtime.resolveSavedViewUrl({ id: 99 }), "/contacts/7/addresses/99");
36
36
  });
37
37
 
38
+ test("createAddEditUiRuntime keeps nested child routes stable when the saved child id matches a parent id", () => {
39
+ const runtime = createAddEditUiRuntime({
40
+ recordIdParam: "addressId",
41
+ routeParams: ref({
42
+ workspaceSlug: "tonymobily",
43
+ contactId: "1"
44
+ }),
45
+ routeParamNames: ref(["workspaceSlug", "contactId"]),
46
+ routePath: ref("/w/tonymobily/admin/contacts/1/addresses/new"),
47
+ viewUrlTemplate: "../:addressId",
48
+ listUrlTemplate: ".."
49
+ });
50
+
51
+ assert.equal(runtime.listUrl.value, "/w/tonymobily/admin/contacts/1/addresses");
52
+ assert.equal(runtime.resolveSavedViewUrl({ id: 1 }), "/w/tonymobily/admin/contacts/1/addresses/1");
53
+ });
54
+
38
55
  test("createAddEditUiRuntime resolves edit-page relative list and cancel links", () => {
39
56
  const runtime = createAddEditUiRuntime({
40
57
  recordIdParam: "addressId",
@@ -0,0 +1,166 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createSchema } from "json-rest-schema";
4
+ import {
5
+ inferCrudJsonApiTransport,
6
+ inferCrudLookupJsonApiTransport,
7
+ resolveCrudJsonApiTransport,
8
+ resolveLookupFieldMap
9
+ } from "../src/client/composables/crud/crudJsonApiTransportSupport.js";
10
+
11
+ const resource = {
12
+ namespace: "pets",
13
+ contract: {
14
+ lookup: {
15
+ containerKey: "lookups"
16
+ }
17
+ },
18
+ operations: {
19
+ view: {
20
+ output: {
21
+ schema: createSchema({
22
+ id: {
23
+ type: "string",
24
+ required: true
25
+ },
26
+ contactId: {
27
+ type: "string",
28
+ required: true,
29
+ belongsTo: "contacts",
30
+ as: "contact"
31
+ },
32
+ breedId: {
33
+ type: "string",
34
+ nullable: true,
35
+ belongsTo: "breeds",
36
+ as: "breed"
37
+ }
38
+ })
39
+ }
40
+ }
41
+ }
42
+ };
43
+
44
+ test("inferCrudJsonApiTransport infers collection transport for CRUD lists", () => {
45
+ assert.deepEqual(
46
+ inferCrudJsonApiTransport(resource, {
47
+ mode: "list"
48
+ }),
49
+ {
50
+ kind: "jsonapi-resource",
51
+ responseType: "pets",
52
+ responseKind: "collection"
53
+ }
54
+ );
55
+ });
56
+
57
+ test("inferCrudJsonApiTransport infers record request/response transport for CRUD add/edit", () => {
58
+ assert.deepEqual(
59
+ inferCrudJsonApiTransport(resource, {
60
+ mode: "add-edit",
61
+ operationName: "patch"
62
+ }),
63
+ {
64
+ kind: "jsonapi-resource",
65
+ requestType: "pets",
66
+ responseType: "pets",
67
+ responseKind: "record"
68
+ }
69
+ );
70
+ });
71
+
72
+ test("inferCrudLookupJsonApiTransport infers collection transport from lookup namespace", () => {
73
+ assert.deepEqual(
74
+ inferCrudLookupJsonApiTransport({
75
+ namespace: "services"
76
+ }),
77
+ {
78
+ kind: "jsonapi-resource",
79
+ responseType: "services",
80
+ responseKind: "collection"
81
+ }
82
+ );
83
+ });
84
+
85
+ test("inferCrudLookupJsonApiTransport infers collection transport from lookup apiPath", () => {
86
+ assert.deepEqual(
87
+ inferCrudLookupJsonApiTransport({
88
+ apiPath: "/contact-roles"
89
+ }),
90
+ {
91
+ kind: "jsonapi-resource",
92
+ responseType: "contact-roles",
93
+ responseKind: "collection"
94
+ }
95
+ );
96
+ });
97
+
98
+ test("inferCrudLookupJsonApiTransport returns null without a lookup namespace", () => {
99
+ assert.equal(
100
+ inferCrudLookupJsonApiTransport({}),
101
+ null
102
+ );
103
+ });
104
+
105
+ test("resolveLookupFieldMap derives lookup aliases from the shared CRUD resource", () => {
106
+ assert.deepEqual(resolveLookupFieldMap(resource), {
107
+ breed: "breedId",
108
+ contact: "contactId"
109
+ });
110
+ });
111
+
112
+ test("resolveCrudJsonApiTransport infers and enriches JSON:API transport from the resource", () => {
113
+ assert.deepEqual(
114
+ resolveCrudJsonApiTransport(undefined, resource, {
115
+ mode: "list"
116
+ }),
117
+ {
118
+ kind: "jsonapi-resource",
119
+ responseType: "pets",
120
+ responseKind: "collection",
121
+ lookupContainerKey: "lookups",
122
+ lookupFieldMap: {
123
+ breed: "breedId",
124
+ contact: "contactId"
125
+ }
126
+ }
127
+ );
128
+ });
129
+
130
+ test("resolveCrudJsonApiTransport infers record request/response transport for CRUD add/edit", () => {
131
+ assert.deepEqual(
132
+ resolveCrudJsonApiTransport(undefined, resource, {
133
+ mode: "add-edit",
134
+ operationName: "patch"
135
+ }),
136
+ {
137
+ kind: "jsonapi-resource",
138
+ requestType: "pets",
139
+ responseType: "pets",
140
+ responseKind: "record",
141
+ lookupContainerKey: "lookups",
142
+ lookupFieldMap: {
143
+ breed: "breedId",
144
+ contact: "contactId"
145
+ }
146
+ }
147
+ );
148
+ });
149
+
150
+ test("resolveCrudJsonApiTransport rejects explicit CRUD transport overrides", () => {
151
+ assert.throws(
152
+ () =>
153
+ resolveCrudJsonApiTransport(
154
+ {
155
+ kind: "jsonapi-resource",
156
+ responseType: "pets",
157
+ responseKind: "record"
158
+ },
159
+ resource,
160
+ {
161
+ mode: "view"
162
+ }
163
+ ),
164
+ /no longer accept explicit transport/
165
+ );
166
+ });
@@ -203,6 +203,31 @@ test("resolveLookupFieldDisplayValue supports custom lookup container key", () =
203
203
  );
204
204
  });
205
205
 
206
+ test("resolveLookupFieldDisplayValue falls back to alias-keyed lookups for relationship-backed fields", () => {
207
+ assert.equal(
208
+ resolveLookupFieldDisplayValue(
209
+ {
210
+ vetId: "17",
211
+ lookups: {
212
+ vet: {
213
+ id: "17",
214
+ name: "Harbor Vet"
215
+ }
216
+ }
217
+ },
218
+ {
219
+ key: "vetId",
220
+ relation: {
221
+ kind: "lookup",
222
+ valueKey: "id",
223
+ labelKey: "name"
224
+ }
225
+ }
226
+ ),
227
+ "Harbor Vet"
228
+ );
229
+ });
230
+
206
231
  test("resolveLookupFieldDisplayValue returns raw value for non-lookup fields", () => {
207
232
  assert.equal(
208
233
  resolveLookupFieldDisplayValue(
@@ -15,6 +15,7 @@ test("users-web exports are explicit and aligned with production/template usage"
15
15
  packageId: "@jskit-ai/users-web",
16
16
  requiredExports: [
17
17
  "./client",
18
+ "./client/components/AccountSettingsClientElement",
18
19
  "./client/account-settings/sections",
19
20
  "./client/composables/useAddEdit",
20
21
  "./client/composables/useCommand",
@@ -95,6 +95,7 @@ test("list request options stay GET and carry transport when provided", () => {
95
95
  "x-demo": "1"
96
96
  },
97
97
  query: {
98
+ limit: 20,
98
99
  cursor: "cursor_2"
99
100
  },
100
101
  transport: {
@@ -105,3 +106,37 @@ test("list request options stay GET and carry transport when provided", () => {
105
106
  }
106
107
  );
107
108
  });
109
+
110
+ test("list request options preserve explicit limit values", () => {
111
+ assert.deepEqual(
112
+ buildListRequestOptions({
113
+ requestOptions: {
114
+ query: {
115
+ limit: 50
116
+ }
117
+ }
118
+ }),
119
+ {
120
+ method: "GET",
121
+ query: {
122
+ limit: 50
123
+ }
124
+ }
125
+ );
126
+
127
+ assert.deepEqual(
128
+ buildListRequestOptions({
129
+ requestOptions: {
130
+ query: {
131
+ "page[limit]": "75"
132
+ }
133
+ }
134
+ }),
135
+ {
136
+ method: "GET",
137
+ query: {
138
+ "page[limit]": "75"
139
+ }
140
+ }
141
+ );
142
+ });