@jskit-ai/users-web 0.1.151 → 0.1.153

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.
@@ -3,7 +3,7 @@ import { HOME_COG_OUTLET } from "./src/shared/toolsOutletContracts.js";
3
3
  export default Object.freeze({
4
4
  packageVersion: 1,
5
5
  packageId: "@jskit-ai/users-web",
6
- version: "0.1.151",
6
+ version: "0.1.153",
7
7
  kind: "runtime",
8
8
  description: "Users web module: account/profile UI plus shared users web widgets.",
9
9
  dependsOn: [
@@ -286,12 +286,12 @@ export default Object.freeze({
286
286
  dependencies: {
287
287
  runtime: {
288
288
  "@mdi/js": "^7.4.47",
289
- "@jskit-ai/http-runtime": "0.1.132",
290
- "@jskit-ai/realtime": "0.1.131",
291
- "@jskit-ai/kernel": "0.1.134",
292
- "@jskit-ai/shell-web": "0.1.135",
293
- "@jskit-ai/uploads-image-web": "0.1.110",
294
- "@jskit-ai/users-core": "0.1.145"
289
+ "@jskit-ai/http-runtime": "0.1.134",
290
+ "@jskit-ai/realtime": "0.1.133",
291
+ "@jskit-ai/kernel": "0.1.136",
292
+ "@jskit-ai/shell-web": "0.1.137",
293
+ "@jskit-ai/uploads-image-web": "0.1.112",
294
+ "@jskit-ai/users-core": "0.1.148"
295
295
  },
296
296
  dev: {}
297
297
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.151",
3
+ "version": "0.1.153",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -46,12 +46,12 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@mdi/js": "^7.4.47",
49
- "@jskit-ai/http-runtime": "0.1.132",
50
- "@jskit-ai/kernel": "0.1.134",
51
- "@jskit-ai/realtime": "0.1.131",
52
- "@jskit-ai/shell-web": "0.1.135",
53
- "@jskit-ai/uploads-image-web": "0.1.110",
54
- "@jskit-ai/users-core": "0.1.145"
49
+ "@jskit-ai/http-runtime": "0.1.134",
50
+ "@jskit-ai/kernel": "0.1.136",
51
+ "@jskit-ai/realtime": "0.1.133",
52
+ "@jskit-ai/shell-web": "0.1.137",
53
+ "@jskit-ai/uploads-image-web": "0.1.112",
54
+ "@jskit-ai/users-core": "0.1.148"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@tanstack/vue-query": "^5.90.5",
@@ -7,7 +7,10 @@ import { resolveOperationAdapter } from "../runtime/operationAdapters.js";
7
7
  import { setupOperationErrorReporting } from "../runtime/operationUiHelpers.js";
8
8
  import { createListUiRuntime } from "../runtime/listUiRuntime.js";
9
9
  import { asPlainObject } from "../support/scopeHelpers.js";
10
- import { buildRequestQueryObject } from "../support/requestQueryRuntimeSupport.js";
10
+ import {
11
+ buildRequestQueryObject,
12
+ createRequestQueryRuntime
13
+ } from "../support/requestQueryRuntimeSupport.js";
11
14
  import {
12
15
  normalizeListSearchConfig,
13
16
  matchesLocalSearch
@@ -57,13 +60,18 @@ function useList({
57
60
  editUrlTemplate = "",
58
61
  search = null,
59
62
  queryParams = null,
63
+ routeQueryValueResolvers = null,
60
64
  requestQueryParams = null,
65
+ requestFieldsets = null,
61
66
  syncToRoute = false
62
67
  } = {}) {
63
68
  const searchConfig = normalizeListSearchConfig(search);
64
69
  const routeSyncConfig = normalizeListSyncToRouteConfig(syncToRoute, {
65
70
  defaultSearchParam: searchConfig.queryParam
66
71
  });
72
+ const routeSyncHydrated = ref(
73
+ routeSyncConfig.enabled !== true || routeSyncConfig.hydrateFromRoute !== true
74
+ );
67
75
  const router = routeSyncConfig.enabled === true ? useRouter() : null;
68
76
  const searchQuery = ref(searchConfig.initialQuery);
69
77
  const debouncedSearchQuery = ref(searchConfig.initialQuery);
@@ -140,24 +148,29 @@ function useList({
140
148
  });
141
149
  });
142
150
  const queryParamDescriptors = computed(() => {
143
- return resolveQueryParamDescriptors(queryParams, queryParamsContext.value);
151
+ return resolveQueryParamDescriptors(queryParams, queryParamsContext.value, {
152
+ routeValueResolvers: routeQueryValueResolvers
153
+ });
144
154
  });
145
- const requestQueryParamDescriptors = computed(() => {
146
- return resolveQueryParamDescriptors(requestQueryParams, queryParamsContext.value);
155
+ const requestQueryRuntime = createRequestQueryRuntime({
156
+ requestQueryParams,
157
+ requestFieldsets,
158
+ context: queryParamsContext
147
159
  });
160
+ const {
161
+ activeRequestQueryParamEntries,
162
+ activeRequestQueryParamsToken,
163
+ activeRequestFieldsets,
164
+ activeRequestFieldsetsToken,
165
+ queryKeyParts: requestQueryKeyParts
166
+ } = requestQueryRuntime;
148
167
  const declaredQueryParamKeys = computed(() => {
149
168
  return queryParamDescriptors.value.map((descriptor) => descriptor.key);
150
169
  });
151
170
  const activeQueryParamEntries = computed(() => {
152
171
  return resolveActiveQueryParamEntries(queryParamDescriptors.value);
153
172
  });
154
- const activeRequestQueryParamEntries = computed(() => {
155
- return resolveActiveQueryParamEntries(requestQueryParamDescriptors.value);
156
- });
157
173
  const activeQueryParamsToken = computed(() => buildQueryParamEntriesToken(activeQueryParamEntries.value));
158
- const activeRequestQueryParamsToken = computed(() => {
159
- return buildQueryParamEntriesToken(activeRequestQueryParamEntries.value);
160
- });
161
174
  const writableQueryParamBindings = computed(() => {
162
175
  return resolveWritableQueryParamBindings(queryParamDescriptors.value);
163
176
  });
@@ -191,7 +204,9 @@ function useList({
191
204
  entries.push(...activeRequestQueryParamEntries.value);
192
205
  entries.push(...activeQueryParamEntries.value);
193
206
 
194
- const query = buildRequestQueryObject(entries);
207
+ const query = buildRequestQueryObject(entries, {
208
+ fieldsets: activeRequestFieldsets.value
209
+ });
195
210
  const baseOptions = asPlainObject(requestOptions);
196
211
  if (Object.keys(query).length < 1) {
197
212
  return baseOptions;
@@ -209,9 +224,7 @@ function useList({
209
224
  : sourceQueryKey == null
210
225
  ? []
211
226
  : [sourceQueryKey];
212
- if (activeRequestQueryParamsToken.value) {
213
- baseQueryKey.push("__request_query__", activeRequestQueryParamsToken.value);
214
- }
227
+ baseQueryKey.push(...requestQueryKeyParts.value);
215
228
  if (querySearchEnabled.value) {
216
229
  baseQueryKey.push("__search__", searchConfig.queryParam, activeSearchQuery.value);
217
230
  }
@@ -221,10 +234,11 @@ function useList({
221
234
  return baseQueryKey;
222
235
  });
223
236
 
237
+ const listReadEnabled = computed(() => canView.value && routeSyncHydrated.value);
224
238
  const list = useListCore({
225
239
  queryKey: listQueryKey,
226
240
  path: listPath,
227
- enabled: operationScope.queryCanRun(canView),
241
+ enabled: operationScope.queryCanRun(listReadEnabled),
228
242
  client,
229
243
  transport,
230
244
  initialPageParam,
@@ -236,7 +250,6 @@ function useList({
236
250
  requestRecoveryLabel,
237
251
  fallbackLoadError
238
252
  });
239
- const routeSyncHydrated = ref(routeSyncConfig.enabled !== true);
240
253
  const routeSyncApplying = ref(false);
241
254
  const routeSyncManagedKeyHistory = ref([]);
242
255
  const routeSyncQueryParamBlacklist = computed(() => {
@@ -306,6 +319,7 @@ function useList({
306
319
  watch(
307
320
  () => operationScope.routeContext.route?.query || {},
308
321
  (routeQuery) => {
322
+ const initialHydration = routeSyncHydrated.value !== true;
309
323
  if (routeSyncConfig.hydrateFromRoute !== true || routeSyncApplying.value === true) {
310
324
  routeSyncHydrated.value = true;
311
325
  return;
@@ -324,7 +338,9 @@ function useList({
324
338
  if (routeSyncQueryParamBlacklistSet.value.has(binding.key)) {
325
339
  continue;
326
340
  }
327
- const nextValue = parseRouteBindingValue(binding, routeQuerySource[binding.key]);
341
+ const nextValue = parseRouteBindingValue(binding, routeQuerySource[binding.key], {
342
+ initial: initialHydration
343
+ });
328
344
  const currentValue = typeof binding.get === "function" ? binding.get() : undefined;
329
345
  if (areQueryParamBindingValuesEqual(currentValue, nextValue)) {
330
346
  continue;
@@ -397,20 +413,16 @@ function useList({
397
413
 
398
414
  list.trimToFirstPage();
399
415
  });
400
- watch(activeQueryParamsToken, (nextValue, previousValue) => {
401
- if (nextValue === previousValue) {
402
- return;
403
- }
404
-
405
- list.trimToFirstPage();
406
- });
407
- watch(activeRequestQueryParamsToken, (nextValue, previousValue) => {
408
- if (nextValue === previousValue) {
409
- return;
416
+ watch(
417
+ [
418
+ activeQueryParamsToken,
419
+ activeRequestQueryParamsToken,
420
+ activeRequestFieldsetsToken
421
+ ],
422
+ () => {
423
+ list.trimToFirstPage();
410
424
  }
411
-
412
- list.trimToFirstPage();
413
- });
425
+ );
414
426
  const filteredItems = computed(() => {
415
427
  const sourceItems = Array.isArray(list.items.value) ? list.items.value : [];
416
428
  if (searchConfig.enabled !== true || searchConfig.mode !== "local") {
@@ -31,6 +31,7 @@ function useView({
31
31
  model,
32
32
  mapLoadedToModel,
33
33
  requestQueryParams = null,
34
+ requestFieldsets = null,
34
35
  recordIdParam = "recordId",
35
36
  routeParams = null,
36
37
  routeRecordId = null,
@@ -95,6 +96,7 @@ function useView({
95
96
  });
96
97
  const requestQueryRuntime = createRequestQueryRuntime({
97
98
  requestQueryParams,
99
+ requestFieldsets,
98
100
  context: queryParamsContext,
99
101
  sourceQueryKey: baseQueryKey
100
102
  });
@@ -137,7 +137,8 @@ function createWritableQueryParamBinding({
137
137
  source = {},
138
138
  rawKey = "",
139
139
  rawValue = null,
140
- key = ""
140
+ key = "",
141
+ resolveRouteValue = null
141
142
  } = {}) {
142
143
  const valueSourceIsRef = isRef(rawValue);
143
144
  const read = valueSourceIsRef
@@ -160,12 +161,18 @@ function createWritableQueryParamBinding({
160
161
  ? resolveArrayQueryParamItemType(currentValue)
161
162
  : QUERY_PARAM_BINDING_TYPE_TEXT,
162
163
  get: read,
163
- set: write
164
+ set: write,
165
+ resolveRouteValue
164
166
  };
165
167
  }
166
168
 
167
- function resolveQueryParamDescriptors(queryParams, context = {}) {
169
+ function resolveQueryParamDescriptors(
170
+ queryParams,
171
+ context = {},
172
+ { routeValueResolvers = null } = {}
173
+ ) {
168
174
  const source = resolveQueryParamsInput(queryParams, context);
175
+ const resolvers = asPlainObject(unref(routeValueResolvers));
169
176
  const descriptorsByKey = new Map();
170
177
  const canWriteToSource = typeof queryParams !== "function";
171
178
 
@@ -189,7 +196,10 @@ function resolveQueryParamDescriptors(queryParams, context = {}) {
189
196
  source,
190
197
  rawKey,
191
198
  rawValue,
192
- key
199
+ key,
200
+ resolveRouteValue: typeof resolvers[key] === "function"
201
+ ? resolvers[key]
202
+ : null
193
203
  });
194
204
  }
195
205
 
@@ -323,7 +333,11 @@ function parseRouteQueryItemValue(value, itemType = QUERY_PARAM_BINDING_TYPE_TEX
323
333
  return normalized || fallback;
324
334
  }
325
335
 
326
- function parseRouteBindingValue(binding, routeQueryValue) {
336
+ function parseRouteBindingValue(binding, routeQueryValue, context = {}) {
337
+ if (typeof binding?.resolveRouteValue === "function") {
338
+ return binding.resolveRouteValue(routeQueryValue, context);
339
+ }
340
+
327
341
  const valueType = normalizeText(binding?.valueType).toLowerCase();
328
342
  if (valueType === QUERY_PARAM_BINDING_TYPE_BOOLEAN) {
329
343
  return parseRouteBooleanValue(routeQueryValue, false);
@@ -1,4 +1,8 @@
1
1
  import { computed, unref } from "vue";
2
+ import {
3
+ buildJsonApiFieldsetsToken,
4
+ normalizeJsonApiFieldsets
5
+ } from "@jskit-ai/kernel/shared/support/jsonApiFieldsets";
2
6
  import {
3
7
  resolveQueryParamDescriptors,
4
8
  resolveActiveQueryParamEntries,
@@ -21,6 +25,13 @@ function resolveRequestQueryBaseKey(sourceQueryKey = null) {
21
25
  return [source];
22
26
  }
23
27
 
28
+ function resolveRequestFieldsets(requestFieldsets = null, context = {}) {
29
+ const source = typeof requestFieldsets === "function"
30
+ ? requestFieldsets(context)
31
+ : unref(requestFieldsets);
32
+ return normalizeJsonApiFieldsets(source);
33
+ }
34
+
24
35
  function appendRequestQueryValue(target = {}, key = "", values = []) {
25
36
  const normalizedKey = String(key || "").trim();
26
37
  const normalizedValues = (Array.isArray(values) ? values : [])
@@ -40,7 +51,7 @@ function appendRequestQueryValue(target = {}, key = "", values = []) {
40
51
  target[normalizedKey] = [...currentValues, ...normalizedValues];
41
52
  }
42
53
 
43
- function buildRequestQueryObject(entries = []) {
54
+ function buildRequestQueryObject(entries = [], { fieldsets = null } = {}) {
44
55
  const sourceEntries = Array.isArray(entries) ? entries : [];
45
56
  const query = {};
46
57
 
@@ -48,11 +59,18 @@ function buildRequestQueryObject(entries = []) {
48
59
  appendRequestQueryValue(query, entry?.key, entry?.values);
49
60
  }
50
61
 
51
- return Object.freeze(query);
62
+ const normalizedFieldsets = normalizeJsonApiFieldsets(fieldsets);
63
+ return Object.freeze({
64
+ ...query,
65
+ ...(Object.keys(normalizedFieldsets).length > 0
66
+ ? { fields: normalizedFieldsets }
67
+ : {})
68
+ });
52
69
  }
53
70
 
54
71
  function createRequestQueryRuntime({
55
72
  requestQueryParams = null,
73
+ requestFieldsets = null,
56
74
  context = null,
57
75
  sourceQueryKey = null
58
76
  } = {}) {
@@ -65,36 +83,59 @@ function createRequestQueryRuntime({
65
83
  const activeRequestQueryParamsToken = computed(() => {
66
84
  return buildQueryParamEntriesToken(activeRequestQueryParamEntries.value);
67
85
  });
86
+ const activeRequestFieldsets = computed(() => {
87
+ return resolveRequestFieldsets(
88
+ requestFieldsets,
89
+ resolveRequestQueryContext(context)
90
+ );
91
+ });
92
+ const activeRequestFieldsetsToken = computed(() => {
93
+ return buildJsonApiFieldsetsToken(activeRequestFieldsets.value);
94
+ });
95
+ const queryKeyParts = computed(() => {
96
+ const parts = [];
97
+ if (activeRequestQueryParamsToken.value) {
98
+ parts.push("__request_query__", activeRequestQueryParamsToken.value);
99
+ }
100
+ if (activeRequestFieldsetsToken.value) {
101
+ parts.push("__request_fieldsets__", activeRequestFieldsetsToken.value);
102
+ }
103
+ return Object.freeze(parts);
104
+ });
68
105
  const queryKey = computed(() => {
69
- if (!activeRequestQueryParamsToken.value) {
106
+ if (queryKeyParts.value.length < 1) {
70
107
  return unref(sourceQueryKey);
71
108
  }
72
109
 
73
- const next = resolveRequestQueryBaseKey(sourceQueryKey);
74
- next.push("__request_query__", activeRequestQueryParamsToken.value);
75
- return next;
110
+ return [
111
+ ...resolveRequestQueryBaseKey(sourceQueryKey),
112
+ ...queryKeyParts.value
113
+ ];
76
114
  });
77
115
  const requestQuery = computed(() => {
78
- if (activeRequestQueryParamEntries.value.length < 1) {
116
+ const query = buildRequestQueryObject(activeRequestQueryParamEntries.value, {
117
+ fieldsets: activeRequestFieldsets.value
118
+ });
119
+ if (Object.keys(query).length < 1) {
79
120
  return null;
80
121
  }
81
122
 
82
- return buildRequestQueryObject(activeRequestQueryParamEntries.value);
123
+ return query;
83
124
  });
84
125
 
85
126
  return Object.freeze({
86
127
  requestQueryParamDescriptors,
87
128
  activeRequestQueryParamEntries,
88
129
  activeRequestQueryParamsToken,
130
+ activeRequestFieldsets,
131
+ activeRequestFieldsetsToken,
132
+ queryKeyParts,
89
133
  queryKey,
90
134
  requestQuery
91
135
  });
92
136
  }
93
137
 
94
138
  export {
95
- appendRequestQueryValue,
96
139
  buildRequestQueryObject,
97
- createRequestQueryRuntime,
98
- resolveRequestQueryBaseKey,
99
- resolveRequestQueryContext
140
+ createRequestQueryRuntime
100
141
  };
@@ -2,6 +2,7 @@ import { computed, reactive } from "vue";
2
2
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import {
4
4
  defineCrudListFilters,
5
+ createCrudListFilterEmptyValue,
5
6
  createCrudListFilterInitialValue,
6
7
  isCrudListFilterMultiValue,
7
8
  isCrudListFilterStructuredValue,
@@ -10,7 +11,9 @@ import {
10
11
  listCrudListFilterChipValues,
11
12
  formatCrudListFilterDefaultChipLabel,
12
13
  formatCrudListFilterQueryValue,
14
+ parseCrudListFilterQueryValue,
13
15
  resolveCrudListFilterOptionLabel,
16
+ INVALID_CRUD_LIST_FILTER_QUERY_VALUE,
14
17
  CRUD_LIST_FILTER_TYPE_FLAG
15
18
  } from "@jskit-ai/kernel/shared/support/crudListFilters";
16
19
  import { formatCrudListDateFilterChipLabel } from "../support/crudListDateFilterSupport.js";
@@ -107,7 +110,7 @@ function assignFilterValue(values, filter = {}, rawValue) {
107
110
  }
108
111
 
109
112
  function resetFilterValue(values, filter = {}) {
110
- assignFilterValue(values, filter, createCrudListFilterInitialValue(filter));
113
+ assignFilterValue(values, filter, createCrudListFilterEmptyValue(filter));
111
114
  }
112
115
 
113
116
  function applyPresetFilterValue(values, filter = {}, rawValue) {
@@ -134,6 +137,28 @@ function createQueryParams(values, filterEntries = []) {
134
137
  return Object.freeze(queryParams);
135
138
  }
136
139
 
140
+ function resolveFilterRouteValue(filter, routeValue, { initial = false } = {}) {
141
+ if (routeValue === undefined) {
142
+ return initial
143
+ ? createCrudListFilterInitialValue(filter)
144
+ : createCrudListFilterEmptyValue(filter);
145
+ }
146
+
147
+ const parsedValue = parseCrudListFilterQueryValue(filter, routeValue);
148
+ return parsedValue === INVALID_CRUD_LIST_FILTER_QUERY_VALUE
149
+ ? createCrudListFilterInitialValue(filter)
150
+ : parsedValue;
151
+ }
152
+
153
+ function createRouteQueryValueResolvers(filterEntries = []) {
154
+ return Object.freeze(Object.fromEntries(
155
+ filterEntries.map((filter) => [
156
+ filter.queryKey,
157
+ (routeValue, context) => resolveFilterRouteValue(filter, routeValue, context)
158
+ ])
159
+ ));
160
+ }
161
+
137
162
  function resolveAtomicValueLabel(filter = {}, value = "", labelResolvers = {}) {
138
163
  const customResolver = labelResolvers[filter.key];
139
164
  if (typeof customResolver === "function") {
@@ -174,6 +199,7 @@ function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels
174
199
  }
175
200
 
176
201
  const queryParams = createQueryParams(values, filterEntries);
202
+ const routeQueryValueResolvers = createRouteQueryValueResolvers(filterEntries);
177
203
 
178
204
  const activeChips = computed(() => {
179
205
  const chips = [];
@@ -296,6 +322,7 @@ function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels
296
322
  filters,
297
323
  values,
298
324
  queryParams,
325
+ routeQueryValueResolvers,
299
326
  options: Object.freeze(options),
300
327
  presets: normalizedPresets,
301
328
  activeChips,
@@ -118,6 +118,7 @@ function useCrudListScreen({
118
118
  syntheticRows = null,
119
119
  routeQueryBlacklist = Object.freeze(["include", "cursor", "limit"]),
120
120
  requestQueryParams = null,
121
+ requestFieldsets = null,
121
122
  readEnabled = true,
122
123
  requestRecoveryLabel = "Records",
123
124
  fallbackLoadError = "Unable to load records."
@@ -143,6 +144,7 @@ function useCrudListScreen({
143
144
  mode: "query"
144
145
  },
145
146
  queryParams: filterRuntime.queryParams,
147
+ routeQueryValueResolvers: filterRuntime.routeQueryValueResolvers,
146
148
  syncToRoute: {
147
149
  enabled: true,
148
150
  mode: "replace",
@@ -152,6 +154,7 @@ function useCrudListScreen({
152
154
  },
153
155
  placementSource: `ui-generator.${normalizedResourceNamespace}.list`,
154
156
  requestQueryParams,
157
+ requestFieldsets,
155
158
  readEnabled,
156
159
  requestRecoveryLabel,
157
160
  fallbackLoadError,
@@ -13,6 +13,7 @@ function useCrudViewScreen({
13
13
  editUrlTemplate = "",
14
14
  recordChangedEvent = "",
15
15
  requestQueryParams = null,
16
+ requestFieldsets = null,
16
17
  readEnabled = true,
17
18
  queryKeyFactory = null,
18
19
  requestRecoveryLabel = "Record",
@@ -36,6 +37,7 @@ function useCrudViewScreen({
36
37
  includeRecordIdInQueryKey: true,
37
38
  queryKeyFactory: typeof queryKeyFactory === "function" ? queryKeyFactory : defaultQueryKeyFactory,
38
39
  requestQueryParams,
40
+ requestFieldsets,
39
41
  readEnabled,
40
42
  placementSource: `ui-generator.${normalizedResourceNamespace}.view`,
41
43
  requestRecoveryLabel,
@@ -151,6 +151,39 @@ test("parseRouteBindingValue handles boolean, number and array bindings", () =>
151
151
  );
152
152
  });
153
153
 
154
+ test("route-aware query parameter bindings can distinguish initial hydration from later clearing", () => {
155
+ const currentness = ref("active");
156
+ const [binding] = resolveWritableQueryParamBindings(
157
+ resolveQueryParamDescriptors(
158
+ { currentness },
159
+ {},
160
+ {
161
+ routeValueResolvers: {
162
+ currentness(routeValue, { initial }) {
163
+ if (routeValue === undefined) {
164
+ return initial ? "active" : "";
165
+ }
166
+ return String(routeValue || "").trim() || "active";
167
+ }
168
+ }
169
+ }
170
+ )
171
+ );
172
+
173
+ assert.equal(
174
+ parseRouteBindingValue(binding, undefined, { initial: true }),
175
+ "active"
176
+ );
177
+ assert.equal(
178
+ parseRouteBindingValue(binding, "archived", { initial: true }),
179
+ "archived"
180
+ );
181
+ assert.equal(
182
+ parseRouteBindingValue(binding, undefined, { initial: false }),
183
+ ""
184
+ );
185
+ });
186
+
154
187
  test("route sync key helpers preserve declared key history for cleanup", () => {
155
188
  const history = mergeManagedQueryParamKeyHistory(
156
189
  ["status"],
@@ -160,3 +160,31 @@ test("request query runtime preserves inactive scalar query keys", () => {
160
160
  assert.equal(runtime.queryKey.value, "products");
161
161
  assert.equal(runtime.requestQuery.value, null);
162
162
  });
163
+
164
+ test("request query runtime carries typed fieldsets as a first-class query and cache-key dimension", () => {
165
+ const runtime = createRequestQueryRuntime({
166
+ requestQueryParams: {
167
+ include: "operator"
168
+ },
169
+ requestFieldsets: {
170
+ jobs: ["status", "id"],
171
+ contacts: ["displayName", "id"]
172
+ },
173
+ sourceQueryKey: ref(["jobs"])
174
+ });
175
+
176
+ assert.deepEqual(runtime.requestQuery.value, {
177
+ include: "operator",
178
+ fields: {
179
+ contacts: ["displayName", "id"],
180
+ jobs: ["id", "status"]
181
+ }
182
+ });
183
+ assert.deepEqual(runtime.queryKey.value, [
184
+ "jobs",
185
+ "__request_query__",
186
+ "include=operator",
187
+ "__request_fieldsets__",
188
+ '{"contacts":["displayName","id"],"jobs":["id","status"]}'
189
+ ]);
190
+ });
@@ -257,3 +257,37 @@ test("useCrudListFilters preserves scalar, complete, partial, and cleared date c
257
257
  assert.deepEqual(filters.values.arrivalDate, { from: "", to: "" });
258
258
  assert.equal(filters.hasActiveFilters.value, false);
259
259
  });
260
+
261
+ test("useCrudListFilters applies declarative defaults once while clear remains an explicit empty state", async () => {
262
+ const { useCrudListFilters } = await import("@jskit-ai/users-web/client/composables/useCrudListFilters");
263
+
264
+ const filters = useCrudListFilters({
265
+ currentness: {
266
+ type: "enum",
267
+ label: "Currentness",
268
+ options: [
269
+ { value: "active", label: "Active" },
270
+ { value: "archived", label: "Archived" }
271
+ ],
272
+ defaultValue: "active"
273
+ },
274
+ onlyStaff: {
275
+ type: "flag",
276
+ label: "Staff",
277
+ defaultValue: true
278
+ }
279
+ });
280
+
281
+ assert.equal(filters.values.currentness, "active");
282
+ assert.equal(filters.values.onlyStaff, true);
283
+ assert.equal(filters.queryParams.currentness.value, "active");
284
+ assert.equal(filters.queryParams.onlyStaff.value, true);
285
+
286
+ filters.clearFilter("currentness");
287
+ filters.clearFilter("onlyStaff");
288
+
289
+ assert.equal(filters.values.currentness, "");
290
+ assert.equal(filters.values.onlyStaff, false);
291
+ assert.equal(filters.queryParams.currentness.value, "");
292
+ assert.equal(filters.queryParams.onlyStaff.value, false);
293
+ });
@@ -0,0 +1,337 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
4
+ import {
5
+ computed,
6
+ createRenderer,
7
+ h,
8
+ unref
9
+ } from "vue";
10
+ import {
11
+ createMemoryHistory,
12
+ createRouter,
13
+ useRoute
14
+ } from "vue-router";
15
+
16
+ import { useList } from "../src/client/composables/records/useList.js";
17
+ import { useCrudListFilters } from "../src/client/composables/useCrudListFilters.js";
18
+
19
+ const CURRENTNESS_FILTERS = Object.freeze({
20
+ currentness: Object.freeze({
21
+ type: "enum",
22
+ defaultValue: "active",
23
+ options: Object.freeze([
24
+ Object.freeze({
25
+ label: "Active",
26
+ value: "active"
27
+ }),
28
+ Object.freeze({
29
+ label: "Archived",
30
+ value: "archived"
31
+ })
32
+ ])
33
+ })
34
+ });
35
+
36
+ async function waitFor(check, message = "Expected condition was not met.") {
37
+ for (let attempt = 0; attempt < 100; attempt += 1) {
38
+ if (check()) {
39
+ return;
40
+ }
41
+ await new Promise((resolve) => setTimeout(resolve, 0));
42
+ }
43
+
44
+ assert.fail(message);
45
+ }
46
+
47
+ function createTestRenderer() {
48
+ function insert(child, parent, anchor = null) {
49
+ const siblings = parent.children || (parent.children = []);
50
+ const index = anchor ? siblings.indexOf(anchor) : -1;
51
+ if (index < 0) {
52
+ siblings.push(child);
53
+ } else {
54
+ siblings.splice(index, 0, child);
55
+ }
56
+ child.parent = parent;
57
+ }
58
+
59
+ function remove(child) {
60
+ const parent = child?.parent;
61
+ const index = parent?.children?.indexOf(child) ?? -1;
62
+ if (index >= 0) {
63
+ parent.children.splice(index, 1);
64
+ }
65
+ child.parent = null;
66
+ }
67
+
68
+ return createRenderer({
69
+ patchProp(element, key, _previousValue, nextValue) {
70
+ element.properties[key] = nextValue;
71
+ },
72
+ insert,
73
+ remove,
74
+ createElement(type) {
75
+ return {
76
+ type,
77
+ properties: {},
78
+ children: [],
79
+ parent: null
80
+ };
81
+ },
82
+ createText(text) {
83
+ return {
84
+ type: "text",
85
+ text,
86
+ parent: null
87
+ };
88
+ },
89
+ createComment(text) {
90
+ return {
91
+ type: "comment",
92
+ text,
93
+ parent: null
94
+ };
95
+ },
96
+ setText(node, text) {
97
+ node.text = text;
98
+ },
99
+ setElementText(element, text) {
100
+ element.children = [{
101
+ type: "text",
102
+ text,
103
+ parent: element
104
+ }];
105
+ },
106
+ parentNode(node) {
107
+ return node?.parent || null;
108
+ },
109
+ nextSibling(node) {
110
+ const parent = node?.parent;
111
+ const index = parent?.children?.indexOf(node) ?? -1;
112
+ return index >= 0 ? parent.children[index + 1] || null : null;
113
+ },
114
+ querySelector() {
115
+ return null;
116
+ },
117
+ setScopeId() {},
118
+ cloneNode(node) {
119
+ return {
120
+ ...node,
121
+ properties: {
122
+ ...node.properties
123
+ },
124
+ children: [...(node.children || [])],
125
+ parent: null
126
+ };
127
+ },
128
+ insertStaticContent(content, parent, anchor) {
129
+ const node = {
130
+ type: "static",
131
+ text: content,
132
+ parent: null
133
+ };
134
+ insert(node, parent, anchor);
135
+ return [node, node];
136
+ }
137
+ });
138
+ }
139
+
140
+ function createListTestAdapter() {
141
+ return Object.freeze({
142
+ useOperationScope() {
143
+ const route = useRoute();
144
+
145
+ return Object.freeze({
146
+ routeContext: Object.freeze({
147
+ route,
148
+ currentSurfaceId: computed(() => "admin")
149
+ }),
150
+ scopeParamValue: computed(() => ""),
151
+ normalizedOwnershipFilter: "none",
152
+ apiPath: computed(() => "/contacts"),
153
+ queryKey: computed(() => ["contacts"]),
154
+ queryCanRun(gate = true) {
155
+ return computed(() => Boolean(unref(gate)));
156
+ },
157
+ permissionGate() {
158
+ return computed(() => true);
159
+ },
160
+ loadError(error = "") {
161
+ return computed(() => String(unref(error) || ""));
162
+ },
163
+ isLoading(loading = false) {
164
+ return computed(() => Boolean(unref(loading)));
165
+ }
166
+ });
167
+ }
168
+ });
169
+ }
170
+
171
+ async function mountRouteSynchronizedList(initialLocation) {
172
+ const calls = [];
173
+ const client = {
174
+ async request(path, options) {
175
+ calls.push({
176
+ path,
177
+ options
178
+ });
179
+ return {
180
+ items: [],
181
+ nextCursor: null
182
+ };
183
+ }
184
+ };
185
+ const queryClient = new QueryClient({
186
+ defaultOptions: {
187
+ queries: {
188
+ retry: false
189
+ }
190
+ }
191
+ });
192
+ const router = createRouter({
193
+ history: createMemoryHistory(),
194
+ routes: [{
195
+ path: "/contacts",
196
+ component: {
197
+ render() {
198
+ return h("div");
199
+ }
200
+ }
201
+ }]
202
+ });
203
+ const renderer = createTestRenderer();
204
+ let filterRuntime = null;
205
+ const app = renderer.createApp({
206
+ setup() {
207
+ filterRuntime = useCrudListFilters(CURRENTNESS_FILTERS);
208
+ useList({
209
+ adapter: createListTestAdapter(),
210
+ client,
211
+ queryKeyFactory: () => ["contacts"],
212
+ queryParams: filterRuntime.queryParams,
213
+ routeQueryValueResolvers: filterRuntime.routeQueryValueResolvers,
214
+ syncToRoute: {
215
+ enabled: true,
216
+ search: false,
217
+ queryParams: true
218
+ }
219
+ });
220
+
221
+ return () => h("div");
222
+ }
223
+ });
224
+ app.use(router);
225
+ app.use(VueQueryPlugin, {
226
+ queryClient
227
+ });
228
+
229
+ await router.push(initialLocation);
230
+ await router.isReady();
231
+ app.mount({
232
+ type: "root",
233
+ children: []
234
+ });
235
+ await waitFor(
236
+ () => calls.length > 0,
237
+ `Expected a list request for ${JSON.stringify(initialLocation)}.`
238
+ );
239
+ await new Promise((resolve) => setImmediate(resolve));
240
+
241
+ return {
242
+ app,
243
+ calls,
244
+ filterRuntime,
245
+ queryClient,
246
+ router
247
+ };
248
+ }
249
+
250
+ test("route-synchronized lists make one initial request with the hydrated route filter", async () => {
251
+ const runtime = await mountRouteSynchronizedList({
252
+ path: "/contacts",
253
+ query: {
254
+ currentness: "archived"
255
+ }
256
+ });
257
+
258
+ assert.equal(runtime.calls.length, 1);
259
+ assert.deepEqual(runtime.calls[0], {
260
+ path: "/contacts",
261
+ options: {
262
+ method: "GET",
263
+ query: {
264
+ currentness: "archived",
265
+ limit: 20
266
+ }
267
+ }
268
+ });
269
+ runtime.app.unmount();
270
+ runtime.queryClient.clear();
271
+ });
272
+
273
+ test("route-synchronized lists make one initial request with a declarative default", async () => {
274
+ const runtime = await mountRouteSynchronizedList("/contacts");
275
+
276
+ assert.equal(runtime.calls.length, 1);
277
+ assert.equal(runtime.calls[0].options.query.currentness, "active");
278
+ runtime.app.unmount();
279
+ runtime.queryClient.clear();
280
+ });
281
+
282
+ test("invalid initial route filters fall back before the first list request", async () => {
283
+ const runtime = await mountRouteSynchronizedList({
284
+ path: "/contacts",
285
+ query: {
286
+ currentness: "not-a-valid-option"
287
+ }
288
+ });
289
+
290
+ assert.equal(runtime.calls.length, 1);
291
+ assert.equal(runtime.calls[0].options.query.currentness, "active");
292
+ runtime.app.unmount();
293
+ runtime.queryClient.clear();
294
+ });
295
+
296
+ test("route navigation rehydrates filters and a later clear stays cleared", async () => {
297
+ const runtime = await mountRouteSynchronizedList({
298
+ path: "/contacts",
299
+ query: {
300
+ currentness: "archived"
301
+ }
302
+ });
303
+
304
+ await runtime.router.push({
305
+ path: "/contacts",
306
+ query: {
307
+ currentness: "active"
308
+ }
309
+ });
310
+ await waitFor(() => runtime.filterRuntime.values.currentness === "active");
311
+ await waitFor(() => runtime.calls.length === 2);
312
+
313
+ runtime.router.back();
314
+ await waitFor(() => runtime.router.currentRoute.value.query.currentness === "archived");
315
+ await waitFor(() => runtime.filterRuntime.values.currentness === "archived");
316
+ await waitFor(() => runtime.calls.length === 3);
317
+
318
+ runtime.router.forward();
319
+ await waitFor(() => runtime.router.currentRoute.value.query.currentness === "active");
320
+ await waitFor(() => runtime.filterRuntime.values.currentness === "active");
321
+ await waitFor(() => runtime.calls.length === 4);
322
+
323
+ runtime.filterRuntime.clearFilter("currentness");
324
+ await waitFor(() => !Object.hasOwn(runtime.router.currentRoute.value.query, "currentness"));
325
+ await waitFor(
326
+ () => runtime.calls.length >= 5,
327
+ `Expected one request after clearing the filter; received ${runtime.calls.length}.`
328
+ );
329
+
330
+ assert.equal(runtime.calls.length, 5, JSON.stringify(runtime.calls, null, 2));
331
+ assert.equal(runtime.filterRuntime.values.currentness, "");
332
+ assert.deepEqual(runtime.calls.at(-1).options.query, {
333
+ limit: 20
334
+ });
335
+ runtime.app.unmount();
336
+ runtime.queryClient.clear();
337
+ });