nuxt-ui-tools 1.1.0 → 1.2.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.
Files changed (44) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/dashboard/components/block/dashboard-skeleton.vue +6 -3
  3. package/dist/runtime/dashboard/components/dashboard-feed.vue +7 -6
  4. package/dist/runtime/dashboard/composables/use-dashboard-remote-options.js +7 -7
  5. package/dist/runtime/dashboard/utils/options.d.ts +0 -7
  6. package/dist/runtime/dashboard/utils/options.js +0 -5
  7. package/dist/runtime/i18n/locales/en.js +3 -1
  8. package/dist/runtime/i18n/locales/fr.js +3 -1
  9. package/dist/runtime/i18n/types.d.ts +3 -0
  10. package/dist/runtime/shared/styles/scrollbars.css +1 -1
  11. package/dist/runtime/shared/utils/remote-options.d.ts +10 -0
  12. package/dist/runtime/shared/utils/remote-options.js +6 -0
  13. package/dist/runtime/table/components/filters/shared/filter-option-list-end.d.vue.ts +11 -0
  14. package/dist/runtime/table/components/filters/shared/filter-option-list-end.vue +76 -0
  15. package/dist/runtime/table/components/filters/shared/filter-option-list-end.vue.d.ts +11 -0
  16. package/dist/runtime/table/components/filters/shared/filter-option-loading-list.d.vue.ts +3 -0
  17. package/dist/runtime/table/components/filters/shared/filter-option-loading-list.vue +2 -1
  18. package/dist/runtime/table/components/filters/shared/filter-option-loading-list.vue.d.ts +3 -0
  19. package/dist/runtime/table/components/filters/shared/filter-option-multiple-list.vue +1 -1
  20. package/dist/runtime/table/components/filters/shared/filter-option-picker-content.vue +13 -0
  21. package/dist/runtime/table/components/filters/shared/filter-option-single-list.d.vue.ts +2 -0
  22. package/dist/runtime/table/components/filters/shared/filter-option-single-list.vue +1 -1
  23. package/dist/runtime/table/components/filters/shared/filter-option-single-list.vue.d.ts +2 -0
  24. package/dist/runtime/table/components/filters/shared/filter-option-tree-content.d.vue.ts +3 -0
  25. package/dist/runtime/table/components/filters/shared/filter-option-tree-content.vue +2 -2
  26. package/dist/runtime/table/components/filters/shared/filter-option-tree-content.vue.d.ts +3 -0
  27. package/dist/runtime/table/components/filters/shared/filter-searchable-panel.vue +1 -0
  28. package/dist/runtime/table/composables/use-option-filter-editor-state.d.ts +13 -1
  29. package/dist/runtime/table/composables/use-option-filter-editor-state.js +6 -2
  30. package/dist/runtime/table/composables/use-table-filter-options.d.ts +11 -1
  31. package/dist/runtime/table/composables/use-table-filter-options.js +128 -17
  32. package/dist/runtime/table/composables/use-table-filter-selected-options.d.ts +20 -0
  33. package/dist/runtime/table/composables/use-table-filter-selected-options.js +99 -0
  34. package/dist/runtime/table/composables/use-table-filters.d.ts +5 -0
  35. package/dist/runtime/table/composables/use-table-filters.js +20 -1
  36. package/dist/runtime/table/composables/use-table-internals.d.ts +15 -0
  37. package/dist/runtime/table/composables/use-table-remote-filter-options.d.ts +42 -0
  38. package/dist/runtime/table/composables/use-table-remote-filter-options.js +102 -0
  39. package/dist/runtime/table/types/filters.d.ts +42 -0
  40. package/dist/runtime/table/types/source.d.ts +5 -0
  41. package/dist/runtime/table/utils/filters/options.d.ts +2 -0
  42. package/dist/runtime/table/utils/filters/options.js +1 -1
  43. package/dist/runtime/table/utils/prefetch.js +21 -1
  44. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- import { useQuery } from "@tanstack/vue-query";
2
- import { computed, unref } from "vue";
1
+ import { useQueries, useQuery } from "@tanstack/vue-query";
2
+ import { computed, unref, watch } from "vue";
3
3
  import {
4
4
  isBoolean,
5
5
  isFunction,
@@ -17,6 +17,7 @@ import {
17
17
  resolveOptionFilterUi,
18
18
  resolveTableFacetMode
19
19
  } from "../utils/index.js";
20
+ import { useTableRemoteFilterOptions } from "./use-table-remote-filter-options.js";
20
21
  export function useTableFilterOptions(options) {
21
22
  const isActive = computed(() => options.active?.value ?? true);
22
23
  const isReady = computed(() => options.ready?.value ?? true);
@@ -52,29 +53,36 @@ export function useTableFilterOptions(options) {
52
53
  () => shouldDeriveCounts.value && isActive.value && isReady.value
53
54
  );
54
55
  const isRemoteTable = computed(() => options.schema.value.source.mode === "remote");
56
+ const remoteConfig = computed(() => optionDefinition.value?.source?.remote);
57
+ const hasRemotePages = computed(() => Boolean(remoteConfig.value));
55
58
  const hasRemoteOptionQuery = computed(
56
- () => options.definition.kind === "option" && isFunction(options.definition.source?.query)
59
+ () => !hasRemotePages.value && options.definition.kind === "option" && isFunction(options.definition.source?.query)
57
60
  );
61
+ const hasServerOptions = computed(() => hasRemotePages.value || hasRemoteOptionQuery.value);
58
62
  const facetSpec = computed(() => options.definition.source?.facet);
59
63
  const facetConfig = computed(
60
64
  () => isFacetConfig(facetSpec.value) ? facetSpec.value : null
61
65
  );
62
66
  const hasPerFilterFacetQuery = computed(() => isFunction(facetConfig.value?.query));
63
67
  const usesFacetCounts = computed(
64
- () => Boolean(facetSpec.value) && (options.schema.value.source.mode === "client" || hasPerFilterFacetQuery.value || isRemoteTable.value && Boolean(remoteSource.value?.facets))
68
+ () => Boolean(facetSpec.value) && (options.schema.value.source.mode === "client" || hasPerFilterFacetQuery.value || // The main request cannot count options it never lists: paged options need `facet.query`.
69
+ isRemoteTable.value && Boolean(remoteSource.value?.facets) && !hasRemotePages.value)
70
+ );
71
+ const countsPagesRemotely = computed(
72
+ () => hasRemotePages.value && isRemoteTable.value && hasPerFilterFacetQuery.value
65
73
  );
66
74
  const resolvedTreeSearchMode = computed(() => {
67
75
  if (optionUi.value?.presentation !== "tree") {
68
- return hasRemoteOptionQuery.value ? "remote" : "local";
76
+ return hasServerOptions.value ? "remote" : "local";
69
77
  }
70
78
  const configured = optionUi.value.tree.searchMode;
71
79
  if (configured === "local" || configured === "remote") {
72
80
  return configured;
73
81
  }
74
- return hasRemoteOptionQuery.value ? "remote" : "local";
82
+ return hasServerOptions.value ? "remote" : "local";
75
83
  });
76
84
  const querySearch = computed(() => {
77
- if (!hasRemoteOptionQuery.value) {
85
+ if (!hasServerOptions.value) {
78
86
  return;
79
87
  }
80
88
  if (resolvedTreeSearchMode.value === "local") {
@@ -97,6 +105,30 @@ export function useTableFilterOptions(options) {
97
105
  key: options.definition.key
98
106
  })
99
107
  );
108
+ const remotePages = useTableRemoteFilterOptions({
109
+ config: remoteConfig,
110
+ enabled: computed(() => isActive.value && isReady.value),
111
+ search: computed(() => querySearch.value ?? "")
112
+ });
113
+ watch(
114
+ [remotePages.entries, selectedValues],
115
+ ([entries, values]) => {
116
+ if (!hasRemotePages.value || !values.length) return;
117
+ const picked = new Set(values.map(String));
118
+ options.filters.selectedOptions.remember(
119
+ options.definition.key,
120
+ flattenRawOptionEntries(entries).filter((entry) => picked.has(String(entry.value)))
121
+ );
122
+ },
123
+ { immediate: true }
124
+ );
125
+ const remotePageEntries = computed(() => {
126
+ const listed = new Set(
127
+ flattenRawOptionEntries(remotePages.entries.value).map((entry) => String(entry.value))
128
+ );
129
+ const selected = options.filters.selectedOptions.getSelectedOptions(options.definition.key).filter((entry) => !listed.has(String(entry.value)));
130
+ return [...selected, ...remotePages.entries.value];
131
+ });
100
132
  const optionQuery = useQuery(
101
133
  computed(() => {
102
134
  const activeDefinition = optionDefinition.value;
@@ -126,7 +158,7 @@ export function useTableFilterOptions(options) {
126
158
  );
127
159
  const perFilterFacetQuery = useQuery(
128
160
  computed(() => {
129
- if (!isRemoteTable.value || !usesFacetCounts.value || !facetConfig.value?.query) {
161
+ if (!isRemoteTable.value || !usesFacetCounts.value || !facetConfig.value?.query || countsPagesRemotely.value) {
130
162
  return {
131
163
  enabled: false,
132
164
  queryFn: async () => ({ facets: [] }),
@@ -168,10 +200,62 @@ export function useTableFilterOptions(options) {
168
200
  }
169
201
  return result?.options ? [...result.options] : [];
170
202
  });
203
+ const pageFacetRequests = computed(() => {
204
+ if (!countsPagesRemotely.value || !shouldResolveCounts.value) return [];
205
+ const paged = remotePages.pageValues.value;
206
+ const listed = new Set(paged.flat().map(String));
207
+ const outside = options.filters.selectedOptions.getSelectedOptions(options.definition.key).map((entry) => entry.value).filter((value) => !listed.has(String(value)));
208
+ return [outside, ...paged].filter((values) => values.length > 0);
209
+ });
210
+ const pageFacetQueries = useQueries({
211
+ queries: computed(() => {
212
+ const config = facetConfig.value;
213
+ const query = config?.query;
214
+ if (!query) return [];
215
+ return pageFacetRequests.value.map((values) => {
216
+ const definition = unref(
217
+ query({
218
+ facets: [{ key: options.definition.key, mode: resolveTableFacetMode(config), values }],
219
+ table: options.queryContent.facetsBaseContext.value
220
+ })
221
+ );
222
+ return {
223
+ // The table's own cache entry: it keeps the requested values next to their counts.
224
+ queryFn: async (context) => {
225
+ if (!definition.queryFn) {
226
+ throw new Error("facet.query must return a query definition with a queryFn.");
227
+ }
228
+ const result = await definition.queryFn({
229
+ ...context,
230
+ direction: "forward",
231
+ pageParam: null,
232
+ queryKey: definition.queryKey
233
+ });
234
+ return { result, values };
235
+ },
236
+ queryKey: ["table-filter-page-facets", ...definition.queryKey],
237
+ refetchOnWindowFocus: QUERY_DEFAULTS.refetchOnWindowFocus,
238
+ staleTime: QUERY_DEFAULTS.staleTime.filterOptions
239
+ };
240
+ });
241
+ })
242
+ });
243
+ const pageFacetCounts = computed(
244
+ () => pageFacetQueries.value.flatMap((query) => {
245
+ if (!query.data) return [];
246
+ const counted = new Map(
247
+ query.data.result.facets.filter((facet) => facet.key === options.definition.key).flatMap((facet) => facet.options).map((option) => [String(option.value), option.count])
248
+ );
249
+ return query.data.values.map((value) => ({ count: counted.get(String(value)) ?? 0, value }));
250
+ })
251
+ );
171
252
  const facetCounts = computed(() => {
172
253
  if (!usesFacetCounts.value) {
173
254
  return [];
174
255
  }
256
+ if (countsPagesRemotely.value) {
257
+ return pageFacetCounts.value;
258
+ }
175
259
  const facets = isRemoteTable.value && hasPerFilterFacetQuery.value ? unref(perFilterFacetQuery.data)?.facets ?? [] : options.queryContent.facets.value.facets;
176
260
  return facets.find((facet) => facet.key === options.definition.key)?.options ?? [];
177
261
  });
@@ -185,8 +269,10 @@ export function useTableFilterOptions(options) {
185
269
  definition: options.definition,
186
270
  deriveCounts: false,
187
271
  facetCounts: resolvedFacetCounts.value,
188
- missingCountFallback: shouldResolveCounts.value && usesFacetCounts.value ? 0 : void 0,
189
- options: hasRemoteOptionQuery.value ? remoteEntries.value : staticEntries.value,
272
+ // Paged options without counts yet stay unknown (a placeholder) until their page is counted.
273
+ missingCountFallback: shouldResolveCounts.value && usesFacetCounts.value && !countsPagesRemotely.value ? 0 : void 0,
274
+ options: hasRemotePages.value ? remotePageEntries.value : hasRemoteOptionQuery.value ? remoteEntries.value : staticEntries.value,
275
+ authoritative: hasRemotePages.value,
190
276
  rows: [],
191
277
  selectedValues: selectedValues.value
192
278
  })
@@ -228,7 +314,7 @@ export function useTableFilterOptions(options) {
228
314
  (entry) => !isNullish(entry.value)
229
315
  );
230
316
  }
231
- if (!normalizedSearch.value.length || hasRemoteOptionQuery.value) {
317
+ if (!normalizedSearch.value.length || hasServerOptions.value) {
232
318
  return selectableSourceEntries.value;
233
319
  }
234
320
  return selectableSourceEntries.value.filter(
@@ -236,28 +322,53 @@ export function useTableFilterOptions(options) {
236
322
  );
237
323
  });
238
324
  const isInitialLoading = computed(
239
- () => optionQuery.isLoading.value && !optionQuery.isPlaceholderData.value
325
+ () => hasRemotePages.value ? remotePages.isLoading.value : optionQuery.isLoading.value && !optionQuery.isPlaceholderData.value
326
+ );
327
+ const isStaleLoading = computed(
328
+ () => hasRemotePages.value ? remotePages.isRefreshing.value : !isInitialLoading.value && optionQuery.isFetching.value
240
329
  );
241
- const isStaleLoading = computed(() => !isInitialLoading.value && optionQuery.isFetching.value);
242
330
  const isCountLoading = computed(
243
- () => isRemoteTable.value && shouldResolveCounts.value && perFilterFacetQuery.isFetching.value
331
+ () => isRemoteTable.value && shouldResolveCounts.value && !countsPagesRemotely.value && perFilterFacetQuery.isFetching.value
244
332
  );
245
333
  return {
246
- error: computed(() => optionQuery.error.value ?? perFilterFacetQuery.error.value),
334
+ error: computed(
335
+ () => (hasRemotePages.value ? remotePages.error.value : optionQuery.error.value) ?? perFilterFacetQuery.error.value
336
+ ),
247
337
  facetCounts,
248
338
  filteredEntries,
249
339
  filteredTreeEntries: computed(() => filteredTreeState.value.entries),
250
340
  isCountLoading,
251
- isError: computed(() => optionQuery.isError.value || perFilterFacetQuery.isError.value),
341
+ isError: computed(
342
+ () => (hasRemotePages.value ? remotePages.failed.value : optionQuery.isError.value) || perFilterFacetQuery.isError.value
343
+ ),
252
344
  isLoading: isInitialLoading,
253
345
  isStaleLoading,
254
- refresh: () => Promise.all([optionQuery.refetch(), perFilterFacetQuery.refetch()]),
346
+ refresh: () => Promise.all([
347
+ hasRemotePages.value ? remotePages.retry() : optionQuery.refetch(),
348
+ perFilterFacetQuery.refetch()
349
+ ]),
350
+ /** Paging of `source.remote`: the editor list loads the next page ahead of the scroll. */
351
+ remote: {
352
+ enabled: hasRemotePages,
353
+ failed: remotePages.failed,
354
+ hasMore: remotePages.hasMore,
355
+ loadMore: remotePages.loadMore,
356
+ loadingMore: remotePages.loadingMore,
357
+ prefetchDistance: remotePages.prefetchDistance,
358
+ retry: remotePages.retry
359
+ },
255
360
  searchExpandedIds: computed(() => filteredTreeState.value.expandedIds),
256
361
  showCounts: computed(() => shouldDeriveCounts.value && usesFacetCounts.value),
257
362
  sourceEntries: selectableSourceEntries,
258
363
  sourceTreeEntries: resolvedSourceTreeEntries
259
364
  };
260
365
  }
366
+ function flattenRawOptionEntries(entries) {
367
+ return entries.flatMap((entry) => [
368
+ ...isNullish(entry.value) ? [] : [entry],
369
+ ...flattenRawOptionEntries(entry.children ?? [])
370
+ ]);
371
+ }
261
372
  function isFacetConfig(value) {
262
373
  return isObject(value);
263
374
  }
@@ -0,0 +1,20 @@
1
+ import type { ComputedRef } from 'vue';
2
+ import type { TableFilterOptionEntry, TableFilterOptionValueEntry, TableQueryStateFilterRule, TableUiFilterDefinition } from '../types/index.js';
3
+ export interface UseTableFilterSelectedOptionsParams {
4
+ definitions: ComputedRef<TableUiFilterDefinition[]>;
5
+ /** Committed filter rules. */
6
+ rules: ComputedRef<TableQueryStateFilterRule[]>;
7
+ }
8
+ /**
9
+ * Options of the committed values of remote option filters, so tags, panel chips, and the mobile
10
+ * sheet show labels instead of raw values. Options the editors loaded are recorded as values get
11
+ * picked; the rest (for example values restored from the URL) resolve through each filter's
12
+ * `source.remote.resolveSelected`, under the consumer's query key so a prefetched result is reused.
13
+ */
14
+ export declare function useTableFilterSelectedOptions(params: UseTableFilterSelectedOptionsParams): {
15
+ getSelectedOptions: (key: string) => TableFilterOptionValueEntry[];
16
+ /** Options of committed values are resolving for this filter. */
17
+ isResolving: (key: string) => boolean;
18
+ remember: (key: string, options: readonly TableFilterOptionEntry[]) => void;
19
+ };
20
+ export type TableFilterSelectedOptions = ReturnType<typeof useTableFilterSelectedOptions>;
@@ -0,0 +1,99 @@
1
+ import { useQuery, useQueryClient } from "@tanstack/vue-query";
2
+ import { computed, shallowRef, watch } from "vue";
3
+ import { isBoolean, isNullish, isNumber, isString } from "../../shared/utils/predicate.js";
4
+ import { QUERY_DEFAULTS } from "../constants/query-state.js";
5
+ export function useTableFilterSelectedOptions(params) {
6
+ const queryClient = useQueryClient();
7
+ const known = shallowRef(/* @__PURE__ */ new Map());
8
+ const remoteDefinitions = computed(
9
+ () => params.definitions.value.flatMap((definition) => {
10
+ const remote = definition.kind === "option" ? definition.source?.remote : void 0;
11
+ return remote ? [{ key: definition.key, remote }] : [];
12
+ })
13
+ );
14
+ function selectedValues(key) {
15
+ const rule = params.rules.value.find((entry) => entry.key === key);
16
+ const values = Array.isArray(rule?.value) ? rule.value : isNullish(rule?.value) ? [] : [rule.value];
17
+ return values.filter(isPrimitiveValue);
18
+ }
19
+ const requests = computed(
20
+ () => remoteDefinitions.value.flatMap(({ key, remote }) => {
21
+ const cached = known.value.get(key);
22
+ const missing = selectedValues(key).filter((value) => !cached?.has(String(value)));
23
+ const query = missing.length ? resolveSelectedQuery(remote, missing) : void 0;
24
+ return query ? [{ key, query }] : [];
25
+ })
26
+ );
27
+ const hydration = useQuery(
28
+ computed(() => {
29
+ const snapshot = requests.value;
30
+ return {
31
+ enabled: snapshot.length > 0,
32
+ queryFn: () => Promise.all(
33
+ snapshot.map(async ({ key, query }) => ({
34
+ key,
35
+ options: await queryClient.fetchQuery({
36
+ queryFn: (context) => {
37
+ if (!query.queryFn) {
38
+ throw new Error(
39
+ "resolveSelected must return a query definition with a queryFn."
40
+ );
41
+ }
42
+ return query.queryFn({ ...context, direction: "forward", pageParam: null });
43
+ },
44
+ queryKey: query.queryKey,
45
+ staleTime: QUERY_DEFAULTS.staleTime.filterOptions
46
+ })
47
+ }))
48
+ ),
49
+ queryKey: [
50
+ "table-filter-selected-options",
51
+ snapshot.map(({ key, query }) => [key, query.queryKey])
52
+ ],
53
+ refetchOnWindowFocus: QUERY_DEFAULTS.refetchOnWindowFocus,
54
+ staleTime: QUERY_DEFAULTS.staleTime.filterOptions
55
+ };
56
+ })
57
+ );
58
+ watch(
59
+ () => hydration.data.value,
60
+ (results) => {
61
+ for (const result of results ?? []) remember(result.key, result.options);
62
+ }
63
+ );
64
+ function remember(key, options) {
65
+ const entries = flattenValueEntries(options);
66
+ if (!entries.length) return;
67
+ const current = known.value.get(key);
68
+ if (entries.every((entry) => current?.get(String(entry.value))?.label === entry.label)) return;
69
+ const next = new Map(current);
70
+ for (const entry of entries) next.set(String(entry.value), entry);
71
+ known.value = new Map(known.value).set(key, next);
72
+ }
73
+ function getSelectedOptions(key) {
74
+ const cached = known.value.get(key);
75
+ if (!cached) return [];
76
+ return selectedValues(key).flatMap((value) => {
77
+ const entry = cached.get(String(value));
78
+ return entry ? [entry] : [];
79
+ });
80
+ }
81
+ return {
82
+ getSelectedOptions,
83
+ /** Options of committed values are resolving for this filter. */
84
+ isResolving: (key) => hydration.isFetching.value && requests.value.some((request) => request.key === key),
85
+ remember
86
+ };
87
+ }
88
+ function resolveSelectedQuery(remote, values) {
89
+ return remote.resolveSelected?.({ values });
90
+ }
91
+ function flattenValueEntries(options) {
92
+ return options.flatMap((option) => [
93
+ ...isNullish(option.value) ? [] : [{ ...option, value: option.value }],
94
+ ...flattenValueEntries(option.children ?? [])
95
+ ]);
96
+ }
97
+ function isPrimitiveValue(value) {
98
+ return isString(value) || isNumber(value) || isBoolean(value);
99
+ }
@@ -71,6 +71,11 @@ export declare function useTableFilters(params: UseTableFiltersParams): {
71
71
  }) => void;
72
72
  searchPlaceholder: ComputedRef<string>;
73
73
  searchQuery: import("vue").WritableComputedRef<string, string>;
74
+ selectedOptions: {
75
+ getSelectedOptions: (key: string) => import("../types/index.js").TableFilterOptionValueEntry[];
76
+ isResolving: (key: string) => boolean;
77
+ remember: (key: string, options: readonly TableFilterOptionEntry[]) => void;
78
+ };
74
79
  setFilterOperator: (input: {
75
80
  key: string;
76
81
  operator: TableFilterOperator;
@@ -11,6 +11,7 @@ import {
11
11
  resolveTableFilterDefaultRules,
12
12
  resolveFilterOptionEntries
13
13
  } from "../utils/index.js";
14
+ import { useTableFilterSelectedOptions } from "./use-table-filter-selected-options.js";
14
15
  import { useTableSearch } from "./use-table-search.js";
15
16
  export function useTableFilters(params) {
16
17
  const { t } = useUiToolsLocale();
@@ -34,6 +35,10 @@ export function useTableFilters(params) {
34
35
  })
35
36
  );
36
37
  const hasActiveUiFilters = computed(() => activeUiFilters.value.length > 0);
38
+ const selectedOptions = useTableFilterSelectedOptions({
39
+ definitions,
40
+ rules: effectiveUiFilters
41
+ });
37
42
  function getDefinition(input) {
38
43
  return definitions.value.find(
39
44
  (definition) => definition.key === input.key
@@ -87,7 +92,7 @@ export function useTableFilters(params) {
87
92
  }
88
93
  const preview = buildFilterPreview({
89
94
  definition,
90
- optionEntries: input.entries,
95
+ optionEntries: withSelectedOptions(input.key, input.entries),
91
96
  rule: getFilterState({ key: input.key })
92
97
  });
93
98
  const rule = getFilterState({ key: input.key });
@@ -98,6 +103,19 @@ export function useTableFilters(params) {
98
103
  dirty: !isNullish(getActiveFilterState({ key: input.key }))
99
104
  };
100
105
  }
106
+ function withSelectedOptions(key, entries = []) {
107
+ const listed = new Set(entries.map((entry) => String(entry.value)));
108
+ const missing = selectedOptions.getSelectedOptions(key).filter((entry) => !listed.has(String(entry.value)));
109
+ return missing.length ? [
110
+ ...entries,
111
+ ...missing.map((entry) => ({
112
+ color: entry.color,
113
+ icon: entry.icon,
114
+ label: getFilterLabelText({ label: entry.label }),
115
+ value: entry.value
116
+ }))
117
+ ] : entries;
118
+ }
101
119
  function getFilterOperator(input) {
102
120
  const definition = getDefinition({ key: input.key });
103
121
  const rule = getFilterState({ key: input.key });
@@ -253,6 +271,7 @@ export function useTableFilters(params) {
253
271
  replaceFilters,
254
272
  searchPlaceholder: search.searchPlaceholder,
255
273
  searchQuery: search.searchQuery,
274
+ selectedOptions,
256
275
  setFilterOperator,
257
276
  setOptionFilterValues,
258
277
  setScalarFilterValue,
@@ -191,6 +191,11 @@ declare function createTableInternals<TSchema>(options: {
191
191
  }) => void;
192
192
  searchPlaceholder: import("vue").ComputedRef<string>;
193
193
  searchQuery: import("vue").WritableComputedRef<string, string>;
194
+ selectedOptions: {
195
+ getSelectedOptions: (key: string) => import("../types/index.js").TableFilterOptionValueEntry[];
196
+ isResolving: (key: string) => boolean;
197
+ remember: (key: string, options: readonly import("../types/index.js").TableFilterOptionEntry[]) => void;
198
+ };
194
199
  setFilterOperator: (input: {
195
200
  key: string;
196
201
  operator: import("../types/index.js").TableFilterOperator;
@@ -782,6 +787,11 @@ declare function useProvideTableInternals<TSchema>(options: {
782
787
  }) => void;
783
788
  searchPlaceholder: import("vue").ComputedRef<string>;
784
789
  searchQuery: import("vue").WritableComputedRef<string, string>;
790
+ selectedOptions: {
791
+ getSelectedOptions: (key: string) => import("../types/index.js").TableFilterOptionValueEntry[];
792
+ isResolving: (key: string) => boolean;
793
+ remember: (key: string, options: readonly import("../types/index.js").TableFilterOptionEntry[]) => void;
794
+ };
785
795
  setFilterOperator: (input: {
786
796
  key: string;
787
797
  operator: import("../types/index.js").TableFilterOperator;
@@ -1370,6 +1380,11 @@ declare function useTableInternals(): {
1370
1380
  }) => void;
1371
1381
  searchPlaceholder: import("vue").ComputedRef<string>;
1372
1382
  searchQuery: import("vue").WritableComputedRef<string, string>;
1383
+ selectedOptions: {
1384
+ getSelectedOptions: (key: string) => import("../types/index.js").TableFilterOptionValueEntry[];
1385
+ isResolving: (key: string) => boolean;
1386
+ remember: (key: string, options: readonly import("../types/index.js").TableFilterOptionEntry[]) => void;
1387
+ };
1373
1388
  setFilterOperator: (input: {
1374
1389
  key: string;
1375
1390
  operator: import("../types/index.js").TableFilterOperator;
@@ -0,0 +1,42 @@
1
+ import type { InfiniteData } from '@tanstack/vue-query';
2
+ import type { ComputedRef } from 'vue';
3
+ import type { RemoteOptionsResult } from '../../shared/types/remote-options.js';
4
+ import type { RemoteOptionsPageParam } from '../../shared/utils/remote-options.js';
5
+ import type { TableFilterOptionEntry, TableFilterRemoteOptions } from '../types/index.js';
6
+ type RemotePage = RemoteOptionsResult<TableFilterOptionEntry>;
7
+ /** Defaults of `source.remote` on option filters, matching the form engine's remote options. */
8
+ export declare const TABLE_REMOTE_FILTER_OPTIONS_DEFAULTS: {
9
+ readonly pageSize: 25;
10
+ readonly searchDebounce: 250;
11
+ };
12
+ export interface UseTableRemoteFilterOptionsParams {
13
+ config: ComputedRef<TableFilterRemoteOptions | undefined>;
14
+ /** Raw search input; it is trimmed and debounced before it reaches the source. */
15
+ search: ComputedRef<string>;
16
+ /** Pages load only while an editor shows the list. */
17
+ enabled: ComputedRef<boolean>;
18
+ }
19
+ /**
20
+ * Pages of a remote option filter. Nothing loads until an editor shows the list; each search term
21
+ * keeps its own pages in the TanStack Query cache, and the previous term's pages stay on screen
22
+ * while the next term loads.
23
+ */
24
+ export declare function useTableRemoteFilterOptions(params: UseTableRemoteFilterOptionsParams): {
25
+ entries: ComputedRef<readonly TableFilterOptionEntry[]>;
26
+ error: import("vue").Ref<null, null> | import("vue").Ref<Error, Error>;
27
+ /** More pages exist after the loaded ones. */
28
+ hasMore: ComputedRef<boolean>;
29
+ /** The first page of the current term has not arrived yet. */
30
+ isLoading: ComputedRef<boolean>;
31
+ /** A new term, or a refetch, is loading while earlier options stay on screen. */
32
+ isRefreshing: ComputedRef<boolean>;
33
+ /** The next page is loading at the end of the list. */
34
+ loadingMore: ComputedRef<boolean>;
35
+ /** The last request failed: the next page (or the first) can be retried. */
36
+ failed: ComputedRef<boolean>;
37
+ loadMore: () => void;
38
+ pageValues: ComputedRef<(string | number | boolean)[][]>;
39
+ prefetchDistance: ComputedRef<number | "viewport">;
40
+ retry: () => Promise<import("@tanstack/vue-query").QueryObserverResult<InfiniteData<RemotePage, RemoteOptionsPageParam>, Error>>;
41
+ };
42
+ export {};
@@ -0,0 +1,102 @@
1
+ import { useInfiniteQuery } from "@tanstack/vue-query";
2
+ import { refDebounced } from "@vueuse/core";
3
+ import { computed } from "vue";
4
+ import {
5
+ REMOTE_OPTIONS_FIRST_PAGE,
6
+ resolveRemoteOptionsNextPage
7
+ } from "../../shared/utils/remote-options.js";
8
+ import { QUERY_DEFAULTS } from "../constants/query-state.js";
9
+ export const TABLE_REMOTE_FILTER_OPTIONS_DEFAULTS = {
10
+ pageSize: 25,
11
+ searchDebounce: 250
12
+ };
13
+ export function useTableRemoteFilterOptions(params) {
14
+ const pageSize = computed(
15
+ () => Math.max(
16
+ 1,
17
+ params.config.value?.pagination?.size ?? TABLE_REMOTE_FILTER_OPTIONS_DEFAULTS.pageSize
18
+ )
19
+ );
20
+ const minLength = computed(() => Math.max(0, params.config.value?.search?.minLength ?? 0));
21
+ const search = refDebounced(
22
+ computed(() => params.search.value.trim()),
23
+ () => params.config.value?.search?.debounce ?? TABLE_REMOTE_FILTER_OPTIONS_DEFAULTS.searchDebounce
24
+ );
25
+ const canFetch = computed(
26
+ () => search.value.length === 0 || search.value.length >= minLength.value
27
+ );
28
+ const pages = useInfiniteQuery(
29
+ computed(() => {
30
+ const config = params.config.value;
31
+ const term = search.value;
32
+ const size = pageSize.value;
33
+ const firstPage = config?.load({ page: { ...REMOTE_OPTIONS_FIRST_PAGE, size }, search: term });
34
+ return {
35
+ enabled: Boolean(config) && params.enabled.value && canFetch.value,
36
+ getNextPageParam: (lastPage, allPages) => resolveRemoteOptionsNextPage(lastPage, allPages.length),
37
+ initialPageParam: REMOTE_OPTIONS_FIRST_PAGE,
38
+ // Keeps the previous term's options on screen while the next term loads.
39
+ placeholderData: (previous) => previous,
40
+ async queryFn(queryContext) {
41
+ const definition = config?.load({
42
+ page: { ...queryContext.pageParam, size },
43
+ search: term
44
+ });
45
+ if (!definition?.queryFn) {
46
+ throw new Error("Remote option filters must return a query definition with a queryFn.");
47
+ }
48
+ return definition.queryFn({
49
+ ...queryContext,
50
+ direction: "forward",
51
+ pageParam: null,
52
+ queryKey: definition.queryKey
53
+ });
54
+ },
55
+ queryKey: [
56
+ ...firstPage?.queryKey ?? ["table-filter-remote-options", "disabled"],
57
+ { tableFilterOptionPages: size }
58
+ ],
59
+ refetchOnWindowFocus: QUERY_DEFAULTS.refetchOnWindowFocus,
60
+ staleTime: QUERY_DEFAULTS.staleTime.filterOptions
61
+ };
62
+ })
63
+ );
64
+ const entries = computed(
65
+ () => pages.data.value?.pages.flatMap((page) => page.options) ?? []
66
+ );
67
+ const pageValues = computed(
68
+ () => pages.data.value?.pages.map((page) => collectOptionValues(page.options)) ?? []
69
+ );
70
+ function loadMore() {
71
+ if (!pages.hasNextPage.value || pages.isFetchingNextPage.value || pages.isFetching.value) return;
72
+ void pages.fetchNextPage();
73
+ }
74
+ return {
75
+ entries,
76
+ error: pages.error,
77
+ /** More pages exist after the loaded ones. */
78
+ hasMore: computed(() => pages.hasNextPage.value),
79
+ /** The first page of the current term has not arrived yet. */
80
+ isLoading: computed(() => pages.isLoading.value && !pages.isPlaceholderData.value),
81
+ /** A new term, or a refetch, is loading while earlier options stay on screen. */
82
+ isRefreshing: computed(
83
+ () => pages.isFetching.value && !pages.isFetchingNextPage.value && !pages.isLoading.value
84
+ ),
85
+ /** The next page is loading at the end of the list. */
86
+ loadingMore: computed(() => pages.isFetchingNextPage.value),
87
+ /** The last request failed: the next page (or the first) can be retried. */
88
+ failed: computed(() => pages.isError.value || pages.isFetchNextPageError.value),
89
+ loadMore,
90
+ pageValues,
91
+ prefetchDistance: computed(
92
+ () => params.config.value?.pagination?.prefetchDistance ?? "viewport"
93
+ ),
94
+ retry: () => pages.isFetchNextPageError.value ? pages.fetchNextPage() : pages.refetch()
95
+ };
96
+ }
97
+ function collectOptionValues(options) {
98
+ return options.flatMap((option) => [
99
+ ...option.value === void 0 ? [] : [option.value],
100
+ ...collectOptionValues(option.children ?? [])
101
+ ]);
102
+ }
@@ -1,3 +1,4 @@
1
+ import type { RemoteOptionsPageRequest, RemoteOptionsPagination, RemoteOptionsResult, RemoteOptionsSearch } from '../../shared/types/remote-options.js';
1
2
  import type { TableFilterOperator, TableQueryStateFilterRule, TableQueryStateFilterValue } from './query-state.js';
2
3
  import type { TableFacetExecutionResult, TableFacetRequestDescriptor, TableFacetsContext, TableQueryDefinition } from './source.js';
3
4
  import type { GenericObject, RenderableType, TableFieldPath, TableKnownFieldPath, TableTextValue } from './utils.js';
@@ -538,6 +539,41 @@ export interface TableFilterOptionQueryContext {
538
539
  cursor?: string | null;
539
540
  }
540
541
  export type TableFilterOptionQueryResult<TValue = TableFilterPrimitiveValue> = TableFilterOptionQueryResultForPresentation<TValue, TableOptionFilterPresentation>;
542
+ /** Page requested from a remote option filter source. */
543
+ export interface TableFilterRemoteOptionsRequest {
544
+ /** Current debounced search term. Empty when the editor lists unfiltered options. */
545
+ search: string;
546
+ /** Requested page: `index` (1-based) for page pagination, `cursor` for cursor pagination. */
547
+ page: RemoteOptionsPageRequest;
548
+ }
549
+ /** Selected values a remote option filter needs options for. */
550
+ export interface TableFilterRemoteSelectedRequest<TValue = TableFilterPrimitiveValue> {
551
+ /** Committed filter values that the options loaded so far do not contain. */
552
+ values: readonly TValue[];
553
+ }
554
+ /**
555
+ * Remote option source loaded page by page, following the form engine's remote options:
556
+ * server-side search, page or cursor pagination loaded ahead of the scroll, and options for
557
+ * selected values the loaded pages do not contain (for example values restored from the URL).
558
+ */
559
+ export interface TableFilterRemoteOptions<TValue = TableFilterPrimitiveValue, TPresentation extends TableOptionFilterPresentation = TableOptionFilterPresentation> {
560
+ /** Query definition of one page of options for the current search term. */
561
+ load: (request: TableFilterRemoteOptionsRequest) => TableQueryDefinition<RemoteOptionsResult<TableOptionEntryForPresentation<TValue, TPresentation>>>;
562
+ /**
563
+ * Query definition resolving the options of selected values the loaded pages do not contain, so
564
+ * tags and editors show their labels. Without it, such values show as their raw value.
565
+ */
566
+ resolveSelected?: {
567
+ bivarianceHack(request: TableFilterRemoteSelectedRequest<TValue>): TableQueryDefinition<readonly TableOptionEntryForPresentation<TValue, TPresentation>[]>;
568
+ }['bivarianceHack'];
569
+ /**
570
+ * Page size, and how far before the list end the next page loads. Defaults to
571
+ * `{ type: 'page', size: 25 }` with the next page requested three viewport heights ahead.
572
+ */
573
+ pagination?: RemoteOptionsPagination;
574
+ /** Search debounce (default 250 ms) and minimum term length (default 0). */
575
+ search?: RemoteOptionsSearch;
576
+ }
541
577
  export type TableFilterFacetMode = boolean | 'exclude-self' | 'include-self';
542
578
  export interface TableFilterFacetQueryContext<TRow extends GenericObject = GenericObject, TContext extends GenericObject = GenericObject, TKey extends string = TableKnownFieldPath<TRow>> {
543
579
  table: Omit<TableFacetsContext<TRow, TContext, TKey>, 'facets'>;
@@ -596,7 +632,13 @@ export interface TableOptionFilterDefinition<TRow extends GenericObject = Generi
596
632
  kind: 'option';
597
633
  source?: {
598
634
  options?: readonly TableOptionEntryForPresentation<TValue, TPresentation>[];
635
+ /** Loads the whole option list in one query. For large lists, prefer `remote`. */
599
636
  query?: (context: TableFilterOptionQueryContext) => TableQueryDefinition<TableOptionEntryForPresentation<TValue, TPresentation>[] | TableFilterOptionQueryResultForPresentation<TValue, TPresentation>>;
637
+ /**
638
+ * Loads options page by page with server-side search, as the editor scrolls. Takes precedence
639
+ * over `options` and `query`.
640
+ */
641
+ remote?: TableFilterRemoteOptions<TValue, TPresentation>;
600
642
  facet?: TableFilterFacetSpec<TRow, TContext, TKey>;
601
643
  sort?: 'alpha' | 'count';
602
644
  };