@jskit-ai/users-web 0.1.35 → 0.1.37

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 (32) hide show
  1. package/package.descriptor.mjs +8 -22
  2. package/package.json +7 -6
  3. package/src/client/components/MembersAdminClientElement.vue +5 -5
  4. package/src/client/components/WorkspaceMembersClientElement.vue +16 -16
  5. package/src/client/components/WorkspacesClientElement.vue +2 -2
  6. package/src/client/composables/accountSettingsAvatarUploadRuntime.js +26 -172
  7. package/src/client/composables/accountSettingsRuntimeConstants.js +0 -4
  8. package/src/client/composables/accountSettingsRuntimeHelpers.js +1 -1
  9. package/src/client/composables/addEditUiRuntime.js +11 -2
  10. package/src/client/composables/crudLookupFieldLabelSupport.js +36 -4
  11. package/src/client/composables/crudLookupFieldRuntime.js +5 -2
  12. package/src/client/composables/crudSchemaFormHelpers.js +23 -3
  13. package/src/client/composables/listQueryParamSupport.js +459 -0
  14. package/src/client/composables/listUiRuntime.js +18 -6
  15. package/src/client/composables/routeTemplateHelpers.js +122 -0
  16. package/src/client/composables/useAddEdit.js +10 -0
  17. package/src/client/composables/useList.js +242 -2
  18. package/src/client/composables/usePagedCollection.js +55 -4
  19. package/src/client/composables/useView.js +4 -1
  20. package/src/client/composables/viewUiRuntime.js +11 -2
  21. package/src/client/lib/bootstrap.js +1 -1
  22. package/src/client/lib/menuIcons.js +27 -6
  23. package/templates/src/components/WorkspaceNotFoundCard.vue +2 -1
  24. package/templates/src/components/account/settings/AccountSettingsInvitesSection.vue +1 -1
  25. package/test/addEditUiRuntime.test.js +18 -0
  26. package/test/crudLookupFieldRuntime.test.js +51 -1
  27. package/test/listQueryParamSupport.test.js +190 -0
  28. package/test/listUiRuntime.test.js +21 -0
  29. package/test/menuIcons.test.js +2 -0
  30. package/test/routeTemplateHelpers.test.js +56 -0
  31. package/test/usePagedCollection.test.js +53 -0
  32. package/test/viewUiRuntime.test.js +35 -0
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import {
4
4
  resolveLookupItemLabel,
5
- resolveLookupFieldDisplayValue
5
+ resolveLookupFieldDisplayValue,
6
+ resolveRecordTitle
6
7
  } from "../src/client/composables/crudLookupFieldLabelSupport.js";
7
8
 
8
9
  test("resolveLookupItemLabel composes name + surname", () => {
@@ -31,6 +32,19 @@ test("resolveLookupItemLabel composes firstName + surname", () => {
31
32
  );
32
33
  });
33
34
 
35
+ test("resolveLookupItemLabel composes firstName + lastName", () => {
36
+ assert.equal(
37
+ resolveLookupItemLabel(
38
+ {
39
+ firstName: "Ana",
40
+ lastName: "Marin"
41
+ },
42
+ "name"
43
+ ),
44
+ "Ana Marin"
45
+ );
46
+ });
47
+
34
48
  test("resolveLookupItemLabel falls back to explicit labelKey", () => {
35
49
  assert.equal(
36
50
  resolveLookupItemLabel(
@@ -59,6 +73,42 @@ test("resolveLookupItemLabel returns empty when no label fields match", () => {
59
73
  assert.equal(resolveLookupItemLabel({ id: 42 }, "name"), "");
60
74
  });
61
75
 
76
+ test("resolveRecordTitle composes name-like fields before fallback key", () => {
77
+ assert.equal(
78
+ resolveRecordTitle(
79
+ {
80
+ firstName: "Ana",
81
+ lastName: "Marin",
82
+ title: "Ignored"
83
+ },
84
+ {
85
+ fallbackKey: "title",
86
+ defaultValue: "Record"
87
+ }
88
+ ),
89
+ "Ana Marin"
90
+ );
91
+ });
92
+
93
+ test("resolveRecordTitle falls back to provided field key", () => {
94
+ assert.equal(
95
+ resolveRecordTitle(
96
+ {
97
+ title: "Harbor Visit"
98
+ },
99
+ {
100
+ fallbackKey: "title",
101
+ defaultValue: "Record"
102
+ }
103
+ ),
104
+ "Harbor Visit"
105
+ );
106
+ });
107
+
108
+ test('resolveRecordTitle falls back to "-" when no title data exists', () => {
109
+ assert.equal(resolveRecordTitle({}, { fallbackKey: "title", defaultValue: "" }), "-");
110
+ });
111
+
62
112
  test("resolveLookupFieldDisplayValue returns hydrated label for lookup fields", () => {
63
113
  assert.equal(
64
114
  resolveLookupFieldDisplayValue(
@@ -0,0 +1,190 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { ref } from "vue";
4
+ import {
5
+ normalizeListSyncToRouteConfig,
6
+ resolveQueryParamDescriptors,
7
+ resolveActiveQueryParamEntries,
8
+ resolveWritableQueryParamBindings,
9
+ buildQueryParamEntriesToken,
10
+ parseRouteBindingValue,
11
+ areQueryParamBindingValuesEqual,
12
+ buildRouteQueryCompareToken,
13
+ mergeManagedQueryParamKeyHistory,
14
+ resolveRouteSyncManagedKeys
15
+ } from "../src/client/composables/listQueryParamSupport.js";
16
+
17
+ test("normalizeListSyncToRouteConfig defaults", () => {
18
+ assert.deepEqual(
19
+ normalizeListSyncToRouteConfig(false, {
20
+ defaultSearchParam: "search"
21
+ }),
22
+ {
23
+ enabled: false,
24
+ mode: "replace",
25
+ syncSearch: false,
26
+ syncQueryParams: false,
27
+ hydrateFromRoute: false,
28
+ searchParam: "search",
29
+ queryParamBlacklist: []
30
+ }
31
+ );
32
+
33
+ assert.deepEqual(
34
+ normalizeListSyncToRouteConfig(true),
35
+ {
36
+ enabled: true,
37
+ mode: "replace",
38
+ syncSearch: true,
39
+ syncQueryParams: true,
40
+ hydrateFromRoute: true,
41
+ searchParam: "q",
42
+ queryParamBlacklist: []
43
+ }
44
+ );
45
+
46
+ assert.deepEqual(
47
+ normalizeListSyncToRouteConfig({
48
+ enabled: true,
49
+ queryParamBlacklist: [" include ", "cursor", "include", ""]
50
+ }),
51
+ {
52
+ enabled: true,
53
+ mode: "replace",
54
+ syncSearch: true,
55
+ syncQueryParams: true,
56
+ hydrateFromRoute: true,
57
+ searchParam: "q",
58
+ queryParamBlacklist: ["include", "cursor"]
59
+ }
60
+ );
61
+ });
62
+
63
+ test("resolveQueryParamDescriptors keeps declared keys and supports writable bindings", () => {
64
+ const source = {
65
+ " status ": ref("open"),
66
+ " count ": 2,
67
+ includeArchived: ref(false),
68
+ empty: ""
69
+ };
70
+
71
+ const descriptors = resolveQueryParamDescriptors(source);
72
+ assert.deepEqual(
73
+ descriptors.map((descriptor) => descriptor.key),
74
+ ["count", "empty", "includeArchived", "status"]
75
+ );
76
+
77
+ const activeEntries = resolveActiveQueryParamEntries(descriptors);
78
+ assert.deepEqual(
79
+ activeEntries,
80
+ [
81
+ {
82
+ key: "count",
83
+ values: ["2"]
84
+ },
85
+ {
86
+ key: "status",
87
+ values: ["open"]
88
+ }
89
+ ]
90
+ );
91
+
92
+ const bindings = resolveWritableQueryParamBindings(descriptors);
93
+ const countBinding = bindings.find((binding) => binding.key === "count");
94
+ assert.ok(countBinding);
95
+ countBinding.set(9);
96
+ assert.equal(source[" count "], 9);
97
+ assert.equal(source.count, undefined);
98
+ });
99
+
100
+ test("buildQueryParamEntriesToken and compare token stay deterministic", () => {
101
+ assert.equal(
102
+ buildQueryParamEntriesToken([
103
+ {
104
+ key: "status",
105
+ values: ["open"]
106
+ },
107
+ {
108
+ key: "tags",
109
+ values: ["a", "b"]
110
+ }
111
+ ]),
112
+ "status=open&tags=a,b"
113
+ );
114
+
115
+ assert.equal(
116
+ buildRouteQueryCompareToken({
117
+ b: "2",
118
+ a: ["1"]
119
+ }),
120
+ buildRouteQueryCompareToken({
121
+ a: "1",
122
+ b: ["2"]
123
+ })
124
+ );
125
+ });
126
+
127
+ test("parseRouteBindingValue handles boolean, number and array bindings", () => {
128
+ const booleanBinding = {
129
+ valueType: "boolean",
130
+ get: () => false
131
+ };
132
+ assert.equal(parseRouteBindingValue(booleanBinding, "1"), true);
133
+ assert.equal(parseRouteBindingValue(booleanBinding, undefined), false);
134
+
135
+ const numberBinding = {
136
+ valueType: "number",
137
+ get: () => 7
138
+ };
139
+ assert.equal(parseRouteBindingValue(numberBinding, "12"), 12);
140
+ assert.equal(parseRouteBindingValue(numberBinding, "bad"), 7);
141
+ assert.equal(parseRouteBindingValue(numberBinding, undefined), null);
142
+
143
+ const arrayBinding = {
144
+ valueType: "array",
145
+ arrayItemType: "number",
146
+ get: () => []
147
+ };
148
+ assert.deepEqual(
149
+ parseRouteBindingValue(arrayBinding, ["1", "bad", "3"]),
150
+ [1, 3]
151
+ );
152
+ });
153
+
154
+ test("route sync key helpers preserve declared key history for cleanup", () => {
155
+ const history = mergeManagedQueryParamKeyHistory(
156
+ ["status"],
157
+ ["assignee", "status"]
158
+ );
159
+ assert.deepEqual(history, ["assignee", "status"]);
160
+
161
+ assert.deepEqual(
162
+ resolveRouteSyncManagedKeys({
163
+ searchEnabled: true,
164
+ searchParam: "q",
165
+ syncSearch: true,
166
+ syncQueryParams: true,
167
+ declaredKeys: ["status"],
168
+ keyHistory: ["assignee"]
169
+ }),
170
+ ["assignee", "q", "status"]
171
+ );
172
+ });
173
+
174
+ test("areQueryParamBindingValuesEqual handles arrays and dates", () => {
175
+ assert.equal(
176
+ areQueryParamBindingValuesEqual([1, 2], [1, 2]),
177
+ true
178
+ );
179
+ assert.equal(
180
+ areQueryParamBindingValuesEqual([1, 2], [2, 1]),
181
+ false
182
+ );
183
+ assert.equal(
184
+ areQueryParamBindingValuesEqual(
185
+ new Date("2026-01-01T00:00:00.000Z"),
186
+ new Date("2026-01-01T00:00:00.000Z")
187
+ ),
188
+ true
189
+ );
190
+ });
@@ -22,6 +22,27 @@ test("createListUiRuntime resolves row keys and relative route templates from st
22
22
  assert.equal(runtime.resolveEditUrl(items.value[0]), "/w/acme/admin/contacts/abc%20123/edit");
23
23
  });
24
24
 
25
+ test("createListUiRuntime resolves nested route links from the parent list scope", () => {
26
+ const runtime = createListUiRuntime({
27
+ items: ref([{ id: "901" }]),
28
+ isInitialLoading: ref(false),
29
+ recordIdParam: "petId",
30
+ routeParams: ref({
31
+ workspaceSlug: "dogandgroom",
32
+ contactId: "541841",
33
+ petId: "715528"
34
+ }),
35
+ routeParamNames: ref(["workspaceSlug", "contactId", "petId"]),
36
+ routePath: ref("/w/dogandgroom/admin/contacts/541841/pets/715528"),
37
+ viewUrlTemplate: "./:petId",
38
+ editUrlTemplate: "./:petId/edit"
39
+ });
40
+
41
+ assert.equal(runtime.resolveParams("./new"), "/w/dogandgroom/admin/contacts/541841/pets/new");
42
+ assert.equal(runtime.resolveViewUrl({ id: "901" }), "/w/dogandgroom/admin/contacts/541841/pets/901");
43
+ assert.equal(runtime.resolveEditUrl({ id: "901" }), "/w/dogandgroom/admin/contacts/541841/pets/901/edit");
44
+ });
45
+
25
46
  test("createListUiRuntime resolves templates that depend on existing route params", () => {
26
47
  const runtime = createListUiRuntime({
27
48
  items: ref([{ id: 42 }]),
@@ -19,9 +19,11 @@ test("resolveSurfaceSwitchIcon prefers explicit icon and maps known surfaces", (
19
19
  assert.equal(resolveSurfaceSwitchIcon("admin"), mdiShieldCrownOutline);
20
20
  assert.equal(resolveSurfaceSwitchIcon("console"), mdiConsoleNetworkOutline);
21
21
  assert.equal(resolveSurfaceSwitchIcon("admin", "custom-icon"), "custom-icon");
22
+ assert.equal(resolveSurfaceSwitchIcon("admin", "mdi-cog-outline"), mdiCogOutline);
22
23
  });
23
24
 
24
25
  test("resolveMenuLinkIcon resolves settings/login fallbacks and generic default", () => {
26
+ assert.equal(resolveMenuLinkIcon({ icon: "mdi-cog-outline" }), mdiCogOutline);
25
27
  assert.equal(resolveMenuLinkIcon({ to: "/account" }), mdiAccountCogOutline);
26
28
  assert.equal(resolveMenuLinkIcon({ label: "Sign in" }), mdiLogin);
27
29
  assert.equal(resolveMenuLinkIcon({ label: "Go to admin" }), mdiShieldCrownOutline);
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import {
4
4
  extractRouteParamNames,
5
+ resolveScopedRoutePathname,
5
6
  resolveRouteParamNamesInOrder
6
7
  } from "../src/client/composables/routeTemplateHelpers.js";
7
8
 
@@ -27,3 +28,58 @@ test("resolveRouteParamNamesInOrder prefers matched route templates", () => {
27
28
 
28
29
  assert.deepEqual(resolveRouteParamNamesInOrder(route), ["workspaceSlug", "contactId", "addressId"]);
29
30
  });
31
+
32
+ test("resolveScopedRoutePathname supports at/before/after anchors", () => {
33
+ const currentPathname = "/w/dogandgroom/admin/contacts/541841/pets/715528/edit/advanced";
34
+ const params = {
35
+ workspaceSlug: "dogandgroom",
36
+ contactId: "541841",
37
+ petId: "715528"
38
+ };
39
+ const orderedParamNames = ["workspaceSlug", "contactId", "petId"];
40
+
41
+ assert.equal(
42
+ resolveScopedRoutePathname({
43
+ currentPathname,
44
+ params,
45
+ orderedParamNames,
46
+ anchorParamName: "contactId",
47
+ anchorMode: "at"
48
+ }),
49
+ "/w/dogandgroom/admin/contacts/541841"
50
+ );
51
+ assert.equal(
52
+ resolveScopedRoutePathname({
53
+ currentPathname,
54
+ params,
55
+ orderedParamNames,
56
+ anchorParamName: "petId",
57
+ anchorMode: "before"
58
+ }),
59
+ "/w/dogandgroom/admin/contacts/541841/pets"
60
+ );
61
+ assert.equal(
62
+ resolveScopedRoutePathname({
63
+ currentPathname,
64
+ params,
65
+ orderedParamNames,
66
+ anchorParamName: "petId",
67
+ anchorMode: "after"
68
+ }),
69
+ "/w/dogandgroom/admin/contacts/541841/pets/715528/edit"
70
+ );
71
+ });
72
+
73
+ test("resolveScopedRoutePathname falls back to direct value matching", () => {
74
+ assert.equal(
75
+ resolveScopedRoutePathname({
76
+ currentPathname: "/contacts/abc%20123/notes",
77
+ params: {},
78
+ orderedParamNames: [],
79
+ anchorParamName: "contactId",
80
+ anchorParamValue: "abc 123",
81
+ anchorMode: "at"
82
+ }),
83
+ "/contacts/abc%20123"
84
+ );
85
+ });
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { trimInfinitePagesToFirst } from "../src/client/composables/usePagedCollection.js";
4
+
5
+ test("trimInfinitePagesToFirst leaves non-object payloads untouched", () => {
6
+ assert.equal(trimInfinitePagesToFirst(null), null);
7
+ assert.equal(trimInfinitePagesToFirst(""), "");
8
+ assert.equal(trimInfinitePagesToFirst(12), 12);
9
+ });
10
+
11
+ test("trimInfinitePagesToFirst leaves single-page payload unchanged", () => {
12
+ const payload = Object.freeze({
13
+ pages: Object.freeze([{ items: [1, 2] }]),
14
+ pageParams: Object.freeze([null])
15
+ });
16
+
17
+ assert.equal(trimInfinitePagesToFirst(payload), payload);
18
+ });
19
+
20
+ test("trimInfinitePagesToFirst truncates to the first page and first pageParam", () => {
21
+ const payload = {
22
+ pages: [
23
+ { items: [1, 2] },
24
+ { items: [3, 4] },
25
+ { items: [5, 6] }
26
+ ],
27
+ pageParams: [null, "cursor-1", "cursor-2"],
28
+ extra: true
29
+ };
30
+
31
+ assert.deepEqual(
32
+ trimInfinitePagesToFirst(payload),
33
+ {
34
+ pages: [{ items: [1, 2] }],
35
+ pageParams: [null],
36
+ extra: true
37
+ }
38
+ );
39
+ });
40
+
41
+ test("trimInfinitePagesToFirst injects null pageParam when missing", () => {
42
+ const payload = {
43
+ pages: [{ items: [1] }, { items: [2] }]
44
+ };
45
+
46
+ assert.deepEqual(
47
+ trimInfinitePagesToFirst(payload),
48
+ {
49
+ pages: [{ items: [1] }],
50
+ pageParams: [null]
51
+ }
52
+ );
53
+ });
@@ -23,6 +23,41 @@ test("createViewUiRuntime resolves api/list/edit paths with nested params", () =
23
23
  assert.equal(runtime.resolveParams("./edit"), "/users/user-7/addresses/addr-42/edit");
24
24
  });
25
25
 
26
+ test("createViewUiRuntime resolves view links from the record pathname when route is nested", () => {
27
+ const runtime = createViewUiRuntime({
28
+ recordIdParam: "contactId",
29
+ routeParams: ref({
30
+ workspaceSlug: "dogandgroom",
31
+ contactId: "541841",
32
+ petId: "715528"
33
+ }),
34
+ routeParamNames: ref(["workspaceSlug", "contactId", "petId"]),
35
+ routePath: ref("/w/dogandgroom/admin/contacts/541841/pets/715528"),
36
+ listUrlTemplate: "..",
37
+ editUrlTemplate: "./edit"
38
+ });
39
+
40
+ assert.equal(runtime.listUrl.value, "/w/dogandgroom/admin/contacts");
41
+ assert.equal(runtime.editUrl.value, "/w/dogandgroom/admin/contacts/541841/edit");
42
+ assert.equal(runtime.resolveParams("./edit"), "/w/dogandgroom/admin/contacts/541841/edit");
43
+ });
44
+
45
+ test("createViewUiRuntime uses route param order when repeated values are present", () => {
46
+ const runtime = createViewUiRuntime({
47
+ recordIdParam: "contactId",
48
+ routeParams: ref({
49
+ workspaceSlug: "123",
50
+ contactId: "123",
51
+ petId: "123"
52
+ }),
53
+ routeParamNames: ref(["workspaceSlug", "contactId", "petId"]),
54
+ routePath: ref("/w/123/admin/contacts/123/pets/123"),
55
+ editUrlTemplate: "./edit"
56
+ });
57
+
58
+ assert.equal(runtime.editUrl.value, "/w/123/admin/contacts/123/edit");
59
+ });
60
+
26
61
  test("createViewUiRuntime uses explicit routeRecordId when route params do not include id", () => {
27
62
  const runtime = createViewUiRuntime({
28
63
  recordIdParam: "addressId",