@salesforce/ui-bundle-template-app-react-template-b2x 11.18.0 → 11.20.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 (77) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/uiBundles/reactexternalapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/{features/object-search/components → components}/ObjectBreadcrumb.tsx +7 -2
  4. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/api/distinctValuesService.ts +84 -0
  5. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/api/searchService.ts +71 -0
  6. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/MergedSearchResults.tsx +263 -0
  7. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/Search.tsx +185 -0
  8. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/SearchResults.tsx +77 -0
  9. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/SourceSection.tsx +166 -0
  10. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/PaginationControls.tsx +185 -0
  11. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/ScopeSelector.tsx +55 -0
  12. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/SearchBar.tsx +55 -0
  13. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/SortControl.tsx +62 -0
  14. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/ActiveFilters.tsx +61 -0
  15. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/DefaultFilterPanel.tsx +125 -0
  16. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/{object-search/components → search/components/filters}/FilterContext.tsx +7 -20
  17. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/BooleanFilter.tsx +50 -0
  18. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/DateRangeFilter.tsx +160 -0
  19. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/FilterFieldWrapper.tsx +26 -0
  20. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +88 -0
  21. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/NumericRangeFilter.tsx +78 -0
  22. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/SelectFilter.tsx +46 -0
  23. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/TextFilter.tsx +52 -0
  24. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/results/DefaultResultRow.tsx +109 -0
  25. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/config.json +83 -0
  26. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/{object-search → search}/hooks/useAsyncData.ts +3 -10
  27. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/hooks/useDistinctValues.ts +59 -0
  28. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/hooks/useSearch.ts +703 -0
  29. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/index.ts +90 -0
  30. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/loadConfig.ts +14 -0
  31. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/queryBuilder.ts +156 -0
  32. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/types.ts +307 -0
  33. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/debounce.ts +17 -0
  34. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/fieldUtils.ts +14 -0
  35. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/filterUtils.ts +285 -0
  36. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/sortUtils.ts +34 -0
  37. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/AccountObjectDetailPage.tsx +49 -16
  38. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/AccountSearch.tsx +21 -293
  39. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/Home.tsx +22 -7
  40. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/routes.tsx +1 -1
  41. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/{features/object-search/utils/fieldUtils.ts → utils/accountFields.ts} +9 -0
  42. package/dist/force-app/main/default/uiBundles/reactexternalapp/tsconfig.tsbuildinfo +1 -1
  43. package/dist/package-lock.json +2 -2
  44. package/dist/package.json +1 -1
  45. package/package.json +1 -1
  46. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/accountSearchService.ts +0 -46
  47. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/query/distinctAccountIndustries.graphql +0 -19
  48. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/query/distinctAccountTypes.graphql +0 -19
  49. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/query/searchAccounts.graphql +0 -51
  50. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/graphql-operations-types.ts +0 -11260
  51. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/accountSearchService.ts +0 -46
  52. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/distinctAccountIndustries.graphql +0 -19
  53. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/distinctAccountTypes.graphql +0 -19
  54. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/getAccountDetail.graphql +0 -121
  55. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/searchAccounts.graphql +0 -51
  56. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/AccountObjectDetailPage.tsx +0 -355
  57. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +0 -304
  58. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/Home.tsx +0 -34
  59. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/api/objectSearchService.ts +0 -82
  60. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/ActiveFilters.tsx +0 -89
  61. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/PaginationControls.tsx +0 -109
  62. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/SearchBar.tsx +0 -41
  63. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/SortControl.tsx +0 -143
  64. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/BooleanFilter.tsx +0 -78
  65. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/DateFilter.tsx +0 -128
  66. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/DateRangeFilter.tsx +0 -70
  67. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/FilterFieldWrapper.tsx +0 -43
  68. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/MultiSelectFilter.tsx +0 -105
  69. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +0 -171
  70. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/SearchFilter.tsx +0 -52
  71. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/SelectFilter.tsx +0 -97
  72. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/TextFilter.tsx +0 -93
  73. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/hooks/useDebouncedCallback.ts +0 -34
  74. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/hooks/useObjectSearchParams.ts +0 -254
  75. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/utils/debounce.ts +0 -25
  76. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/utils/filterUtils.ts +0 -404
  77. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/utils/sortUtils.ts +0 -38
@@ -1,171 +0,0 @@
1
- import { useId, useState } from "react";
2
- import { Input } from "../../../../components/ui/input";
3
-
4
- import { useFilterField } from "../FilterContext";
5
- import { useDebouncedCallback } from "../../hooks/useDebouncedCallback";
6
- import { FilterFieldWrapper } from "./FilterFieldWrapper";
7
- import type { ActiveFilterValue } from "../../utils/filterUtils";
8
-
9
- interface NumericRangeFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
10
- field: string;
11
- label: string;
12
- helpText?: string;
13
- min?: number;
14
- max?: number;
15
- }
16
-
17
- export function NumericRangeFilter({
18
- field,
19
- label,
20
- helpText,
21
- min,
22
- max,
23
- className,
24
- ...props
25
- }: NumericRangeFilterProps) {
26
- const { value, onChange } = useFilterField(field);
27
- return (
28
- <NumericRangeFilterInputs
29
- field={field}
30
- label={label}
31
- helpText={helpText}
32
- value={value}
33
- onChange={onChange}
34
- min={min}
35
- max={max}
36
- className={className}
37
- {...props}
38
- />
39
- );
40
- }
41
-
42
- interface NumericRangeFilterInputsProps extends Omit<React.ComponentProps<"div">, "onChange"> {
43
- field: string;
44
- label: string;
45
- helpText?: string;
46
- value: ActiveFilterValue | undefined;
47
- onChange: (value: ActiveFilterValue | undefined) => void;
48
- min?: number;
49
- max?: number;
50
- minInputProps?: React.ComponentProps<typeof Input>;
51
- maxInputProps?: React.ComponentProps<typeof Input>;
52
- }
53
-
54
- export function NumericRangeFilterInputs({
55
- field,
56
- label,
57
- helpText,
58
- value,
59
- onChange,
60
- min: boundMin,
61
- max: boundMax,
62
- className,
63
- ...props
64
- }: NumericRangeFilterInputsProps) {
65
- const errorId = useId();
66
- const [localMin, setLocalMin] = useState(value?.min ?? "");
67
- const [localMax, setLocalMax] = useState(value?.max ?? "");
68
-
69
- const externalMin = value?.min ?? "";
70
- const externalMax = value?.max ?? "";
71
- const [prevExternalMin, setPrevExternalMin] = useState(externalMin);
72
- const [prevExternalMax, setPrevExternalMax] = useState(externalMax);
73
- if (prevExternalMin !== externalMin) {
74
- setPrevExternalMin(externalMin);
75
- setLocalMin(externalMin);
76
- }
77
- if (prevExternalMax !== externalMax) {
78
- setPrevExternalMax(externalMax);
79
- setLocalMax(externalMax);
80
- }
81
-
82
- const isOutOfBounds = (v: string) => {
83
- if (v === "") return false;
84
- const n = Number(v);
85
- return (boundMin != null && n < boundMin) || (boundMax != null && n > boundMax);
86
- };
87
- const minOutOfBounds = isOutOfBounds(localMin);
88
- const maxOutOfBounds = isOutOfBounds(localMax);
89
- const isRangeInverted = localMin !== "" && localMax !== "" && Number(localMin) > Number(localMax);
90
- const hasError = minOutOfBounds || maxOutOfBounds || isRangeInverted;
91
-
92
- const debouncedOnChange = useDebouncedCallback((min: string, max: string) => {
93
- if (!min && !max) {
94
- onChange(undefined);
95
- return;
96
- }
97
- const minNum = min !== "" ? Number(min) : null;
98
- const maxNum = max !== "" ? Number(max) : null;
99
- if (minNum != null && maxNum != null && minNum > maxNum) return;
100
- if (
101
- minNum != null &&
102
- ((boundMin != null && minNum < boundMin) || (boundMax != null && minNum > boundMax))
103
- )
104
- return;
105
- if (
106
- maxNum != null &&
107
- ((boundMin != null && maxNum < boundMin) || (boundMax != null && maxNum > boundMax))
108
- )
109
- return;
110
- onChange({ field, label, type: "numeric" as const, min, max });
111
- });
112
-
113
- const boundsLabel =
114
- boundMin != null && boundMax != null
115
- ? `${boundMin}–${boundMax}`
116
- : boundMin != null
117
- ? `${boundMin} or more`
118
- : boundMax != null
119
- ? `${boundMax} or less`
120
- : null;
121
-
122
- const errorMessage = isRangeInverted
123
- ? "Min must not exceed max"
124
- : (minOutOfBounds || maxOutOfBounds) && boundsLabel
125
- ? `Value must be between ${boundsLabel}`
126
- : undefined;
127
-
128
- return (
129
- <FilterFieldWrapper
130
- label={label}
131
- helpText={helpText}
132
- error={errorMessage}
133
- errorId={errorId}
134
- className={className}
135
- {...props}
136
- >
137
- <div className="flex gap-2">
138
- <Input
139
- type="number"
140
- placeholder="Min"
141
- value={localMin}
142
- min={boundMin}
143
- max={boundMax}
144
- onChange={(e) => {
145
- const v = e.target.value;
146
- setLocalMin(v);
147
- debouncedOnChange(v, localMax);
148
- }}
149
- aria-label={`${label} minimum`}
150
- aria-invalid={hasError || undefined}
151
- aria-describedby={hasError ? errorId : undefined}
152
- />
153
- <Input
154
- type="number"
155
- placeholder="Max"
156
- value={localMax}
157
- min={boundMin}
158
- max={boundMax}
159
- onChange={(e) => {
160
- const v = e.target.value;
161
- setLocalMax(v);
162
- debouncedOnChange(localMin, v);
163
- }}
164
- aria-label={`${label} maximum`}
165
- aria-invalid={hasError || undefined}
166
- aria-describedby={hasError ? errorId : undefined}
167
- />
168
- </div>
169
- </FilterFieldWrapper>
170
- );
171
- }
@@ -1,52 +0,0 @@
1
- import { useState } from "react";
2
-
3
- import { SearchBar } from "../SearchBar";
4
- import { useFilterField } from "../FilterContext";
5
- import { FilterFieldWrapper } from "./FilterFieldWrapper";
6
- import { useDebouncedCallback } from "../../hooks/useDebouncedCallback";
7
-
8
- interface SearchFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
9
- field: string;
10
- label: string;
11
- placeholder?: string;
12
- }
13
-
14
- export function SearchFilter({
15
- field,
16
- label,
17
- placeholder,
18
- className,
19
- ...props
20
- }: SearchFilterProps) {
21
- const { value, onChange } = useFilterField(field);
22
- const [localValue, setLocalValue] = useState(value?.value ?? "");
23
-
24
- const externalValue = value?.value ?? "";
25
- const [prevExternalValue, setPrevExternalValue] = useState(externalValue);
26
- if (prevExternalValue !== externalValue) {
27
- setPrevExternalValue(externalValue);
28
- setLocalValue(externalValue);
29
- }
30
-
31
- const debouncedOnChange = useDebouncedCallback((v: string) => {
32
- if (v) {
33
- onChange({ field, label, type: "search", value: v });
34
- } else {
35
- onChange(undefined);
36
- }
37
- });
38
-
39
- return (
40
- <FilterFieldWrapper label={label} htmlFor={`filter-${field}`} className={className} {...props}>
41
- <SearchBar
42
- value={localValue}
43
- handleChange={(v) => {
44
- setLocalValue(v);
45
- debouncedOnChange(v);
46
- }}
47
- placeholder={placeholder}
48
- inputProps={{ id: `filter-${field}` }}
49
- />
50
- </FilterFieldWrapper>
51
- );
52
- }
@@ -1,97 +0,0 @@
1
- import {
2
- Select,
3
- SelectContent,
4
- SelectItem,
5
- SelectTrigger,
6
- SelectValue,
7
- } from "../../../../components/ui/select";
8
- import { cn } from "../../../../lib/utils";
9
- import { useFilterField } from "../FilterContext";
10
- import { FilterFieldWrapper } from "./FilterFieldWrapper";
11
- import type { ActiveFilterValue } from "../../utils/filterUtils";
12
-
13
- const ALL_VALUE = "__all__";
14
-
15
- interface SelectFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
16
- field: string;
17
- label: string;
18
- options: Array<{ value: string; label: string }>;
19
- helpText?: string;
20
- }
21
-
22
- export function SelectFilter({
23
- field,
24
- label,
25
- options,
26
- helpText,
27
- className,
28
- ...props
29
- }: SelectFilterProps) {
30
- const { value, onChange } = useFilterField(field);
31
- return (
32
- <FilterFieldWrapper
33
- label={label}
34
- htmlFor={`filter-${field}`}
35
- helpText={helpText}
36
- className={className}
37
- {...props}
38
- >
39
- <SelectFilterControl
40
- field={field}
41
- label={label}
42
- options={options}
43
- value={value}
44
- onChange={onChange}
45
- />
46
- </FilterFieldWrapper>
47
- );
48
- }
49
-
50
- interface SelectFilterControlProps {
51
- field: string;
52
- label: string;
53
- options: Array<{ value: string; label: string }>;
54
- value: ActiveFilterValue | undefined;
55
- onChange: (value: ActiveFilterValue | undefined) => void;
56
- triggerProps?: React.ComponentProps<typeof SelectTrigger>;
57
- contentProps?: React.ComponentProps<typeof SelectContent>;
58
- }
59
-
60
- export function SelectFilterControl({
61
- field,
62
- label,
63
- options,
64
- value,
65
- onChange,
66
- triggerProps,
67
- contentProps,
68
- }: SelectFilterControlProps) {
69
- return (
70
- <Select
71
- value={value?.value ?? ALL_VALUE}
72
- onValueChange={(v) => {
73
- if (v === ALL_VALUE) {
74
- onChange(undefined);
75
- } else {
76
- onChange({ field, label, type: "picklist", value: v });
77
- }
78
- }}
79
- >
80
- <SelectTrigger
81
- id={`filter-${field}`}
82
- {...triggerProps}
83
- className={cn("w-full", triggerProps?.className)}
84
- >
85
- <SelectValue placeholder={`Select ${label.toLowerCase()}`} />
86
- </SelectTrigger>
87
- <SelectContent {...contentProps}>
88
- <SelectItem value={ALL_VALUE}>All</SelectItem>
89
- {options.map((opt) => (
90
- <SelectItem key={opt.value} value={opt.value}>
91
- {opt.label}
92
- </SelectItem>
93
- ))}
94
- </SelectContent>
95
- </Select>
96
- );
97
- }
@@ -1,93 +0,0 @@
1
- import { useState } from "react";
2
- import { Input } from "../../../../components/ui/input";
3
- import { cn } from "../../../../lib/utils";
4
- import { useFilterField } from "../FilterContext";
5
- import { FilterFieldWrapper } from "./FilterFieldWrapper";
6
- import { useDebouncedCallback } from "../../hooks/useDebouncedCallback";
7
- import type { ActiveFilterValue } from "../../utils/filterUtils";
8
-
9
- interface TextFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
10
- field: string;
11
- label: string;
12
- placeholder?: string;
13
- helpText?: string;
14
- }
15
-
16
- export function TextFilter({
17
- field,
18
- label,
19
- placeholder,
20
- helpText,
21
- className,
22
- ...props
23
- }: TextFilterProps) {
24
- const { value, onChange } = useFilterField(field);
25
- return (
26
- <FilterFieldWrapper
27
- label={label}
28
- htmlFor={`filter-${field}`}
29
- helpText={helpText}
30
- className={className}
31
- {...props}
32
- >
33
- <TextFilterInput
34
- field={field}
35
- label={label}
36
- placeholder={placeholder}
37
- value={value}
38
- onChange={onChange}
39
- />
40
- </FilterFieldWrapper>
41
- );
42
- }
43
-
44
- interface TextFilterInputProps extends Omit<
45
- React.ComponentProps<typeof Input>,
46
- "onChange" | "value"
47
- > {
48
- field: string;
49
- label: string;
50
- value: ActiveFilterValue | undefined;
51
- onChange: (value: ActiveFilterValue | undefined) => void;
52
- }
53
-
54
- export function TextFilterInput({
55
- field,
56
- label,
57
- value,
58
- onChange,
59
- className,
60
- ...props
61
- }: TextFilterInputProps) {
62
- const [localValue, setLocalValue] = useState(value?.value ?? "");
63
-
64
- const externalValue = value?.value ?? "";
65
- const [prevExternalValue, setPrevExternalValue] = useState(externalValue);
66
- if (prevExternalValue !== externalValue) {
67
- setPrevExternalValue(externalValue);
68
- setLocalValue(externalValue);
69
- }
70
-
71
- const debouncedOnChange = useDebouncedCallback((v: string) => {
72
- if (v) {
73
- onChange({ field, label, type: "text", value: v });
74
- } else {
75
- onChange(undefined);
76
- }
77
- });
78
-
79
- return (
80
- <Input
81
- id={`filter-${field}`}
82
- type="text"
83
- placeholder={props.placeholder ?? `Filter by ${label.toLowerCase()}...`}
84
- value={localValue}
85
- onChange={(e) => {
86
- setLocalValue(e.target.value);
87
- debouncedOnChange(e.target.value);
88
- }}
89
- className={cn(className)}
90
- {...props}
91
- />
92
- );
93
- }
@@ -1,34 +0,0 @@
1
- import { useCallback, useEffect, useRef } from "react";
2
- import { debounce, FILTER_DEBOUNCE_MS } from "../utils/debounce";
3
-
4
- /**
5
- * Returns a stable debounced wrapper around the provided callback.
6
- *
7
- * The wrapper always invokes the *latest* version of `fn` (via a ref),
8
- * so the debounce timer is never reset when `fn` changes — only when
9
- * the caller invokes the returned function again.
10
- *
11
- * @param fn - The callback to debounce.
12
- * @param delay - Debounce delay in ms. Defaults to `FILTER_DEBOUNCE_MS`.
13
- */
14
- export function useDebouncedCallback<T extends (...args: any[]) => void>(
15
- fn: T,
16
- delay: number = FILTER_DEBOUNCE_MS,
17
- ): (...args: Parameters<T>) => void {
18
- const fnRef = useRef(fn);
19
- const debouncedRef = useRef<((...args: any[]) => void) | null>(null);
20
-
21
- useEffect(() => {
22
- fnRef.current = fn;
23
- });
24
-
25
- useEffect(() => {
26
- debouncedRef.current = debounce((...args: any[]) => {
27
- fnRef.current(...(args as Parameters<T>));
28
- }, delay);
29
- }, [delay]);
30
-
31
- return useCallback((...args: Parameters<T>) => {
32
- debouncedRef.current?.(...args);
33
- }, []);
34
- }
@@ -1,254 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { useSearchParams } from "react-router";
3
- import type { FilterFieldConfig, ActiveFilterValue } from "../utils/filterUtils";
4
- import type { SortFieldConfig, SortState } from "../utils/sortUtils";
5
- import { filtersToSearchParams, searchParamsToFilters, buildFilter } from "../utils/filterUtils";
6
- import { buildOrderBy } from "../utils/sortUtils";
7
- import { debounce } from "../utils/debounce";
8
-
9
- /** How long to wait before flushing local state changes to the URL. */
10
- const URL_SYNC_DEBOUNCE_MS = 300;
11
-
12
- export interface PaginationConfig {
13
- defaultPageSize: number;
14
- validPageSizes: number[];
15
- }
16
-
17
- export interface UseObjectSearchParamsReturn<TFilter, TOrderBy> {
18
- filters: {
19
- active: ActiveFilterValue[];
20
- set: (field: string, value: ActiveFilterValue | undefined) => void;
21
- remove: (field: string) => void;
22
- };
23
- sort: {
24
- current: SortState | null;
25
- set: (sort: SortState | null) => void;
26
- };
27
- query: { where: TFilter; orderBy: TOrderBy };
28
- pagination: {
29
- pageSize: number;
30
- pageIndex: number;
31
- afterCursor: string | undefined;
32
- setPageSize: (size: number) => void;
33
- goToNextPage: (cursor: string) => void;
34
- goToPreviousPage: () => void;
35
- };
36
- resetAll: () => void;
37
- }
38
-
39
- /**
40
- * Manages filter, sort, and cursor-based pagination state for an object search page.
41
- *
42
- * ## State model
43
- * Local React state is the primary driver for instant UI updates.
44
- * URL search params act as the durable source of truth so that a page
45
- * refresh or shared link restores the same view. Changes are synced to
46
- * the URL via a debounced write (300 ms) to avoid excessive history entries.
47
- *
48
- * ## Return shape
49
- * Returns memoized groups so each group's reference is stable unless its
50
- * contents change — safe to pass directly as props to `React.memo` children.
51
- *
52
- * - `filters` — active filter values + set/remove callbacks
53
- * - `sort` — current sort state + set callback
54
- * - `query` — derived `where` / `orderBy` objects ready for the API
55
- * - `pagination` — page size, page index, cursor, and navigation callbacks
56
- * - `resetAll` — clears all filters, sort, and pagination in one call
57
- */
58
- export function useObjectSearchParams<TFilter, TOrderBy>(
59
- filterConfigs: FilterFieldConfig[],
60
- _sortConfigs?: SortFieldConfig[],
61
- paginationConfig?: PaginationConfig,
62
- ) {
63
- const defaultPageSize = paginationConfig?.defaultPageSize ?? 10;
64
- const validPageSizes = useMemo(
65
- () => paginationConfig?.validPageSizes ?? [defaultPageSize],
66
- [paginationConfig?.validPageSizes, defaultPageSize],
67
- );
68
- const [searchParams, setSearchParams] = useSearchParams();
69
-
70
- // Seed local state from URL on initial load
71
- const initial = useMemo(
72
- () => searchParamsToFilters(searchParams, filterConfigs),
73
- // Only run on mount — local state takes over after that, no deps needed
74
- // eslint-disable-next-line react-hooks/exhaustive-deps
75
- [],
76
- );
77
-
78
- const [filters, setFilters] = useState<ActiveFilterValue[]>(initial.filters);
79
- const [sort, setLocalSort] = useState<SortState | null>(initial.sort);
80
-
81
- // Pagination — cursor-based with a stack to support "previous page" navigation.
82
- const getValidPageSize = useCallback(
83
- (size: number) => (validPageSizes.includes(size) ? size : defaultPageSize),
84
- [validPageSizes, defaultPageSize],
85
- );
86
-
87
- const [pageSize, setPageSizeState] = useState<number>(
88
- getValidPageSize(initial.pageSize ?? defaultPageSize),
89
- );
90
- const [pageIndex, setPageIndex] = useState(initial.pageIndex);
91
- const [afterCursor, setAfterCursor] = useState<string | undefined>(undefined);
92
- const cursorStackRef = useRef<string[]>([]);
93
-
94
- // Debounced URL sync — keeps URL in sync without blocking the UI
95
- const syncToUrl = useCallback(
96
- (
97
- nextFilters: ActiveFilterValue[],
98
- nextSort: SortState | null,
99
- nextPageSize?: number,
100
- nextPageIndex?: number,
101
- ) => {
102
- const params = filtersToSearchParams(nextFilters, nextSort, nextPageSize, nextPageIndex);
103
- setSearchParams(params, { replace: true });
104
- },
105
- [setSearchParams],
106
- );
107
-
108
- const debouncedSyncRef = useRef(debounce(syncToUrl, URL_SYNC_DEBOUNCE_MS));
109
- useEffect(() => {
110
- debouncedSyncRef.current = debounce(syncToUrl, URL_SYNC_DEBOUNCE_MS);
111
- }, [syncToUrl]);
112
-
113
- // Snapshot ref — lets callbacks read the latest state without being
114
- // recreated on every render (avoids infinite useCallback chains).
115
- const stateRef = useRef({ filters, sort, pageSize, pageIndex });
116
- useEffect(() => {
117
- stateRef.current = { filters, sort, pageSize, pageIndex };
118
- });
119
-
120
- // Any filter/sort change resets pagination to the first page.
121
- const resetPagination = useCallback(() => {
122
- setPageIndex(0);
123
- setAfterCursor(undefined);
124
- cursorStackRef.current = [];
125
- }, []);
126
-
127
- // -- Filter callbacks -------------------------------------------------------
128
-
129
- const setFilter = useCallback(
130
- (field: string, value: ActiveFilterValue | undefined) => {
131
- const { sort: s, pageSize: ps } = stateRef.current;
132
- setFilters((prev) => {
133
- const next = prev.filter((f) => f.field !== field);
134
- if (value) next.push(value);
135
- debouncedSyncRef.current(next, s, ps);
136
- return next;
137
- });
138
- resetPagination();
139
- },
140
- [resetPagination],
141
- );
142
-
143
- const removeFilter = useCallback(
144
- (field: string) => {
145
- const { sort: s, pageSize: ps } = stateRef.current;
146
- setFilters((prev) => {
147
- const next = prev.filter((f) => f.field !== field);
148
- debouncedSyncRef.current(next, s, ps);
149
- return next;
150
- });
151
- resetPagination();
152
- },
153
- [resetPagination],
154
- );
155
-
156
- // -- Sort callback ----------------------------------------------------------
157
-
158
- const setSort = useCallback(
159
- (nextSort: SortState | null) => {
160
- const { filters: f, pageSize: ps } = stateRef.current;
161
- setLocalSort(nextSort);
162
- debouncedSyncRef.current(f, nextSort, ps);
163
- resetPagination();
164
- },
165
- [resetPagination],
166
- );
167
-
168
- // -- Reset ------------------------------------------------------------------
169
-
170
- const resetAll = useCallback(() => {
171
- setFilters([]);
172
- setLocalSort(null);
173
- resetPagination();
174
- syncToUrl([], null, defaultPageSize, 0);
175
- setPageSizeState(defaultPageSize);
176
- }, [syncToUrl, resetPagination, defaultPageSize]);
177
-
178
- // -- Pagination callbacks ---------------------------------------------------
179
- // Uses a cursor stack to track visited pages. "Next" pushes the current
180
- // endCursor onto the stack; "Previous" pops it to restore the prior cursor.
181
-
182
- const goToNextPage = useCallback((endCursor: string) => {
183
- cursorStackRef.current = [...cursorStackRef.current, endCursor];
184
- setAfterCursor(endCursor);
185
- setPageIndex((prev) => {
186
- const nextIndex = prev + 1;
187
- const { filters: f, sort: s, pageSize: ps } = stateRef.current;
188
- debouncedSyncRef.current(f, s, ps, nextIndex);
189
- return nextIndex;
190
- });
191
- }, []);
192
-
193
- const goToPreviousPage = useCallback(() => {
194
- const stack = cursorStackRef.current;
195
- const next = stack.slice(0, -1);
196
- cursorStackRef.current = next;
197
- setAfterCursor(next.length > 0 ? next[next.length - 1] : undefined);
198
- setPageIndex((prev) => {
199
- const nextIndex = Math.max(0, prev - 1);
200
- const { filters: f, sort: s, pageSize: ps } = stateRef.current;
201
- debouncedSyncRef.current(f, s, ps, nextIndex);
202
- return nextIndex;
203
- });
204
- }, []);
205
-
206
- const setPageSize = useCallback(
207
- (newSize: number) => {
208
- const validated = getValidPageSize(newSize);
209
- const { filters: f, sort: s } = stateRef.current;
210
- setPageSizeState(validated);
211
- resetPagination();
212
- debouncedSyncRef.current(f, s, validated);
213
- },
214
- [resetPagination, getValidPageSize],
215
- );
216
-
217
- // -- Derived query objects ---------------------------------------------------
218
- // Translate local filter/sort state into API-ready `where` and `orderBy`.
219
-
220
- const where = useMemo(
221
- () => buildFilter<TFilter>(filters, filterConfigs),
222
- [filters, filterConfigs],
223
- );
224
-
225
- const orderBy = useMemo(() => buildOrderBy<TOrderBy>(sort), [sort]);
226
-
227
- // -- Memoized return groups -------------------------------------------------
228
- // Each group is individually memoized so its object reference stays stable
229
- // unless the contained values change. This makes it safe to pass a group
230
- // (e.g. `pagination`) directly as props to a React.memo child without
231
- // causing unnecessary re-renders.
232
-
233
- const filtersGroup = useMemo(
234
- () => ({ active: filters, set: setFilter, remove: removeFilter }),
235
- [filters, setFilter, removeFilter],
236
- );
237
-
238
- const sortGroup = useMemo(() => ({ current: sort, set: setSort }), [sort, setSort]);
239
-
240
- const query = useMemo(() => ({ where, orderBy }), [where, orderBy]);
241
-
242
- const pagination = useMemo(
243
- () => ({ pageSize, pageIndex, afterCursor, setPageSize, goToNextPage, goToPreviousPage }),
244
- [pageSize, pageIndex, afterCursor, setPageSize, goToNextPage, goToPreviousPage],
245
- );
246
-
247
- return {
248
- filters: filtersGroup,
249
- sort: sortGroup,
250
- query,
251
- pagination,
252
- resetAll,
253
- };
254
- }