@jskit-ai/users-web 0.1.59 → 0.1.60

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_TOOLS_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.59",
6
+ version: "0.1.60",
7
7
  kind: "runtime",
8
8
  description: "Users web module: account/profile UI plus shared users web widgets.",
9
9
  dependsOn: [
@@ -66,6 +66,10 @@ export default Object.freeze({
66
66
  subpath: "./client/composables/useCommand",
67
67
  summary: "Exports command operation composable."
68
68
  },
69
+ {
70
+ subpath: "./client/composables/useCrudListFilterLookups",
71
+ summary: "Exports lookup-backed CRUD list filter helper for remote autocomplete filters."
72
+ },
69
73
  {
70
74
  subpath: "./client/composables/useView",
71
75
  summary: "Exports read/view operation composable."
@@ -142,12 +146,12 @@ export default Object.freeze({
142
146
  runtime: {
143
147
  "@tanstack/vue-query": "5.92.12",
144
148
  "@mdi/js": "^7.4.47",
145
- "@jskit-ai/http-runtime": "0.1.43",
146
- "@jskit-ai/realtime": "0.1.43",
147
- "@jskit-ai/kernel": "0.1.44",
148
- "@jskit-ai/shell-web": "0.1.43",
149
- "@jskit-ai/uploads-image-web": "0.1.22",
150
- "@jskit-ai/users-core": "0.1.54",
149
+ "@jskit-ai/http-runtime": "0.1.44",
150
+ "@jskit-ai/realtime": "0.1.44",
151
+ "@jskit-ai/kernel": "0.1.45",
152
+ "@jskit-ai/shell-web": "0.1.44",
153
+ "@jskit-ai/uploads-image-web": "0.1.23",
154
+ "@jskit-ai/users-core": "0.1.55",
151
155
  vuetify: "^4.0.0"
152
156
  },
153
157
  dev: {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.59",
3
+ "version": "0.1.60",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -13,6 +13,8 @@
13
13
  "./client/composables/useCommand": "./src/client/composables/useCommand.js",
14
14
  "./client/composables/useCrudAddEdit": "./src/client/composables/records/useCrudAddEdit.js",
15
15
  "./client/composables/crudLookupFieldRuntime": "./src/client/composables/crud/crudLookupFieldRuntime.js",
16
+ "./client/composables/useCrudListFilterLookups": "./src/client/composables/useCrudListFilterLookups.js",
17
+ "./client/composables/useCrudListFilters": "./src/client/composables/useCrudListFilters.js",
16
18
  "./client/composables/useList": "./src/client/composables/records/useList.js",
17
19
  "./client/composables/useCrudList": "./src/client/composables/records/useCrudList.js",
18
20
  "./client/composables/useCrudListParentTitle": "./src/client/composables/useCrudListParentTitle.js",
@@ -31,12 +33,12 @@
31
33
  "dependencies": {
32
34
  "@tanstack/vue-query": "5.92.12",
33
35
  "@mdi/js": "^7.4.47",
34
- "@jskit-ai/http-runtime": "0.1.43",
35
- "@jskit-ai/kernel": "0.1.44",
36
- "@jskit-ai/realtime": "0.1.43",
37
- "@jskit-ai/shell-web": "0.1.43",
38
- "@jskit-ai/uploads-image-web": "0.1.22",
39
- "@jskit-ai/users-core": "0.1.54",
36
+ "@jskit-ai/http-runtime": "0.1.44",
37
+ "@jskit-ai/kernel": "0.1.45",
38
+ "@jskit-ai/realtime": "0.1.44",
39
+ "@jskit-ai/shell-web": "0.1.44",
40
+ "@jskit-ai/uploads-image-web": "0.1.23",
41
+ "@jskit-ai/users-core": "0.1.55",
40
42
  "vuetify": "^4.0.0"
41
43
  }
42
44
  }
@@ -48,6 +48,10 @@ function resolveFormFieldFormat(field = {}) {
48
48
  return String(field.format || "").trim().toLowerCase();
49
49
  }
50
50
 
51
+ function isNullableFormField(field = {}) {
52
+ return field?.nullable === true;
53
+ }
54
+
51
55
  function padDateTimePart(value) {
52
56
  return String(value).padStart(2, "0");
53
57
  }
@@ -132,7 +136,7 @@ function resolveFormFieldInitialValue(field = {}) {
132
136
 
133
137
  const fieldType = resolveFormFieldType(field);
134
138
  if (fieldType === "boolean") {
135
- return false;
139
+ return isNullableFormField(field) ? null : false;
136
140
  }
137
141
 
138
142
  return "";
@@ -176,7 +180,9 @@ function buildCrudFormPayload(fields = [], model = {}) {
176
180
  const rawValue = sourceModel[fieldKey];
177
181
 
178
182
  if (fieldType === "boolean") {
179
- payload[fieldKey] = Boolean(rawValue);
183
+ payload[fieldKey] = rawValue == null && isNullableFormField(field)
184
+ ? null
185
+ : Boolean(rawValue);
180
186
  continue;
181
187
  }
182
188
 
@@ -258,7 +264,9 @@ function applyCrudPayloadToForm(fields = [], model = {}, payload = {}) {
258
264
  const rawValue = sourcePayload[fieldKey];
259
265
 
260
266
  if (fieldType === "boolean") {
261
- targetModel[fieldKey] = Boolean(rawValue);
267
+ targetModel[fieldKey] = rawValue == null && isNullableFormField(field)
268
+ ? null
269
+ : Boolean(rawValue);
262
270
  continue;
263
271
  }
264
272
 
@@ -0,0 +1,161 @@
1
+ import { normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { resolveLookupItemLabel } from "../crud/crudLookupFieldLabelSupport.js";
3
+ import { asPlainObject } from "../support/scopeHelpers.js";
4
+
5
+ function normalizeLookupQueryKeyPrefix(value = []) {
6
+ const source = Array.isArray(value) ? value : [];
7
+ return Object.freeze(
8
+ source
9
+ .map((entry) => normalizeText(entry))
10
+ .filter(Boolean)
11
+ );
12
+ }
13
+
14
+ function normalizeLookupLabelResolverMap(value = {}) {
15
+ const source = value && typeof value === "object" && !Array.isArray(value)
16
+ ? value
17
+ : {};
18
+ const normalized = {};
19
+
20
+ for (const [key, entry] of Object.entries(source)) {
21
+ const normalizedKey = normalizeText(key);
22
+ if (!normalizedKey || typeof entry !== "function") {
23
+ continue;
24
+ }
25
+
26
+ normalized[normalizedKey] = entry;
27
+ }
28
+
29
+ return Object.freeze(normalized);
30
+ }
31
+
32
+ function normalizeLookupRequestQueryParamsMap(value = {}) {
33
+ const source = value && typeof value === "object" && !Array.isArray(value)
34
+ ? value
35
+ : {};
36
+ const normalized = {};
37
+
38
+ for (const [key, entry] of Object.entries(source)) {
39
+ const normalizedKey = normalizeText(key);
40
+ if (!normalizedKey) {
41
+ continue;
42
+ }
43
+ if (entry == null) {
44
+ continue;
45
+ }
46
+ if (typeof entry === "function") {
47
+ normalized[normalizedKey] = entry;
48
+ continue;
49
+ }
50
+ if (typeof entry !== "object" || Array.isArray(entry)) {
51
+ continue;
52
+ }
53
+
54
+ normalized[normalizedKey] = asPlainObject(entry);
55
+ }
56
+
57
+ return Object.freeze(normalized);
58
+ }
59
+
60
+ function resolveLookupSelectedValues(filter = {}, rawValue = undefined) {
61
+ if (filter?.type === "recordIdMany") {
62
+ return normalizeUniqueTextList(rawValue, {
63
+ acceptSingle: true
64
+ });
65
+ }
66
+
67
+ const normalizedValue = normalizeText(rawValue);
68
+ return normalizedValue ? Object.freeze([normalizedValue]) : Object.freeze([]);
69
+ }
70
+
71
+ function createLookupOptionFromItem(item = {}, filter = {}, labelResolver = null) {
72
+ const sourceRecord = asPlainObject(item);
73
+ const valueKey = normalizeText(filter?.lookup?.valueKey) || "id";
74
+ const labelKey = normalizeText(filter?.lookup?.labelKey);
75
+ const value = normalizeText(sourceRecord[valueKey]);
76
+ if (!value) {
77
+ return null;
78
+ }
79
+
80
+ const customLabel = typeof labelResolver === "function"
81
+ ? normalizeText(labelResolver(sourceRecord, filter))
82
+ : "";
83
+ const fallbackLabel = resolveLookupItemLabel(sourceRecord, labelKey) || value;
84
+
85
+ return Object.freeze({
86
+ value,
87
+ label: customLabel || String(fallbackLabel || value),
88
+ record: sourceRecord
89
+ });
90
+ }
91
+
92
+ function createLookupOptionsFromItems(items = [], filter = {}, labelResolver = null) {
93
+ const optionMap = new Map();
94
+ for (const item of Array.isArray(items) ? items : []) {
95
+ const option = createLookupOptionFromItem(item, filter, labelResolver);
96
+ if (!option || optionMap.has(option.value)) {
97
+ continue;
98
+ }
99
+
100
+ optionMap.set(option.value, option);
101
+ }
102
+
103
+ return Object.freeze([...optionMap.values()]);
104
+ }
105
+
106
+ function mergeSelectedLookupOptions(options = [], selectedValues = [], cachedOptions = new Map()) {
107
+ const optionMap = new Map(
108
+ (Array.isArray(options) ? options : []).map((option) => [normalizeText(option?.value), option])
109
+ );
110
+ const normalizedCache = cachedOptions instanceof Map ? cachedOptions : new Map();
111
+
112
+ for (const value of Array.isArray(selectedValues) ? selectedValues : []) {
113
+ const normalizedValue = normalizeText(value);
114
+ if (!normalizedValue || optionMap.has(normalizedValue)) {
115
+ continue;
116
+ }
117
+
118
+ const cachedOption = normalizedCache.get(normalizedValue);
119
+ if (cachedOption) {
120
+ optionMap.set(normalizedValue, cachedOption);
121
+ }
122
+ }
123
+
124
+ return Object.freeze(
125
+ [...optionMap.values()].sort((left, right) => {
126
+ return String(left?.label || "").localeCompare(String(right?.label || ""));
127
+ })
128
+ );
129
+ }
130
+
131
+ function resolveLookupOptionLabel(options = [], cachedOptions = new Map(), value = "", fallback = "Option") {
132
+ const normalizedValue = normalizeText(value);
133
+ if (!normalizedValue) {
134
+ return normalizeText(fallback) || "Option";
135
+ }
136
+
137
+ const matchingOption = (Array.isArray(options) ? options : [])
138
+ .find((option) => normalizeText(option?.value) === normalizedValue);
139
+ if (matchingOption?.label) {
140
+ return String(matchingOption.label);
141
+ }
142
+
143
+ const normalizedCache = cachedOptions instanceof Map ? cachedOptions : new Map();
144
+ const cachedOption = normalizedCache.get(normalizedValue);
145
+ if (cachedOption?.label) {
146
+ return String(cachedOption.label);
147
+ }
148
+
149
+ const normalizedFallback = normalizeText(fallback) || "Option";
150
+ return `${normalizedFallback} ${normalizedValue}`;
151
+ }
152
+
153
+ export {
154
+ normalizeLookupQueryKeyPrefix,
155
+ normalizeLookupLabelResolverMap,
156
+ normalizeLookupRequestQueryParamsMap,
157
+ resolveLookupSelectedValues,
158
+ createLookupOptionsFromItems,
159
+ mergeSelectedLookupOptions,
160
+ resolveLookupOptionLabel
161
+ };
@@ -0,0 +1,141 @@
1
+ import { computed, proxyRefs, ref, watch } from "vue";
2
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import {
4
+ defineCrudListFilters,
5
+ CRUD_LIST_FILTER_TYPE_RECORD_ID,
6
+ CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY
7
+ } from "@jskit-ai/kernel/shared/support/crudListFilters";
8
+ import { useList } from "./records/useList.js";
9
+ import {
10
+ normalizeLookupQueryKeyPrefix,
11
+ normalizeLookupLabelResolverMap,
12
+ normalizeLookupRequestQueryParamsMap,
13
+ resolveLookupSelectedValues,
14
+ createLookupOptionsFromItems,
15
+ mergeSelectedLookupOptions,
16
+ resolveLookupOptionLabel
17
+ } from "./internal/crudListFilterLookupSupport.js";
18
+
19
+ function useCrudListFilterLookups(
20
+ definitions = {},
21
+ {
22
+ values = {},
23
+ adapter = null,
24
+ recordIdParam = "recordId",
25
+ queryKeyPrefix = [],
26
+ placementSourcePrefix = "",
27
+ requestQueryParams = {},
28
+ labelResolvers = {}
29
+ } = {}
30
+ ) {
31
+ const filters = defineCrudListFilters(definitions);
32
+ const filterEntries = Object.values(filters);
33
+ const normalizedQueryKeyPrefix = normalizeLookupQueryKeyPrefix(queryKeyPrefix);
34
+ const normalizedPlacementSourcePrefix = normalizeText(placementSourcePrefix);
35
+ const normalizedLabelResolvers = normalizeLookupLabelResolverMap(labelResolvers);
36
+ const normalizedRequestQueryParams = normalizeLookupRequestQueryParamsMap(requestQueryParams);
37
+ const lookups = {};
38
+
39
+ for (const filter of filterEntries) {
40
+ if (
41
+ filter.type !== CRUD_LIST_FILTER_TYPE_RECORD_ID &&
42
+ filter.type !== CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY
43
+ ) {
44
+ continue;
45
+ }
46
+ if (!filter.lookup?.apiSuffix) {
47
+ continue;
48
+ }
49
+
50
+ const runtime = useList({
51
+ adapter: adapter || undefined,
52
+ apiSuffix: filter.lookup.apiSuffix,
53
+ queryKeyFactory: (surfaceId = "", scopeParamValue = "") => [
54
+ ...normalizedQueryKeyPrefix,
55
+ filter.key,
56
+ String(surfaceId || ""),
57
+ String(scopeParamValue || "")
58
+ ],
59
+ search: {
60
+ enabled: true,
61
+ mode: "query"
62
+ },
63
+ ...(Object.hasOwn(normalizedRequestQueryParams, filter.key)
64
+ ? { requestQueryParams: normalizedRequestQueryParams[filter.key] }
65
+ : {}),
66
+ placementSource: normalizedPlacementSourcePrefix
67
+ ? `${normalizedPlacementSourcePrefix}.${filter.key}`
68
+ : `crud.list-filter.lookup.${filter.key}`,
69
+ fallbackLoadError: `Unable to load filter options (${filter.lookup.apiSuffix}).`,
70
+ recordIdParam,
71
+ recordIdSelector: (item = {}) => item[filter.lookup.valueKey || "id"],
72
+ viewUrlTemplate: "",
73
+ editUrlTemplate: ""
74
+ });
75
+
76
+ const cachedOptions = ref(new Map());
77
+ const selectedValues = computed(() => resolveLookupSelectedValues(filter, values?.[filter.key]));
78
+ const currentOptions = computed(() => createLookupOptionsFromItems(
79
+ Array.isArray(runtime.items) ? runtime.items : [],
80
+ filter,
81
+ normalizedLabelResolvers[filter.key]
82
+ ));
83
+ const options = computed(() => {
84
+ return mergeSelectedLookupOptions(
85
+ currentOptions.value,
86
+ selectedValues.value,
87
+ cachedOptions.value
88
+ );
89
+ });
90
+
91
+ watch(currentOptions, (nextOptions) => {
92
+ const nextCache = new Map(cachedOptions.value);
93
+ for (const option of nextOptions) {
94
+ nextCache.set(option.value, option);
95
+ }
96
+ cachedOptions.value = nextCache;
97
+ }, { immediate: true });
98
+
99
+ lookups[filter.key] = proxyRefs({
100
+ filter,
101
+ options,
102
+ searchQuery: computed(() => String(runtime.searchQuery || "")),
103
+ isLoading: computed(() => {
104
+ return Boolean(runtime.isInitialLoading || runtime.isFetching || runtime.isRefetching || runtime.isSearchDebouncing);
105
+ }),
106
+ resolveLabel(value, fallback = filter.label || "Option") {
107
+ return resolveLookupOptionLabel(options.value, cachedOptions.value, value, fallback);
108
+ },
109
+ setSearch(value = "") {
110
+ runtime.searchQuery = normalizeText(value);
111
+ }
112
+ });
113
+ }
114
+
115
+ function resolveLookup(filterKey = "") {
116
+ return lookups[normalizeText(filterKey)] || null;
117
+ }
118
+
119
+ return Object.freeze({
120
+ lookups: Object.freeze(lookups),
121
+ resolveLookup,
122
+ resolveLookupItems(filterKey = "") {
123
+ return resolveLookup(filterKey)?.options || [];
124
+ },
125
+ resolveLookupLoading(filterKey = "") {
126
+ return Boolean(resolveLookup(filterKey)?.isLoading);
127
+ },
128
+ resolveLookupSearch(filterKey = "") {
129
+ return String(resolveLookup(filterKey)?.searchQuery || "");
130
+ },
131
+ setLookupSearch(filterKey = "", value = "") {
132
+ resolveLookup(filterKey)?.setSearch(value);
133
+ },
134
+ resolveLookupLabel(filterKey = "", value = "", fallback = "Option") {
135
+ return resolveLookup(filterKey)?.resolveLabel(value, fallback)
136
+ || resolveLookupOptionLabel([], new Map(), value, fallback);
137
+ }
138
+ });
139
+ }
140
+
141
+ export { useCrudListFilterLookups };
@@ -0,0 +1,503 @@
1
+ import { computed, reactive, toRef } from "vue";
2
+ import { normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import {
4
+ defineCrudListFilters,
5
+ resolveCrudListFilterOptionLabel,
6
+ CRUD_LIST_FILTER_TYPE_FLAG,
7
+ CRUD_LIST_FILTER_TYPE_ENUM,
8
+ CRUD_LIST_FILTER_TYPE_ENUM_MANY,
9
+ CRUD_LIST_FILTER_TYPE_RECORD_ID,
10
+ CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY,
11
+ CRUD_LIST_FILTER_TYPE_DATE,
12
+ CRUD_LIST_FILTER_TYPE_DATE_RANGE,
13
+ CRUD_LIST_FILTER_TYPE_NUMBER_RANGE,
14
+ CRUD_LIST_FILTER_TYPE_PRESENCE
15
+ } from "@jskit-ai/kernel/shared/support/crudListFilters";
16
+
17
+ function normalizeFunctionMap(value = {}) {
18
+ const source = value && typeof value === "object" && !Array.isArray(value)
19
+ ? value
20
+ : {};
21
+ const normalized = {};
22
+
23
+ for (const [key, entry] of Object.entries(source)) {
24
+ const normalizedKey = normalizeText(key);
25
+ if (!normalizedKey || typeof entry !== "function") {
26
+ continue;
27
+ }
28
+
29
+ normalized[normalizedKey] = entry;
30
+ }
31
+
32
+ return Object.freeze(normalized);
33
+ }
34
+
35
+ function createInitialFilterValue(filter = {}) {
36
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
37
+ return false;
38
+ }
39
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
40
+ return [];
41
+ }
42
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
43
+ return reactive({
44
+ from: "",
45
+ to: ""
46
+ });
47
+ }
48
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
49
+ return reactive({
50
+ min: "",
51
+ max: ""
52
+ });
53
+ }
54
+
55
+ return "";
56
+ }
57
+
58
+ function normalizePresetEntries(presets = []) {
59
+ const source = Array.isArray(presets) ? presets : [];
60
+ const normalized = [];
61
+ const seenKeys = new Set();
62
+
63
+ for (const rawPreset of source) {
64
+ if (!rawPreset || typeof rawPreset !== "object" || Array.isArray(rawPreset)) {
65
+ continue;
66
+ }
67
+
68
+ const key = normalizeText(rawPreset.key);
69
+ const label = normalizeText(rawPreset.label);
70
+ if (!key || !label || seenKeys.has(key)) {
71
+ continue;
72
+ }
73
+ seenKeys.add(key);
74
+
75
+ const values = rawPreset.values && typeof rawPreset.values === "object" && !Array.isArray(rawPreset.values)
76
+ ? rawPreset.values
77
+ : {};
78
+ const resolveValues = typeof rawPreset.resolveValues === "function"
79
+ ? rawPreset.resolveValues
80
+ : null;
81
+
82
+ normalized.push(Object.freeze({
83
+ key,
84
+ label,
85
+ values,
86
+ resolveValues
87
+ }));
88
+ }
89
+
90
+ return Object.freeze(normalized);
91
+ }
92
+
93
+ function resolvePresetValues(preset = {}, { values = {}, filters = {} } = {}) {
94
+ const rawValues = typeof preset.resolveValues === "function"
95
+ ? preset.resolveValues({
96
+ values,
97
+ filters,
98
+ presetKey: preset.key,
99
+ preset
100
+ })
101
+ : preset.values;
102
+
103
+ return rawValues && typeof rawValues === "object" && !Array.isArray(rawValues)
104
+ ? rawValues
105
+ : {};
106
+ }
107
+
108
+ function normalizePresetFilterValue(filter = {}, rawValue) {
109
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
110
+ return rawValue === true;
111
+ }
112
+
113
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
114
+ const allowedValues = filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY
115
+ ? new Set((filter.options || []).map((entry) => entry.value))
116
+ : null;
117
+ const normalizedList = normalizeUniqueTextList(rawValue, {
118
+ acceptSingle: true
119
+ });
120
+ if (!allowedValues) {
121
+ return normalizedList;
122
+ }
123
+
124
+ return normalizedList.filter((entry) => allowedValues.has(entry));
125
+ }
126
+
127
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
128
+ const source = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)
129
+ ? rawValue
130
+ : {};
131
+ return {
132
+ from: normalizeText(source.from),
133
+ to: normalizeText(source.to)
134
+ };
135
+ }
136
+
137
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
138
+ const source = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)
139
+ ? rawValue
140
+ : {};
141
+ return {
142
+ min: normalizeText(source.min),
143
+ max: normalizeText(source.max)
144
+ };
145
+ }
146
+
147
+ const normalized = normalizeText(rawValue);
148
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM || filter.type === CRUD_LIST_FILTER_TYPE_PRESENCE) {
149
+ const allowedValues = new Set((filter.options || []).map((entry) => entry.value));
150
+ return allowedValues.has(normalized) ? normalized : "";
151
+ }
152
+
153
+ return normalized;
154
+ }
155
+
156
+ function normalizeCurrentManyFilterValues(value) {
157
+ const source = Array.isArray(value) ? value : [value];
158
+ return source
159
+ .map((entry) => normalizeText(entry))
160
+ .filter(Boolean);
161
+ }
162
+
163
+ function matchArrayValues(currentValue = [], expectedValue = []) {
164
+ const currentList = Array.isArray(currentValue) ? [...currentValue].sort() : [];
165
+ const expectedList = Array.isArray(expectedValue) ? [...expectedValue].sort() : [];
166
+ if (currentList.length !== expectedList.length) {
167
+ return false;
168
+ }
169
+
170
+ return currentList.every((entry, index) => entry === expectedList[index]);
171
+ }
172
+
173
+ function matchesPresetFilterValue(filter = {}, currentValue, rawExpectedValue) {
174
+ const expectedValue = normalizePresetFilterValue(filter, rawExpectedValue);
175
+
176
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
177
+ return matchArrayValues(normalizeCurrentManyFilterValues(currentValue), expectedValue);
178
+ }
179
+
180
+ const normalizedCurrentValue = normalizePresetFilterValue(filter, currentValue);
181
+
182
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
183
+ return (
184
+ normalizeText(normalizedCurrentValue?.from) === normalizeText(expectedValue?.from) &&
185
+ normalizeText(normalizedCurrentValue?.to) === normalizeText(expectedValue?.to)
186
+ );
187
+ }
188
+
189
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
190
+ return (
191
+ normalizeText(normalizedCurrentValue?.min) === normalizeText(expectedValue?.min) &&
192
+ normalizeText(normalizedCurrentValue?.max) === normalizeText(expectedValue?.max)
193
+ );
194
+ }
195
+
196
+ return normalizedCurrentValue === expectedValue;
197
+ }
198
+
199
+ function resetFilterValue(values, filter = {}) {
200
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
201
+ values[filter.key] = false;
202
+ return;
203
+ }
204
+
205
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
206
+ values[filter.key] = [];
207
+ return;
208
+ }
209
+
210
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
211
+ values[filter.key].from = "";
212
+ values[filter.key].to = "";
213
+ return;
214
+ }
215
+
216
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
217
+ values[filter.key].min = "";
218
+ values[filter.key].max = "";
219
+ return;
220
+ }
221
+
222
+ values[filter.key] = "";
223
+ }
224
+
225
+ function applyPresetFilterValue(values, filter = {}, rawValue) {
226
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE || filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
227
+ const nextValue = normalizePresetFilterValue(filter, rawValue);
228
+ Object.assign(values[filter.key], nextValue);
229
+ return;
230
+ }
231
+
232
+ values[filter.key] = normalizePresetFilterValue(filter, rawValue);
233
+ }
234
+
235
+ function createQueryParams(values, filterEntries = []) {
236
+ const queryParams = {};
237
+
238
+ for (const filter of filterEntries) {
239
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
240
+ queryParams[filter.fromKey] = toRef(values[filter.key], "from");
241
+ queryParams[filter.toKey] = toRef(values[filter.key], "to");
242
+ continue;
243
+ }
244
+
245
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
246
+ queryParams[filter.minKey] = toRef(values[filter.key], "min");
247
+ queryParams[filter.maxKey] = toRef(values[filter.key], "max");
248
+ continue;
249
+ }
250
+
251
+ queryParams[filter.queryKey] = toRef(values, filter.key);
252
+ }
253
+
254
+ return Object.freeze(queryParams);
255
+ }
256
+
257
+ function resolveAtomicValueLabel(filter = {}, value = "", labelResolvers = {}) {
258
+ const customResolver = labelResolvers[filter.key];
259
+ if (typeof customResolver === "function") {
260
+ const customLabel = normalizeText(customResolver(value, filter));
261
+ if (customLabel) {
262
+ return customLabel;
263
+ }
264
+ }
265
+
266
+ return resolveCrudListFilterOptionLabel(filter, value, {
267
+ fallback: String(value || "")
268
+ });
269
+ }
270
+
271
+ function defaultChipLabel(filter = {}, value, labelResolvers = {}) {
272
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
273
+ return filter.label;
274
+ }
275
+
276
+ if (
277
+ filter.type === CRUD_LIST_FILTER_TYPE_ENUM ||
278
+ filter.type === CRUD_LIST_FILTER_TYPE_PRESENCE ||
279
+ filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID
280
+ ) {
281
+ return `${filter.label}: ${resolveAtomicValueLabel(filter, value, labelResolvers)}`;
282
+ }
283
+
284
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE) {
285
+ return `${filter.label}: ${value}`;
286
+ }
287
+
288
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
289
+ if (value?.from && value?.to) {
290
+ return `${filter.label}: ${value.from} to ${value.to}`;
291
+ }
292
+ if (value?.from) {
293
+ return `${filter.label}: from ${value.from}`;
294
+ }
295
+ return `${filter.label}: to ${value?.to || ""}`;
296
+ }
297
+
298
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
299
+ if (value?.min && value?.max) {
300
+ return `${filter.label}: ${value.min} to ${value.max}`;
301
+ }
302
+ if (value?.min) {
303
+ return `${filter.label}: min ${value.min}`;
304
+ }
305
+ return `${filter.label}: max ${value?.max || ""}`;
306
+ }
307
+
308
+ return filter.label;
309
+ }
310
+
311
+ function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels = {}, presets = [] } = {}) {
312
+ const filters = defineCrudListFilters(definitions);
313
+ const filterEntries = Object.values(filters);
314
+ const normalizedLabelResolvers = normalizeFunctionMap(labelResolvers);
315
+ const normalizedChipLabels = normalizeFunctionMap(chipLabels);
316
+ const normalizedPresets = normalizePresetEntries(presets);
317
+ const values = reactive({});
318
+ const options = {};
319
+
320
+ for (const filter of filterEntries) {
321
+ values[filter.key] = createInitialFilterValue(filter);
322
+ if (Array.isArray(filter.options) && filter.options.length > 0) {
323
+ options[filter.key] = filter.options;
324
+ }
325
+ }
326
+
327
+ const queryParams = createQueryParams(values, filterEntries);
328
+
329
+ const activeChips = computed(() => {
330
+ const chips = [];
331
+
332
+ for (const filter of filterEntries) {
333
+ const customChipLabel = normalizedChipLabels[filter.key];
334
+ const rawValue = values[filter.key];
335
+
336
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
337
+ if (rawValue === true) {
338
+ chips.push({
339
+ id: filter.key,
340
+ filterKey: filter.key,
341
+ label: normalizeText(customChipLabel?.(rawValue, filter, values))
342
+ || normalizeText(filter.chipLabel?.(rawValue, filter, values))
343
+ || defaultChipLabel(filter, rawValue, normalizedLabelResolvers)
344
+ });
345
+ }
346
+ continue;
347
+ }
348
+
349
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
350
+ for (const value of Array.isArray(rawValue) ? rawValue : []) {
351
+ chips.push({
352
+ id: `${filter.key}:${value}`,
353
+ filterKey: filter.key,
354
+ value,
355
+ label: normalizeText(customChipLabel?.(value, filter, values))
356
+ || normalizeText(filter.chipLabel?.(value, filter, values))
357
+ || `${filter.label}: ${resolveAtomicValueLabel(filter, value, normalizedLabelResolvers)}`
358
+ });
359
+ }
360
+ continue;
361
+ }
362
+
363
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE || filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
364
+ const hasValue = Boolean(rawValue?.from || rawValue?.to || rawValue?.min || rawValue?.max);
365
+ if (!hasValue) {
366
+ continue;
367
+ }
368
+
369
+ chips.push({
370
+ id: filter.key,
371
+ filterKey: filter.key,
372
+ label: normalizeText(customChipLabel?.(rawValue, filter, values))
373
+ || normalizeText(filter.chipLabel?.(rawValue, filter, values))
374
+ || defaultChipLabel(filter, rawValue, normalizedLabelResolvers)
375
+ });
376
+ continue;
377
+ }
378
+
379
+ if (!normalizeText(rawValue)) {
380
+ continue;
381
+ }
382
+
383
+ chips.push({
384
+ id: filter.key,
385
+ filterKey: filter.key,
386
+ label: normalizeText(customChipLabel?.(rawValue, filter, values))
387
+ || normalizeText(filter.chipLabel?.(rawValue, filter, values))
388
+ || defaultChipLabel(filter, rawValue, normalizedLabelResolvers)
389
+ });
390
+ }
391
+
392
+ return chips;
393
+ });
394
+
395
+ const hasActiveFilters = computed(() => activeChips.value.length > 0);
396
+
397
+ function clearFilter(filterKey = "") {
398
+ const filter = filters[normalizeText(filterKey)];
399
+ if (!filter) {
400
+ return;
401
+ }
402
+
403
+ resetFilterValue(values, filter);
404
+ }
405
+
406
+ function clearFilters() {
407
+ for (const filter of filterEntries) {
408
+ resetFilterValue(values, filter);
409
+ }
410
+ }
411
+
412
+ function clearChip(chip = {}) {
413
+ const filter = filters[normalizeText(chip.filterKey)];
414
+ if (!filter) {
415
+ return;
416
+ }
417
+
418
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
419
+ values[filter.key] = (Array.isArray(values[filter.key]) ? values[filter.key] : [])
420
+ .filter((entry) => entry !== chip.value);
421
+ return;
422
+ }
423
+
424
+ resetFilterValue(values, filter);
425
+ }
426
+
427
+ function toggle(filterKey = "") {
428
+ const filter = filters[normalizeText(filterKey)];
429
+ if (!filter || filter.type !== CRUD_LIST_FILTER_TYPE_FLAG) {
430
+ return;
431
+ }
432
+
433
+ values[filter.key] = !values[filter.key];
434
+ }
435
+
436
+ function applyPreset(presetKey = "", { mode = "replace" } = {}) {
437
+ const preset = normalizedPresets.find((entry) => entry.key === normalizeText(presetKey));
438
+ if (!preset) {
439
+ return;
440
+ }
441
+
442
+ const presetValues = resolvePresetValues(preset, {
443
+ values,
444
+ filters
445
+ });
446
+
447
+ if (mode !== "merge") {
448
+ clearFilters();
449
+ }
450
+
451
+ for (const filter of filterEntries) {
452
+ if (!Object.hasOwn(presetValues, filter.key)) {
453
+ continue;
454
+ }
455
+
456
+ applyPresetFilterValue(values, filter, presetValues[filter.key]);
457
+ }
458
+ }
459
+
460
+ function matchesPreset(presetKey = "") {
461
+ const preset = normalizedPresets.find((entry) => entry.key === normalizeText(presetKey));
462
+ if (!preset) {
463
+ return false;
464
+ }
465
+
466
+ const presetValues = resolvePresetValues(preset, {
467
+ values,
468
+ filters
469
+ });
470
+ let matchedFilter = false;
471
+
472
+ for (const filter of filterEntries) {
473
+ if (!Object.hasOwn(presetValues, filter.key)) {
474
+ continue;
475
+ }
476
+
477
+ matchedFilter = true;
478
+ if (!matchesPresetFilterValue(filter, values[filter.key], presetValues[filter.key])) {
479
+ return false;
480
+ }
481
+ }
482
+
483
+ return matchedFilter;
484
+ }
485
+
486
+ return Object.freeze({
487
+ filters,
488
+ values,
489
+ queryParams,
490
+ options: Object.freeze(options),
491
+ presets: normalizedPresets,
492
+ activeChips,
493
+ hasActiveFilters,
494
+ clearFilter,
495
+ clearFilters,
496
+ clearChip,
497
+ toggle,
498
+ applyPreset,
499
+ matchesPreset
500
+ });
501
+ }
502
+
503
+ export { useCrudListFilters };
@@ -20,6 +20,8 @@ test("users-web exports are explicit and aligned with production/template usage"
20
20
  "./client/composables/useList",
21
21
  "./client/composables/useView",
22
22
  "./client/composables/useCrudAddEdit",
23
+ "./client/composables/useCrudListFilterLookups",
24
+ "./client/composables/useCrudListFilters",
23
25
  "./client/composables/useCrudList",
24
26
  "./client/composables/useCrudView"
25
27
  ]
@@ -29,16 +29,30 @@ test("createCrudFormModel resolves defaults and supports explicit initial values
29
29
  const model = createCrudFormModel([
30
30
  { key: "name", type: "string" },
31
31
  { key: "active", type: "boolean" },
32
+ { key: "reviewed", type: "boolean", nullable: true },
32
33
  { key: "role", type: "string", initialValue: "member" }
33
34
  ]);
34
35
 
35
36
  assert.deepEqual(model, {
36
37
  name: "",
37
38
  active: false,
39
+ reviewed: null,
38
40
  role: "member"
39
41
  });
40
42
  });
41
43
 
44
+ test("createCrudFormModel preserves explicit boolean defaults for nullable fields", () => {
45
+ const model = createCrudFormModel([
46
+ { key: "reviewed", type: "boolean", nullable: true, initialValue: false },
47
+ { key: "approved", type: "boolean", nullable: true, defaultValue: true }
48
+ ]);
49
+
50
+ assert.deepEqual(model, {
51
+ reviewed: false,
52
+ approved: true
53
+ });
54
+ });
55
+
42
56
  test("buildCrudFormPayload normalizes booleans and numbers while skipping empty numeric values", () => {
43
57
  const payload = buildCrudFormPayload(
44
58
  [
@@ -100,12 +114,14 @@ test("buildCrudFormPayload normalizes time fields to canonical HH:MM", () => {
100
114
  test("buildCrudFormPayload serializes cleared nullable typed fields as null", () => {
101
115
  const payload = buildCrudFormPayload(
102
116
  [
117
+ { key: "reviewed", type: "boolean", nullable: true },
103
118
  { key: "serviceId", type: "integer", nullable: true },
104
119
  { key: "fromDate", type: "string", format: "date", nullable: true },
105
120
  { key: "scheduledAt", type: "string", format: "date-time", nullable: true },
106
121
  { key: "fromTime", type: "string", format: "time", nullable: true }
107
122
  ],
108
123
  {
124
+ reviewed: null,
109
125
  serviceId: null,
110
126
  fromDate: "",
111
127
  scheduledAt: "",
@@ -114,6 +130,7 @@ test("buildCrudFormPayload serializes cleared nullable typed fields as null", ()
114
130
  );
115
131
 
116
132
  assert.deepEqual(payload, {
133
+ reviewed: null,
117
134
  serviceId: null,
118
135
  fromDate: null,
119
136
  scheduledAt: null,
@@ -121,6 +138,40 @@ test("buildCrudFormPayload serializes cleared nullable typed fields as null", ()
121
138
  });
122
139
  });
123
140
 
141
+ test("buildCrudFormPayload preserves nullable booleans while keeping non-nullable booleans binary", () => {
142
+ const fields = [
143
+ { key: "active", type: "boolean" },
144
+ { key: "reviewed", type: "boolean", nullable: true },
145
+ { key: "approved", type: "boolean", nullable: true }
146
+ ];
147
+
148
+ assert.deepEqual(
149
+ buildCrudFormPayload(fields, {
150
+ active: null,
151
+ reviewed: null,
152
+ approved: true
153
+ }),
154
+ {
155
+ active: false,
156
+ reviewed: null,
157
+ approved: true
158
+ }
159
+ );
160
+
161
+ assert.deepEqual(
162
+ buildCrudFormPayload(fields, {
163
+ active: 1,
164
+ reviewed: 0,
165
+ approved: false
166
+ }),
167
+ {
168
+ active: true,
169
+ reviewed: false,
170
+ approved: false
171
+ }
172
+ );
173
+ });
174
+
124
175
  test("applyCrudPayloadToForm normalizes time fields for form inputs", () => {
125
176
  const fields = [
126
177
  { key: "fromTime", type: "string", format: "time" },
@@ -146,18 +197,21 @@ test("applyCrudPayloadToForm maps payload values into reactive form model", () =
146
197
  const form = reactive({
147
198
  name: "",
148
199
  active: false,
200
+ reviewed: null,
149
201
  age: ""
150
202
  });
151
203
  applyCrudPayloadToForm(
152
204
  [
153
205
  { key: "name", type: "string" },
154
206
  { key: "active", type: "boolean" },
207
+ { key: "reviewed", type: "boolean", nullable: true },
155
208
  { key: "age", type: "integer" }
156
209
  ],
157
210
  form,
158
211
  {
159
212
  name: "Grace",
160
213
  active: 1,
214
+ reviewed: null,
161
215
  age: 33
162
216
  }
163
217
  );
@@ -165,10 +219,36 @@ test("applyCrudPayloadToForm maps payload values into reactive form model", () =
165
219
  assert.deepEqual(form, {
166
220
  name: "Grace",
167
221
  active: true,
222
+ reviewed: null,
168
223
  age: "33"
169
224
  });
170
225
  });
171
226
 
227
+ test("applyCrudPayloadToForm preserves nullable boolean payload values", () => {
228
+ const fields = [
229
+ { key: "active", type: "boolean" },
230
+ { key: "reviewed", type: "boolean", nullable: true },
231
+ { key: "approved", type: "boolean", nullable: true }
232
+ ];
233
+ const form = reactive({
234
+ active: false,
235
+ reviewed: null,
236
+ approved: null
237
+ });
238
+
239
+ applyCrudPayloadToForm(fields, form, {
240
+ active: null,
241
+ reviewed: null,
242
+ approved: true
243
+ });
244
+
245
+ assert.deepEqual(form, {
246
+ active: false,
247
+ reviewed: null,
248
+ approved: true
249
+ });
250
+ });
251
+
172
252
  test("resolveCrudRouteBoundFieldValues maps route params for route-bound form fields", () => {
173
253
  const values = resolveCrudRouteBoundFieldValues(
174
254
  [
@@ -0,0 +1,114 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ resolveLookupSelectedValues,
5
+ createLookupOptionsFromItems,
6
+ mergeSelectedLookupOptions,
7
+ resolveLookupOptionLabel
8
+ } from "../src/client/composables/internal/crudListFilterLookupSupport.js";
9
+
10
+ const supplierFilter = Object.freeze({
11
+ key: "supplierContactId",
12
+ type: "recordIdMany",
13
+ label: "Supplier",
14
+ lookup: Object.freeze({
15
+ apiSuffix: "/contacts",
16
+ valueKey: "id",
17
+ labelKey: "name"
18
+ })
19
+ });
20
+
21
+ test("lookup filter support normalizes selected record ids for single and many filters", () => {
22
+ assert.deepEqual(
23
+ resolveLookupSelectedValues(supplierFilter, ["7", "4", "7", ""]),
24
+ ["7", "4"]
25
+ );
26
+
27
+ assert.deepEqual(
28
+ resolveLookupSelectedValues(
29
+ {
30
+ ...supplierFilter,
31
+ type: "recordId"
32
+ },
33
+ 12
34
+ ),
35
+ ["12"]
36
+ );
37
+ });
38
+
39
+ test("lookup filter support builds option labels from custom resolvers and fallback lookup labels", () => {
40
+ const options = createLookupOptionsFromItems(
41
+ [
42
+ {
43
+ id: 7,
44
+ firstName: "Pollen",
45
+ lastName: "Partners"
46
+ },
47
+ {
48
+ id: 4,
49
+ name: "Harbor Storage"
50
+ }
51
+ ],
52
+ supplierFilter,
53
+ (item = {}) => {
54
+ if (item.id === 7) {
55
+ return `${item.firstName} ${item.lastName}`;
56
+ }
57
+
58
+ return "";
59
+ }
60
+ );
61
+
62
+ assert.deepEqual(options, [
63
+ {
64
+ value: "7",
65
+ label: "Pollen Partners",
66
+ record: {
67
+ id: 7,
68
+ firstName: "Pollen",
69
+ lastName: "Partners"
70
+ }
71
+ },
72
+ {
73
+ value: "4",
74
+ label: "Harbor Storage",
75
+ record: {
76
+ id: 4,
77
+ name: "Harbor Storage"
78
+ }
79
+ }
80
+ ]);
81
+ });
82
+
83
+ test("lookup filter support merges cached selected labels and resolves fallback labels", () => {
84
+ const currentOptions = [
85
+ {
86
+ value: "7",
87
+ label: "Pollen Partners",
88
+ record: {
89
+ id: 7,
90
+ name: "Pollen Partners"
91
+ }
92
+ }
93
+ ];
94
+ const cachedOptions = new Map([
95
+ ["12", {
96
+ value: "12",
97
+ label: "North Shed",
98
+ record: {
99
+ id: 12,
100
+ name: "North Shed"
101
+ }
102
+ }]
103
+ ]);
104
+
105
+ const mergedOptions = mergeSelectedLookupOptions(
106
+ currentOptions,
107
+ ["12", "7"],
108
+ cachedOptions
109
+ );
110
+
111
+ assert.deepEqual(mergedOptions.map((option) => option.value), ["12", "7"]);
112
+ assert.equal(resolveLookupOptionLabel(mergedOptions, cachedOptions, "12", "Storage"), "North Shed");
113
+ assert.equal(resolveLookupOptionLabel(mergedOptions, cachedOptions, "55", "Storage"), "Storage 55");
114
+ });
@@ -0,0 +1,182 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ test("useCrudListFilters manages values, query params, chips, and presets", async () => {
5
+ const { useCrudListFilters } = await import("@jskit-ai/users-web/client/composables/useCrudListFilters");
6
+
7
+ const filters = useCrudListFilters(
8
+ {
9
+ onlyStaff: {
10
+ type: "flag",
11
+ label: "Staff"
12
+ },
13
+ status: {
14
+ type: "enumMany",
15
+ label: "Status",
16
+ options: [
17
+ { value: "active", label: "Active" },
18
+ { value: "archived", label: "Archived" }
19
+ ]
20
+ },
21
+ supplierContactId: {
22
+ type: "recordIdMany",
23
+ label: "Supplier"
24
+ },
25
+ arrivalDate: {
26
+ type: "dateRange",
27
+ label: "Arrival Date"
28
+ }
29
+ },
30
+ {
31
+ labelResolvers: {
32
+ supplierContactId(value) {
33
+ return value === "7" ? "Pollen Partners" : "";
34
+ }
35
+ },
36
+ presets: [
37
+ {
38
+ key: "needs-staff-review",
39
+ label: "Needs Staff Review",
40
+ values: {
41
+ onlyStaff: true,
42
+ status: ["archived"]
43
+ }
44
+ }
45
+ ]
46
+ }
47
+ );
48
+
49
+ filters.values.onlyStaff = true;
50
+ filters.values.status = ["active"];
51
+ filters.values.supplierContactId = ["7"];
52
+ filters.values.arrivalDate.from = "2026-04-01";
53
+
54
+ assert.equal(filters.queryParams.onlyStaff.value, true);
55
+ assert.deepEqual(filters.queryParams.status.value, ["active"]);
56
+ assert.equal(filters.queryParams.arrivalDateFrom.value, "2026-04-01");
57
+ assert.equal(filters.hasActiveFilters.value, true);
58
+ assert.deepEqual(
59
+ filters.activeChips.value.map((chip) => chip.label),
60
+ [
61
+ "Staff",
62
+ "Status: Active",
63
+ "Supplier: Pollen Partners",
64
+ "Arrival Date: from 2026-04-01"
65
+ ]
66
+ );
67
+
68
+ filters.clearChip(filters.activeChips.value.find((chip) => chip.filterKey === "supplierContactId"));
69
+ assert.deepEqual(filters.values.supplierContactId, []);
70
+
71
+ filters.applyPreset("needs-staff-review");
72
+ assert.equal(filters.values.onlyStaff, true);
73
+ assert.deepEqual(filters.values.status, ["archived"]);
74
+ assert.equal(filters.values.arrivalDate.from, "");
75
+
76
+ filters.clearFilters();
77
+ assert.equal(filters.values.onlyStaff, false);
78
+ assert.deepEqual(filters.values.status, []);
79
+ assert.equal(filters.hasActiveFilters.value, false);
80
+ });
81
+
82
+ test("useCrudListFilters supports dynamic presets and preset matching", async () => {
83
+ const { useCrudListFilters } = await import("@jskit-ai/users-web/client/composables/useCrudListFilters");
84
+ let today = "2026-04-18";
85
+
86
+ const filters = useCrudListFilters(
87
+ {
88
+ status: {
89
+ type: "enumMany",
90
+ label: "Status",
91
+ options: [
92
+ { value: "active", label: "Active" },
93
+ { value: "archived", label: "Archived" }
94
+ ]
95
+ },
96
+ arrivalDate: {
97
+ type: "dateRange",
98
+ label: "Arrival Date"
99
+ }
100
+ },
101
+ {
102
+ presets: [
103
+ {
104
+ key: "today",
105
+ label: "Today",
106
+ resolveValues() {
107
+ return {
108
+ arrivalDate: {
109
+ from: today,
110
+ to: today
111
+ }
112
+ };
113
+ }
114
+ },
115
+ {
116
+ key: "all-dates",
117
+ label: "All Dates",
118
+ values: {
119
+ arrivalDate: {
120
+ from: "",
121
+ to: ""
122
+ }
123
+ }
124
+ }
125
+ ]
126
+ }
127
+ );
128
+
129
+ filters.values.status = ["archived"];
130
+ filters.applyPreset("today", { mode: "merge" });
131
+
132
+ assert.equal(filters.values.arrivalDate.from, "2026-04-18");
133
+ assert.equal(filters.values.arrivalDate.to, "2026-04-18");
134
+ assert.deepEqual(filters.values.status, ["archived"]);
135
+ assert.equal(filters.matchesPreset("today"), true);
136
+ assert.equal(filters.matchesPreset("all-dates"), false);
137
+
138
+ today = "2026-04-19";
139
+ assert.equal(filters.matchesPreset("today"), false);
140
+
141
+ filters.applyPreset("all-dates", { mode: "merge" });
142
+ assert.equal(filters.values.arrivalDate.from, "");
143
+ assert.equal(filters.values.arrivalDate.to, "");
144
+ assert.deepEqual(filters.values.status, ["archived"]);
145
+ assert.equal(filters.matchesPreset("all-dates"), true);
146
+ });
147
+
148
+ test("useCrudListFilters preset matching rejects extra enumMany values present in current state", async () => {
149
+ const { useCrudListFilters } = await import("@jskit-ai/users-web/client/composables/useCrudListFilters");
150
+
151
+ const filters = useCrudListFilters(
152
+ {
153
+ status: {
154
+ type: "enumMany",
155
+ label: "Status",
156
+ options: [
157
+ { value: "active", label: "Active" },
158
+ { value: "archived", label: "Archived" }
159
+ ]
160
+ }
161
+ },
162
+ {
163
+ presets: [
164
+ {
165
+ key: "archived-only",
166
+ label: "Archived Only",
167
+ values: {
168
+ status: ["archived"]
169
+ }
170
+ }
171
+ ]
172
+ }
173
+ );
174
+
175
+ filters.values.status = ["archived", "bogus"];
176
+
177
+ assert.equal(filters.matchesPreset("archived-only"), false);
178
+ assert.deepEqual(
179
+ filters.activeChips.value.map((chip) => chip.label),
180
+ ["Status: Archived", "Status: bogus"]
181
+ );
182
+ });