@salesforce/ui-bundle-template-app-react-template-b2x 11.18.0 → 11.19.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/CHANGELOG.md +8 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/package.json +4 -4
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/{features/object-search/components → components}/ObjectBreadcrumb.tsx +7 -2
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/api/distinctValuesService.ts +84 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/api/searchService.ts +71 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/MergedSearchResults.tsx +263 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/Search.tsx +185 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/SearchResults.tsx +77 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/SourceSection.tsx +166 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/PaginationControls.tsx +185 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/ScopeSelector.tsx +55 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/SearchBar.tsx +55 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/SortControl.tsx +62 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/ActiveFilters.tsx +61 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/DefaultFilterPanel.tsx +125 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/{object-search/components → search/components/filters}/FilterContext.tsx +7 -20
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/BooleanFilter.tsx +50 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/DateRangeFilter.tsx +160 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/FilterFieldWrapper.tsx +26 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +88 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/NumericRangeFilter.tsx +78 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/SelectFilter.tsx +46 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/TextFilter.tsx +52 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/results/DefaultResultRow.tsx +109 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/config.json +83 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/{object-search → search}/hooks/useAsyncData.ts +3 -10
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/hooks/useDistinctValues.ts +59 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/hooks/useSearch.ts +703 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/index.ts +90 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/loadConfig.ts +14 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/queryBuilder.ts +156 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/types.ts +307 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/debounce.ts +17 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/fieldUtils.ts +14 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/filterUtils.ts +285 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/sortUtils.ts +34 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/AccountObjectDetailPage.tsx +49 -16
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/AccountSearch.tsx +21 -293
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/Home.tsx +22 -7
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/routes.tsx +1 -1
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/{features/object-search/utils/fieldUtils.ts → utils/accountFields.ts} +9 -0
- package/dist/force-app/main/default/uiBundles/reactexternalapp/tsconfig.tsbuildinfo +1 -1
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +1 -1
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/accountSearchService.ts +0 -46
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/query/distinctAccountIndustries.graphql +0 -19
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/query/distinctAccountTypes.graphql +0 -19
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/query/searchAccounts.graphql +0 -51
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/graphql-operations-types.ts +0 -11260
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/accountSearchService.ts +0 -46
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/distinctAccountIndustries.graphql +0 -19
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/distinctAccountTypes.graphql +0 -19
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/getAccountDetail.graphql +0 -121
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/searchAccounts.graphql +0 -51
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/AccountObjectDetailPage.tsx +0 -355
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +0 -304
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/Home.tsx +0 -34
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/api/objectSearchService.ts +0 -82
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/ActiveFilters.tsx +0 -89
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/PaginationControls.tsx +0 -109
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/SearchBar.tsx +0 -41
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/SortControl.tsx +0 -143
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/BooleanFilter.tsx +0 -78
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/DateFilter.tsx +0 -128
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/DateRangeFilter.tsx +0 -70
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/FilterFieldWrapper.tsx +0 -43
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/MultiSelectFilter.tsx +0 -105
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +0 -171
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/SearchFilter.tsx +0 -52
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/SelectFilter.tsx +0 -97
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/TextFilter.tsx +0 -93
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/hooks/useDebouncedCallback.ts +0 -34
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/hooks/useObjectSearchParams.ts +0 -254
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/utils/debounce.ts +0 -25
- package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/utils/filterUtils.ts +0 -404
- 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
|
-
}
|