nuxt-ui-tools 1.25.1 → 1.27.0
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.
- package/dist/module.json +1 -1
- package/dist/runtime/file-preview/components/shell/file-preview-header.d.vue.ts +2 -0
- package/dist/runtime/file-preview/components/shell/file-preview-header.vue +13 -7
- package/dist/runtime/file-preview/components/shell/file-preview-header.vue.d.ts +2 -0
- package/dist/runtime/file-preview/components/shell/file-preview-shell.vue +6 -0
- package/dist/runtime/file-preview/components/shell/file-preview-stage.d.vue.ts +5 -3
- package/dist/runtime/file-preview/components/shell/file-preview-stage.vue +4 -10
- package/dist/runtime/file-preview/components/shell/file-preview-stage.vue.d.ts +5 -3
- package/dist/runtime/form/components/root/form.vue +1 -0
- package/dist/runtime/form/composables/use-field-options.js +3 -1
- package/dist/runtime/form/composables/use-form-overlay-controller.js +1 -1
- package/dist/runtime/form/composables/use-form-root.d.ts +3 -1
- package/dist/runtime/form/composables/use-form-root.js +26 -11
- package/dist/runtime/form/composables/use-form-runtime.js +14 -4
- package/dist/runtime/form/composables/use-form-validation.d.ts +5 -2
- package/dist/runtime/form/composables/use-form-validation.js +51 -5
- package/dist/runtime/form/fields/custom-component/component.vue +2 -3
- package/dist/runtime/form/fields/date-family/component.vue +7 -6
- package/dist/runtime/form/fields/date-family/utils.d.ts +5 -0
- package/dist/runtime/form/fields/date-family/utils.js +13 -0
- package/dist/runtime/form/fields/number/component.vue +11 -5
- package/dist/runtime/form/fields/phone-number/component.vue +20 -1
- package/dist/runtime/form/types/api.d.ts +9 -2
- package/dist/runtime/form/types/options.d.ts +4 -1
- package/dist/runtime/form/types/runtime.d.ts +10 -0
- package/dist/runtime/form/types/schema.d.ts +5 -1
- package/dist/runtime/form/utils/api.js +1 -0
- package/dist/runtime/i18n/locales/en.js +12 -3
- package/dist/runtime/i18n/locales/fr.js +12 -3
- package/dist/runtime/i18n/types.d.ts +16 -0
- package/dist/runtime/table/components/filters/tags/date-filter-tag.vue +4 -0
- package/dist/runtime/table/composables/use-filter-persistence.d.ts +14 -0
- package/dist/runtime/table/composables/use-filter-persistence.js +77 -0
- package/dist/runtime/table/composables/use-query-state.js +31 -26
- package/dist/runtime/table/composables/use-table-api.js +4 -0
- package/dist/runtime/table/composables/use-table-data.js +3 -2
- package/dist/runtime/table/composables/use-table-state.d.ts +1 -0
- package/dist/runtime/table/composables/use-table-state.js +13 -2
- package/dist/runtime/table/types/filters.d.ts +28 -0
- package/dist/runtime/table/types/table-api.d.ts +5 -0
- package/dist/runtime/table/utils/filter-persistence.d.ts +16 -0
- package/dist/runtime/table/utils/filter-persistence.js +42 -0
- package/dist/runtime/table/utils/filters/options.js +2 -1
- package/dist/runtime/table/utils/filters/preview/option-preview.js +4 -1
- package/dist/runtime/table/utils/index.d.ts +1 -0
- package/dist/runtime/table/utils/index.js +1 -0
- package/dist/runtime/table/utils/resolved-filters.d.ts +8 -0
- package/dist/runtime/table/utils/resolved-filters.js +6 -0
- package/package.json +1 -1
|
@@ -193,6 +193,19 @@ export function calendarSeedFromValue(value, type) {
|
|
|
193
193
|
}
|
|
194
194
|
return "";
|
|
195
195
|
}
|
|
196
|
+
export function formatDateFamilyBound(value, locale) {
|
|
197
|
+
const date = /^\d{4}$/u.test(value) ? void 0 : canonicalDateToJsDate(value);
|
|
198
|
+
if (!date) {
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
if (/^\d{4}-\d{2}$/u.test(value)) {
|
|
202
|
+
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
|
|
203
|
+
}
|
|
204
|
+
return new Intl.DateTimeFormat(
|
|
205
|
+
locale,
|
|
206
|
+
value.includes("T") ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" }
|
|
207
|
+
).format(date);
|
|
208
|
+
}
|
|
196
209
|
export function calendarDateToJsDate(value) {
|
|
197
210
|
return value.toDate(getLocalTimeZone());
|
|
198
211
|
}
|
|
@@ -16,7 +16,7 @@ const { code } = useUiToolsLocale();
|
|
|
16
16
|
const { fieldProps, form, controlProps, disabled, handleBlur, placeholder } = useFieldControl(
|
|
17
17
|
() => props.field,
|
|
18
18
|
() => props.path,
|
|
19
|
-
{ omit: ["prefix", "suffix", "format", "controls", "mono"] }
|
|
19
|
+
{ omit: ["prefix", "suffix", "format", "controls", "mono", "min", "max"] }
|
|
20
20
|
);
|
|
21
21
|
const model = computed({
|
|
22
22
|
get: () => {
|
|
@@ -25,6 +25,12 @@ const model = computed({
|
|
|
25
25
|
},
|
|
26
26
|
set: (value) => form.setValue(props.path, value ?? null)
|
|
27
27
|
});
|
|
28
|
+
const atMax = computed(
|
|
29
|
+
() => isNumber(model.value) && isNumber(fieldProps.value.max) && model.value >= fieldProps.value.max
|
|
30
|
+
);
|
|
31
|
+
const atMin = computed(
|
|
32
|
+
() => isNumber(model.value) && isNumber(fieldProps.value.min) && model.value <= fieldProps.value.min
|
|
33
|
+
);
|
|
28
34
|
const prefix = computed(() => resolveFormText(fieldProps.value.prefix));
|
|
29
35
|
const suffix = computed(() => resolveFormText(fieldProps.value.suffix));
|
|
30
36
|
const controls = computed(() => fieldProps.value.controls !== false);
|
|
@@ -76,13 +82,13 @@ const affixStyle = computed(() => ({
|
|
|
76
82
|
:ui="controlUi"
|
|
77
83
|
:placeholder="placeholder"
|
|
78
84
|
:disabled="disabled"
|
|
79
|
-
:min="fieldProps.min"
|
|
80
|
-
:max="fieldProps.max"
|
|
81
85
|
:step="fieldProps.step"
|
|
82
86
|
:locale="code"
|
|
83
87
|
:format-options="fieldProps.format"
|
|
84
88
|
:increment="controls"
|
|
85
89
|
:decrement="controls"
|
|
90
|
+
:increment-disabled="atMax"
|
|
91
|
+
:decrement-disabled="atMin"
|
|
86
92
|
@blur="handleBlur"
|
|
87
93
|
/>
|
|
88
94
|
<span
|
|
@@ -121,13 +127,13 @@ const affixStyle = computed(() => ({
|
|
|
121
127
|
:ui="controlUi"
|
|
122
128
|
:placeholder="placeholder"
|
|
123
129
|
:disabled="disabled"
|
|
124
|
-
:min="fieldProps.min"
|
|
125
|
-
:max="fieldProps.max"
|
|
126
130
|
:step="fieldProps.step"
|
|
127
131
|
:locale="code"
|
|
128
132
|
:format-options="fieldProps.format"
|
|
129
133
|
:increment="controls"
|
|
130
134
|
:decrement="controls"
|
|
135
|
+
:increment-disabled="atMax"
|
|
136
|
+
:decrement-disabled="atMin"
|
|
131
137
|
@blur="handleBlur"
|
|
132
138
|
/>
|
|
133
139
|
<span
|
|
@@ -4,7 +4,12 @@ import UFieldGroup from "@nuxt/ui/components/FieldGroup.vue";
|
|
|
4
4
|
import UIcon from "@nuxt/ui/components/Icon.vue";
|
|
5
5
|
import UInput from "@nuxt/ui/components/Input.vue";
|
|
6
6
|
import USelectMenu from "@nuxt/ui/components/SelectMenu.vue";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
getCountries,
|
|
9
|
+
getCountryCallingCode,
|
|
10
|
+
parsePhoneNumberFromString,
|
|
11
|
+
validatePhoneNumberLength
|
|
12
|
+
} from "libphonenumber-js";
|
|
8
13
|
import { computed, onMounted, ref, useId, watch } from "vue";
|
|
9
14
|
import { useUiToolsLocale } from "../../../i18n/use-locale";
|
|
10
15
|
import FormFieldShell from "../../components/renderer/form-field-shell.vue";
|
|
@@ -47,6 +52,20 @@ const processedValue = computed(() => {
|
|
|
47
52
|
}
|
|
48
53
|
return { valid: true, value: formatPhoneNumber(parsed) };
|
|
49
54
|
});
|
|
55
|
+
const inputError = computed(() => {
|
|
56
|
+
if (!phoneValue.value || !countryCode.value || processedValue.value.valid) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return validatePhoneNumberLength(phoneValue.value, countryCode.value) === "TOO_SHORT" ? t("form.validation.phoneIncomplete") : t("form.validation.phoneInvalid");
|
|
60
|
+
});
|
|
61
|
+
watch(
|
|
62
|
+
[() => props.path, inputError],
|
|
63
|
+
([path, message], _previous, onCleanup) => {
|
|
64
|
+
form.setInputError(path, message);
|
|
65
|
+
onCleanup(() => form.setInputError(path, null));
|
|
66
|
+
},
|
|
67
|
+
{ immediate: true }
|
|
68
|
+
);
|
|
50
69
|
onMounted(() => {
|
|
51
70
|
syncFromExternalValue(form.getValue(props.path));
|
|
52
71
|
if (!countryCode.value) {
|
|
@@ -61,8 +61,8 @@ export interface FormFieldFormApi {
|
|
|
61
61
|
submit: () => Promise<boolean>;
|
|
62
62
|
/**
|
|
63
63
|
* Closes the form the way its Cancel action does: asks to discard unsaved changes when the
|
|
64
|
-
* schema asks for it, then cancels (an overlay closes). Does
|
|
65
|
-
* a cancel handler.
|
|
64
|
+
* schema asks for it (an overlay form does by default), then cancels (an overlay closes). Does
|
|
65
|
+
* nothing for a form rendered without a cancel handler.
|
|
66
66
|
*/
|
|
67
67
|
close: () => Promise<void>;
|
|
68
68
|
/** Advances to the next step on stepped forms. */
|
|
@@ -206,6 +206,13 @@ export interface FormApi<TOutput = FormObject> {
|
|
|
206
206
|
submit: () => Promise<void>;
|
|
207
207
|
/** Resets the form and waits for dependency effects triggered by the reset. */
|
|
208
208
|
reset: () => Promise<void>;
|
|
209
|
+
/**
|
|
210
|
+
* Closes the form the way its Cancel action does: asks to discard unsaved changes when the
|
|
211
|
+
* schema asks for it (an overlay form does by default), then cancels, so an overlay closes and
|
|
212
|
+
* its `createForm` promise resolves with `isCompleted: false`. Does nothing for a form rendered
|
|
213
|
+
* without a cancel handler.
|
|
214
|
+
*/
|
|
215
|
+
close: () => Promise<void>;
|
|
209
216
|
}
|
|
210
217
|
export type FormSubmitAction = 'next' | 'previous' | 'submit' | 'reset';
|
|
211
218
|
export type FormSubmitResult<TSubmitData = FormValue> = boolean | void | {
|
|
@@ -257,7 +257,10 @@ export interface FormRemoteOptionConfig<TOption, TContext = NonNullable<unknown>
|
|
|
257
257
|
create?: FormCreateOption<TOption, TContext, TDeps, TValue>;
|
|
258
258
|
/** Enables the field-level refresh affordance. */
|
|
259
259
|
allowOptionsRefresh?: boolean;
|
|
260
|
-
/**
|
|
260
|
+
/**
|
|
261
|
+
* Ignored: remote options load once the menu opens and the menu shows the loading state, so the
|
|
262
|
+
* field and its search input stay usable while a page loads.
|
|
263
|
+
*/
|
|
261
264
|
disableOnLoading?: boolean;
|
|
262
265
|
}
|
|
263
266
|
/** Reuses one remote loader while keeping field-specific refresh and creation behavior local. */
|
|
@@ -80,6 +80,11 @@ export interface FormRuntime {
|
|
|
80
80
|
validate: (options?: FormValidationOptions) => Promise<boolean>;
|
|
81
81
|
validateCurrentStep: (options?: FormValidationOptions) => Promise<boolean>;
|
|
82
82
|
setError: (path: string | readonly string[], message: string, options?: FormErrorOptions) => void;
|
|
83
|
+
/**
|
|
84
|
+
* Reports input a control holds but cannot store as a value, such as an incomplete phone number,
|
|
85
|
+
* or clears it with `null`. It shows like a rule error once the field is touched and blocks submit.
|
|
86
|
+
*/
|
|
87
|
+
setInputError: (path: string | readonly string[], message: string | null) => void;
|
|
83
88
|
clearError: (path?: string | readonly string[]) => void;
|
|
84
89
|
focusRequest: Ref<FormFocusRequest | null>;
|
|
85
90
|
registerFieldElement: (path: string | readonly string[], element: HTMLElement) => () => void;
|
|
@@ -94,4 +99,9 @@ export interface FormRuntime {
|
|
|
94
99
|
goToStep: (index: number) => Promise<boolean>;
|
|
95
100
|
/** Cancels the rendered form, as its Cancel action does. Set by the form root that renders it. */
|
|
96
101
|
bindCancel: (cancel: (() => Promise<void>) | null) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Closes the form the way its Cancel action does, asking first when it holds unsaved changes the
|
|
104
|
+
* schema protects. Resolves at once when no form root renders the runtime.
|
|
105
|
+
*/
|
|
106
|
+
close: () => Promise<void>;
|
|
97
107
|
}
|
|
@@ -18,7 +18,11 @@ export interface FormControlsConfig {
|
|
|
18
18
|
ignoreDirtyPaths?: readonly string[];
|
|
19
19
|
/** Focuses the first field after mount, or a specific raw field path. */
|
|
20
20
|
autoFocus?: boolean | string;
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Asks before a modified form is cancelled, closed, or left for another page. A form opened as a
|
|
23
|
+
* modal, drawer, or fullscreen overlay asks before closing (Escape, outside click, close button,
|
|
24
|
+
* Cancel, `api.close()`) unless this is `false`; leaving the page asks only when it is set.
|
|
25
|
+
*/
|
|
22
26
|
confirmNavOnDirty?: boolean | FormDirtyNavigationConfig;
|
|
23
27
|
/** Keeps external input changes synchronized after the initial mount. */
|
|
24
28
|
syncInput?: boolean | readonly string[];
|
|
@@ -2,6 +2,7 @@ import { pathSegments } from "./path.js";
|
|
|
2
2
|
export function createPublicFormApi(runtime) {
|
|
3
3
|
return {
|
|
4
4
|
clearError: (path) => runtime.clearError(path ? pathSegments(path) : void 0),
|
|
5
|
+
close: runtime.close,
|
|
5
6
|
focus: runtime.focusField,
|
|
6
7
|
get: runtime.getValue,
|
|
7
8
|
initial: runtime.getInitialValue,
|
|
@@ -269,6 +269,7 @@ export default defineUiToolsLocale({
|
|
|
269
269
|
open: "Open color picker"
|
|
270
270
|
},
|
|
271
271
|
date: {
|
|
272
|
+
calendar: "Calendar",
|
|
272
273
|
clear: "Clear value",
|
|
273
274
|
confirm: "Confirm",
|
|
274
275
|
end: "End",
|
|
@@ -355,9 +356,17 @@ export default defineUiToolsLocale({
|
|
|
355
356
|
fallback: "Step {number}"
|
|
356
357
|
},
|
|
357
358
|
validation: {
|
|
358
|
-
dateMax: "
|
|
359
|
-
dateMin: "
|
|
360
|
-
|
|
359
|
+
dateMax: "Date after {max}",
|
|
360
|
+
dateMin: "Date before {min}",
|
|
361
|
+
monthMax: "Month after {max}",
|
|
362
|
+
monthMin: "Month before {min}",
|
|
363
|
+
numberMax: "Maximum value: {bound}",
|
|
364
|
+
numberMin: "Minimum value: {bound}",
|
|
365
|
+
phoneIncomplete: "Incomplete phone number",
|
|
366
|
+
phoneInvalid: "Invalid phone number",
|
|
367
|
+
required: "This field is required",
|
|
368
|
+
yearMax: "Year after {max}",
|
|
369
|
+
yearMin: "Year before {min}"
|
|
361
370
|
}
|
|
362
371
|
},
|
|
363
372
|
spreadsheet: {
|
|
@@ -269,6 +269,7 @@ export default defineUiToolsLocale({
|
|
|
269
269
|
open: "Ouvrir le s\xE9lecteur de couleur"
|
|
270
270
|
},
|
|
271
271
|
date: {
|
|
272
|
+
calendar: "Calendrier",
|
|
272
273
|
clear: "Effacer la valeur",
|
|
273
274
|
confirm: "Confirmer",
|
|
274
275
|
end: "Fin",
|
|
@@ -355,9 +356,17 @@ export default defineUiToolsLocale({
|
|
|
355
356
|
fallback: "\xC9tape {number}"
|
|
356
357
|
},
|
|
357
358
|
validation: {
|
|
358
|
-
dateMax: "
|
|
359
|
-
dateMin: "
|
|
360
|
-
|
|
359
|
+
dateMax: "Date post\xE9rieure au {max}",
|
|
360
|
+
dateMin: "Date ant\xE9rieure au {min}",
|
|
361
|
+
monthMax: "Mois post\xE9rieur \xE0 {max}",
|
|
362
|
+
monthMin: "Mois ant\xE9rieur \xE0 {min}",
|
|
363
|
+
numberMax: "Valeur maximale : {bound}",
|
|
364
|
+
numberMin: "Valeur minimale : {bound}",
|
|
365
|
+
phoneIncomplete: "Num\xE9ro de t\xE9l\xE9phone incomplet",
|
|
366
|
+
phoneInvalid: "Num\xE9ro de t\xE9l\xE9phone invalide",
|
|
367
|
+
required: "Ce champ est requis",
|
|
368
|
+
yearMax: "Ann\xE9e post\xE9rieure \xE0 {max}",
|
|
369
|
+
yearMin: "Ann\xE9e ant\xE9rieure \xE0 {min}"
|
|
361
370
|
}
|
|
362
371
|
},
|
|
363
372
|
spreadsheet: {
|
|
@@ -504,8 +504,22 @@ export interface UiToolsFormMessages {
|
|
|
504
504
|
};
|
|
505
505
|
validation: {
|
|
506
506
|
required: string;
|
|
507
|
+
/** A date before the field's `min`: "Date before {min}". */
|
|
507
508
|
dateMin: string;
|
|
509
|
+
/** A date after the field's `max`: "Date after {max}". */
|
|
508
510
|
dateMax: string;
|
|
511
|
+
monthMin: string;
|
|
512
|
+
monthMax: string;
|
|
513
|
+
yearMin: string;
|
|
514
|
+
yearMax: string;
|
|
515
|
+
/** A number below the field's `min`: "Minimum value: {bound}". */
|
|
516
|
+
numberMin: string;
|
|
517
|
+
/** A number above the field's `max`: "Maximum value: {bound}". */
|
|
518
|
+
numberMax: string;
|
|
519
|
+
/** A phone number too short for its country. */
|
|
520
|
+
phoneIncomplete: string;
|
|
521
|
+
/** A phone number that is not valid for its country or allowed types. */
|
|
522
|
+
phoneInvalid: string;
|
|
509
523
|
};
|
|
510
524
|
fields: {
|
|
511
525
|
text: {
|
|
@@ -562,6 +576,8 @@ export interface UiToolsFormMessages {
|
|
|
562
576
|
end: string;
|
|
563
577
|
clear: string;
|
|
564
578
|
confirm: string;
|
|
579
|
+
/** Accessible name of the calendar when the field has no label, read before the month. */
|
|
580
|
+
calendar: string;
|
|
565
581
|
tokens: {
|
|
566
582
|
day: string;
|
|
567
583
|
month: string;
|
|
@@ -464,6 +464,9 @@ function areSameCalendarDay(left, right) {
|
|
|
464
464
|
<UCalendar
|
|
465
465
|
:model-value="calendarRange"
|
|
466
466
|
:size="size"
|
|
467
|
+
:calendar-label="
|
|
468
|
+
internals.filters.getFilterLabelText({ label: definition.label })
|
|
469
|
+
"
|
|
467
470
|
range
|
|
468
471
|
:number-of-months="rangeCalendarPanels"
|
|
469
472
|
:paged-navigation="
|
|
@@ -602,6 +605,7 @@ function areSameCalendarDay(left, right) {
|
|
|
602
605
|
"
|
|
603
606
|
:model-value="localDate"
|
|
604
607
|
:size="size"
|
|
608
|
+
:calendar-label="internals.filters.getFilterLabelText({ label: definition.label })"
|
|
605
609
|
:fixed-weeks="filterUi.scalar.calendar.fixedWeeks"
|
|
606
610
|
:ui="{
|
|
607
611
|
root: mergeDataListUiClass(
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ComputedRef, WritableComputedRef } from 'vue';
|
|
2
|
+
import type { TableFilterState, TableSchemaView } from '../types/index.js';
|
|
3
|
+
export interface UseFilterPersistenceParams {
|
|
4
|
+
schema: ComputedRef<TableSchemaView>;
|
|
5
|
+
filters: WritableComputedRef<TableFilterState>;
|
|
6
|
+
/** Writes the restored state without adding a browser history entry. */
|
|
7
|
+
restore: (state: TableFilterState) => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Keeps the value of every filter marked `behavior.persist` in `localStorage`. On setup, a stored
|
|
11
|
+
* value replaces the default of a filter the URL does not set; afterwards each change is written
|
|
12
|
+
* back, and a filter back at its default is forgotten.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useFilterPersistence(params: UseFilterPersistenceParams): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { watch } from "vue";
|
|
2
|
+
import {
|
|
3
|
+
isTableFilterRuleDefault,
|
|
4
|
+
parsePersistedFilterRule,
|
|
5
|
+
persistedFilterStorageKey,
|
|
6
|
+
serializePersistedFilterRule
|
|
7
|
+
} from "../utils/index.js";
|
|
8
|
+
export function useFilterPersistence(params) {
|
|
9
|
+
const storage = resolveStorage();
|
|
10
|
+
const persisted = (params.schema.value.filters?.ui ?? []).filter(
|
|
11
|
+
(definition) => definition.behavior?.persist === true
|
|
12
|
+
);
|
|
13
|
+
if (!storage || persisted.length === 0) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const storageKey = (definition) => persistedFilterStorageKey({
|
|
17
|
+
tableKey: params.schema.value.tableKey,
|
|
18
|
+
filterKey: definition.key
|
|
19
|
+
});
|
|
20
|
+
const current = params.filters.value.ui;
|
|
21
|
+
const restored = persisted.flatMap((definition) => {
|
|
22
|
+
const rule = current.find((candidate) => candidate.key === definition.key);
|
|
23
|
+
if (rule && !isTableFilterRuleDefault({ definition, rule })) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const stored = parsePersistedFilterRule({
|
|
27
|
+
definition,
|
|
28
|
+
raw: safeRead(storage, storageKey(definition))
|
|
29
|
+
});
|
|
30
|
+
return stored ? [stored] : [];
|
|
31
|
+
});
|
|
32
|
+
if (restored.length > 0) {
|
|
33
|
+
const keys = new Set(restored.map((rule) => rule.key));
|
|
34
|
+
params.restore({
|
|
35
|
+
...params.filters.value,
|
|
36
|
+
ui: [...current.filter((rule) => !keys.has(rule.key)), ...restored]
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
watch(
|
|
40
|
+
() => params.filters.value.ui,
|
|
41
|
+
(rules) => {
|
|
42
|
+
for (const definition of persisted) {
|
|
43
|
+
const rule = rules.find((candidate) => candidate.key === definition.key);
|
|
44
|
+
safeWrite(
|
|
45
|
+
storage,
|
|
46
|
+
storageKey(definition),
|
|
47
|
+
rule && !isTableFilterRuleDefault({ definition, rule }) ? serializePersistedFilterRule({ definition, rule }) : null
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
function resolveStorage() {
|
|
54
|
+
try {
|
|
55
|
+
return typeof window === "undefined" ? null : window.localStorage;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function safeRead(storage, key) {
|
|
61
|
+
try {
|
|
62
|
+
return storage.getItem(key);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function safeWrite(storage, key, value) {
|
|
68
|
+
try {
|
|
69
|
+
if (value === null) {
|
|
70
|
+
storage.removeItem(key);
|
|
71
|
+
} else {
|
|
72
|
+
storage.setItem(key, value);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveFilterSupportedOperators,
|
|
20
20
|
serializeTableFilterQueryState
|
|
21
21
|
} from "../utils/index.js";
|
|
22
|
+
import { useFilterPersistence } from "./use-filter-persistence.js";
|
|
22
23
|
export function useQueryState(params) {
|
|
23
24
|
const defaultPageSize = getDefaultPageSize({
|
|
24
25
|
layout: params.activeLayout.value,
|
|
@@ -96,32 +97,36 @@ export function useQueryState(params) {
|
|
|
96
97
|
sortingState.value = value ? { dir: value.dir, key: value.key } : { dir: void 0, key: "" };
|
|
97
98
|
}
|
|
98
99
|
});
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
100
|
+
const filterSchema = {
|
|
101
|
+
search: { codec: stringCodec, defaultValue: "" },
|
|
102
|
+
ui: dynamicQueryState({
|
|
103
|
+
defaultValue: resolveTableFilterDefaultRules(params.schema.value.filters?.ui ?? []),
|
|
104
|
+
definitions: () => params.schema.value.filters?.ui ?? [],
|
|
105
|
+
parse(entries, definitions) {
|
|
106
|
+
return parseTableFilterQueryState({ definitions, entries });
|
|
107
|
+
},
|
|
108
|
+
resolve(filter) {
|
|
109
|
+
const definition = normalizeFilterDefinition(filter);
|
|
110
|
+
const operators = resolveFilterSupportedOperators(definition);
|
|
111
|
+
const defaultOp = resolveFilterDefaultOperator(definition);
|
|
112
|
+
const codec = createTableFilterValueCodec(definition);
|
|
113
|
+
return operators.map((op) => ({
|
|
114
|
+
codec,
|
|
115
|
+
urlKey: op === defaultOp ? definition.key : `${definition.key}~${op}`
|
|
116
|
+
}));
|
|
117
|
+
},
|
|
118
|
+
serialize(rules, definitions) {
|
|
119
|
+
return serializeTableFilterQueryState({ definitions, rules });
|
|
120
|
+
},
|
|
121
|
+
urlPrefix: "ui"
|
|
122
|
+
})
|
|
123
|
+
};
|
|
124
|
+
const filters = useQueryStates({ historyMode: "push", prefix: "f", schema: filterSchema });
|
|
125
|
+
useFilterPersistence({
|
|
126
|
+
schema: params.schema,
|
|
127
|
+
filters,
|
|
128
|
+
restore(state) {
|
|
129
|
+
useQueryStates({ historyMode: "replace", prefix: "f", schema: filterSchema }).value = state;
|
|
125
130
|
}
|
|
126
131
|
});
|
|
127
132
|
return {
|
|
@@ -23,6 +23,10 @@ export function useTableApi(params) {
|
|
|
23
23
|
// SAFETY: raw rows follow the same schema source contract before client-side shaping.
|
|
24
24
|
rawRows: computed(() => params.queryContent.rawData.value.rows),
|
|
25
25
|
rawRowCount: computed(() => params.queryContent.rawData.value.rowCount),
|
|
26
|
+
// SAFETY: filtered rows are the raw source rows after client filtering and sorting.
|
|
27
|
+
filteredRows: computed(
|
|
28
|
+
() => params.queryContent.filteredRows.value
|
|
29
|
+
),
|
|
26
30
|
context: computed(
|
|
27
31
|
// SAFETY: context keys are decoded from the schema context items before publication.
|
|
28
32
|
() => params.queryContent.contextData.value
|
|
@@ -59,6 +59,7 @@ export function useTableData(params) {
|
|
|
59
59
|
() => params.state.queryState.sorting.value ? [params.state.queryState.sorting.value] : []
|
|
60
60
|
);
|
|
61
61
|
const requestFilters = computed(() => params.state.resolvedFilterState.value);
|
|
62
|
+
const rowFilters = computed(() => params.state.rowFilterState.value);
|
|
62
63
|
const requestSearch = computed(() => ({
|
|
63
64
|
fields: params.schema.value.filters?.search?.fields ?? [],
|
|
64
65
|
value: params.state.queryState.filters.value.search
|
|
@@ -214,7 +215,7 @@ export function useTableData(params) {
|
|
|
214
215
|
return rawData.value.rows;
|
|
215
216
|
}
|
|
216
217
|
return filterClientRows({
|
|
217
|
-
filters:
|
|
218
|
+
filters: rowFilters.value,
|
|
218
219
|
rows: rawData.value.rows,
|
|
219
220
|
search: requestSearch.value
|
|
220
221
|
});
|
|
@@ -269,7 +270,7 @@ export function useTableData(params) {
|
|
|
269
270
|
if (params.schema.value.source.mode === "client") {
|
|
270
271
|
return executeClientFacets({
|
|
271
272
|
facets: clientFacetDescriptors.value,
|
|
272
|
-
request: requestContext.value,
|
|
273
|
+
request: { ...requestContext.value, filters: rowFilters.value },
|
|
273
274
|
rows: rawData.value.rows
|
|
274
275
|
});
|
|
275
276
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computed } from "vue";
|
|
2
|
-
import { createResolvedFilterState } from "../utils/index.js";
|
|
2
|
+
import { createResolvedFilterState, withoutQueryFilterRules } from "../utils/index.js";
|
|
3
3
|
import { useQueryState } from "./use-query-state.js";
|
|
4
4
|
export function useTableState(params) {
|
|
5
5
|
const queryState = useQueryState({
|
|
@@ -13,8 +13,19 @@ export function useTableState(params) {
|
|
|
13
13
|
staticFilters: params.schema.value.filters?.static
|
|
14
14
|
})
|
|
15
15
|
);
|
|
16
|
+
const rowFilterState = computed(
|
|
17
|
+
() => createResolvedFilterState({
|
|
18
|
+
definitions: params.schema.value.filters?.ui ?? [],
|
|
19
|
+
filters: withoutQueryFilterRules({
|
|
20
|
+
definitions: params.schema.value.filters?.ui ?? [],
|
|
21
|
+
filters: queryState.filters.value
|
|
22
|
+
}),
|
|
23
|
+
staticFilters: params.schema.value.filters?.static
|
|
24
|
+
})
|
|
25
|
+
);
|
|
16
26
|
return {
|
|
17
27
|
queryState,
|
|
18
|
-
resolvedFilterState
|
|
28
|
+
resolvedFilterState,
|
|
29
|
+
rowFilterState
|
|
19
30
|
};
|
|
20
31
|
}
|
|
@@ -31,6 +31,17 @@ export interface TableFilterBehaviorCommon<TOperator extends TableFilterOperator
|
|
|
31
31
|
commitMode?: TableFilterCommitMode;
|
|
32
32
|
clearOnOperatorChange?: boolean;
|
|
33
33
|
reopenOnOperatorChange?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* What the filter narrows. `rows` (default) matches every row against it. `query` only hands the
|
|
36
|
+
* value to the source query, through its `filters`, so a client source can ask the server for a
|
|
37
|
+
* different dataset (a period, a scope) while its rows are never matched against the rule.
|
|
38
|
+
*/
|
|
39
|
+
applyTo?: 'rows' | 'query';
|
|
40
|
+
/**
|
|
41
|
+
* Remembers the last value in `localStorage`, per table key and filter key. It is restored when
|
|
42
|
+
* the URL carries no value of its own, and forgotten once the filter returns to its default.
|
|
43
|
+
*/
|
|
44
|
+
persist?: boolean;
|
|
34
45
|
}
|
|
35
46
|
export interface TableFilterUiActionLabelsConfig {
|
|
36
47
|
actions?: {
|
|
@@ -647,6 +658,23 @@ export interface TableOptionFilterDefinition<TRow extends GenericObject = Generi
|
|
|
647
658
|
*/
|
|
648
659
|
remote?: TableFilterRemoteOptions<TValue, TPresentation>;
|
|
649
660
|
facet?: TableFilterFacetSpec<TRow, TContext, TKey>;
|
|
661
|
+
/**
|
|
662
|
+
* Labels the values a facet lists when `options` is not set, such as provider statuses stored
|
|
663
|
+
* as `Approved` or `Security compromised`. The facet still decides which values show, with their
|
|
664
|
+
* counts; a value it returns `undefined` for shows as stored. Tags and previews of a committed
|
|
665
|
+
* value read the same label.
|
|
666
|
+
*
|
|
667
|
+
* @example
|
|
668
|
+
* ```ts
|
|
669
|
+
* source: {
|
|
670
|
+
* facet: true,
|
|
671
|
+
* label: (value) => ({ Approved: 'Validé', Done: 'Terminé' })[String(value)],
|
|
672
|
+
* }
|
|
673
|
+
* ```
|
|
674
|
+
*/
|
|
675
|
+
label?: {
|
|
676
|
+
bivarianceHack(value: TValue): TableTextValue | undefined;
|
|
677
|
+
}['bivarianceHack'];
|
|
650
678
|
sort?: 'alpha' | 'count';
|
|
651
679
|
};
|
|
652
680
|
editor?: TableOptionFilterEditorConfig<TPresentation>;
|
|
@@ -87,6 +87,11 @@ export interface TableApi<TSchema = TableSchemaView> {
|
|
|
87
87
|
totalRowCount: ComputedRef<number | null>;
|
|
88
88
|
rawRows: ComputedRef<ExtractTableRow<TSchema>[]>;
|
|
89
89
|
rawRowCount: ComputedRef<number | null>;
|
|
90
|
+
/**
|
|
91
|
+
* Every row that passes the filters and the search, sorted, before pagination. Client sources
|
|
92
|
+
* only; `undefined` for remote sources, which never hold the whole result.
|
|
93
|
+
*/
|
|
94
|
+
filteredRows: ComputedRef<ExtractTableRow<TSchema>[] | undefined>;
|
|
90
95
|
context: ComputedRef<ExtractTableContextData<TSchema>>;
|
|
91
96
|
pageContext: ComputedRef<ExtractTablePageContextData<TSchema>>;
|
|
92
97
|
requestContext: ReturnType<typeof useTableData>['requestContext'];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TableQueryStateFilterRule, TableUiFilterDefinition } from '../types/index.js';
|
|
2
|
+
/** Storage key of one persisted filter, scoped by table key. */
|
|
3
|
+
export declare function persistedFilterStorageKey(options: {
|
|
4
|
+
tableKey: string;
|
|
5
|
+
filterKey: string;
|
|
6
|
+
}): string;
|
|
7
|
+
/** Serializes a rule with the filter's URL codec, or returns `null` when it has nothing to keep. */
|
|
8
|
+
export declare function serializePersistedFilterRule(options: {
|
|
9
|
+
definition: TableUiFilterDefinition;
|
|
10
|
+
rule: TableQueryStateFilterRule;
|
|
11
|
+
}): string | null;
|
|
12
|
+
/** Reads a stored rule back, dropping anything the filter no longer accepts. */
|
|
13
|
+
export declare function parsePersistedFilterRule(options: {
|
|
14
|
+
definition: TableUiFilterDefinition;
|
|
15
|
+
raw: string | null;
|
|
16
|
+
}): TableQueryStateFilterRule | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isObject, isString } from "../../shared/utils/predicate.js";
|
|
2
|
+
import {
|
|
3
|
+
createTableFilterValueCodec,
|
|
4
|
+
normalizeFilterDefinition,
|
|
5
|
+
resolveFilterDefaultOperator,
|
|
6
|
+
resolveFilterSupportedOperators
|
|
7
|
+
} from "./query-state.js";
|
|
8
|
+
const STORAGE_PREFIX = "nuxt-ui-tools:table-filter";
|
|
9
|
+
export function persistedFilterStorageKey(options) {
|
|
10
|
+
return `${STORAGE_PREFIX}:${options.tableKey}:${options.filterKey}`;
|
|
11
|
+
}
|
|
12
|
+
export function serializePersistedFilterRule(options) {
|
|
13
|
+
const normalized = normalizeFilterDefinition(options.definition);
|
|
14
|
+
const value = createTableFilterValueCodec(normalized).serialize(options.rule.value);
|
|
15
|
+
return value === null ? null : JSON.stringify({
|
|
16
|
+
operator: options.rule.operator ?? resolveFilterDefaultOperator(normalized),
|
|
17
|
+
value
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export function parsePersistedFilterRule(options) {
|
|
21
|
+
if (!options.raw) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const stored = parseStoredRule(options.raw);
|
|
25
|
+
if (!stored) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const normalized = normalizeFilterDefinition(options.definition);
|
|
29
|
+
const operator = resolveFilterSupportedOperators(normalized).find(
|
|
30
|
+
(candidate) => candidate === stored.operator
|
|
31
|
+
);
|
|
32
|
+
const value = createTableFilterValueCodec(normalized).parse(stored.value);
|
|
33
|
+
return operator && value !== void 0 ? { key: options.definition.key, operator, value } : null;
|
|
34
|
+
}
|
|
35
|
+
function parseStoredRule(raw) {
|
|
36
|
+
try {
|
|
37
|
+
const stored = JSON.parse(raw);
|
|
38
|
+
return isObject(stored) && isString(stored.operator) && isString(stored.value) ? { operator: stored.operator, value: stored.value } : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|