@salesforce/webapp-template-app-react-template-b2e-experimental 1.109.4 → 1.109.6
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 +16 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/package.json +5 -6
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/graphql-operations-types.ts +11260 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/sonner.tsx +20 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/__examples__/api/accountSearchService.ts +46 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/__examples__/api/query/distinctAccountIndustries.graphql +19 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/__examples__/api/query/distinctAccountTypes.graphql +19 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/__examples__/api/query/getAccountDetail.graphql +121 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/__examples__/api/query/searchAccounts.graphql +51 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/__examples__/pages/AccountObjectDetailPage.tsx +357 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/__examples__/pages/AccountSearch.tsx +275 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/__examples__/pages/Home.tsx +34 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/api/objectSearchService.ts +84 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/ActiveFilters.tsx +89 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/FilterPanel.tsx +127 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/ObjectBreadcrumb.tsx +66 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/PaginationControls.tsx +151 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/SearchBar.tsx +41 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/SortControl.tsx +143 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/BooleanFilter.tsx +94 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/DateFilter.tsx +138 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/DateRangeFilter.tsx +78 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/MultiSelectFilter.tsx +106 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/NumericRangeFilter.tsx +102 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/SearchFilter.tsx +40 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/SelectFilter.tsx +97 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/TextFilter.tsx +77 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/hooks/useAsyncData.ts +53 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/hooks/useCachedAsyncData.ts +183 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/hooks/useObjectSearchParams.ts +225 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/utils/debounce.ts +22 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/utils/fieldUtils.ts +29 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/utils/filterUtils.ts +372 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/utils/sortUtils.ts +38 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/index.ts +3 -117
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/pages/Home.tsx +10 -11
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/routes.tsx +8 -20
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +1 -1
- package/dist/.a4drules/skills/designing-webapp-ui-ux/SKILL.md +0 -271
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/charts.csv +0 -26
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/colors.csv +0 -97
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/icons.csv +0 -101
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/landing.csv +0 -31
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/products.csv +0 -97
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/react-performance.csv +0 -45
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/stacks/html-tailwind.csv +0 -56
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/stacks/react.csv +0 -54
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/stacks/shadcn.csv +0 -61
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/styles.csv +0 -68
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/typography.csv +0 -58
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/ui-reasoning.csv +0 -101
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/ux-guidelines.csv +0 -100
- package/dist/.a4drules/skills/designing-webapp-ui-ux/data/web-interface.csv +0 -31
- package/dist/.a4drules/skills/designing-webapp-ui-ux/scripts/core.js +0 -255
- package/dist/.a4drules/skills/designing-webapp-ui-ux/scripts/design_system.js +0 -861
- package/dist/.a4drules/skills/designing-webapp-ui-ux/scripts/search.js +0 -98
- package/dist/.a4drules/skills/integrating-unsplash-images/SKILL.md +0 -71
- package/dist/.a4drules/skills/integrating-unsplash-images/implementation/usage.md +0 -159
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/api/objectDetailService.ts +0 -102
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/api/objectInfoGraphQLService.ts +0 -137
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/api/objectInfoService.ts +0 -95
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/api/recordListGraphQLService.ts +0 -364
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/DetailFields.tsx +0 -55
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/DetailForm.tsx +0 -146
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/DetailHeader.tsx +0 -34
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/DetailLayoutSections.tsx +0 -80
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/Section.tsx +0 -108
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/SectionRow.tsx +0 -20
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/UiApiDetailForm.tsx +0 -140
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/formatted/FieldValueDisplay.tsx +0 -73
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/formatted/FormattedAddress.tsx +0 -29
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/formatted/FormattedEmail.tsx +0 -17
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/formatted/FormattedPhone.tsx +0 -24
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/formatted/FormattedText.tsx +0 -11
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/detail/formatted/FormattedUrl.tsx +0 -29
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/filters/FilterField.tsx +0 -54
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/filters/FilterInput.tsx +0 -55
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/filters/FilterSelect.tsx +0 -72
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/filters/FiltersPanel.tsx +0 -380
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/forms/filters-form.tsx +0 -114
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/forms/submit-button.tsx +0 -47
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/search/GlobalSearchInput.tsx +0 -114
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/search/ResultCardFields.tsx +0 -71
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/search/SearchHeader.tsx +0 -31
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/search/SearchPagination.tsx +0 -144
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/search/SearchResultCard.tsx +0 -138
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/search/SearchResultsPanel.tsx +0 -197
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/components/shared/LoadingFallback.tsx +0 -61
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/constants.ts +0 -39
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/filters/FilterInput.tsx +0 -55
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/filters/FilterSelect.tsx +0 -72
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/hooks/form.tsx +0 -209
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/hooks/useObjectInfoBatch.ts +0 -72
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/hooks/useObjectSearchData.ts +0 -174
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/hooks/useRecordDetailLayout.ts +0 -137
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/hooks/useRecordListGraphQL.ts +0 -135
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/pages/DetailPage.tsx +0 -109
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/pages/GlobalSearch.tsx +0 -235
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/types/filters/filters.ts +0 -121
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/types/filters/picklist.ts +0 -6
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/types/objectInfo/objectInfo.ts +0 -49
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/types/recordDetail/recordDetail.ts +0 -61
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/types/schema.d.ts +0 -200
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/types/search/searchResults.ts +0 -229
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/apiUtils.ts +0 -59
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/cacheUtils.ts +0 -76
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/debounce.ts +0 -90
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/fieldUtils.ts +0 -354
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/fieldValueExtractor.ts +0 -67
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/filterUtils.ts +0 -32
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/formDataTransformUtils.ts +0 -260
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/formUtils.ts +0 -142
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/graphQLNodeFieldUtils.ts +0 -186
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/graphQLObjectInfoAdapter.ts +0 -77
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/graphQLRecordAdapter.ts +0 -90
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/layoutTransformUtils.ts +0 -236
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/linkUtils.ts +0 -14
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/paginationUtils.ts +0 -49
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/recordUtils.ts +0 -159
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/utils/sanitizationUtils.ts +0 -50
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Popover,
|
|
3
|
+
PopoverContent,
|
|
4
|
+
PopoverTrigger,
|
|
5
|
+
} from "../../../../components/ui/popover";
|
|
6
|
+
import { Checkbox } from "../../../../components/ui/checkbox";
|
|
7
|
+
import { Label } from "../../../../components/ui/label";
|
|
8
|
+
import { Button } from "../../../../components/ui/button";
|
|
9
|
+
import { cn } from "../../../../lib/utils";
|
|
10
|
+
import { ChevronDown } from "lucide-react";
|
|
11
|
+
import type { FilterFieldConfig, ActiveFilterValue } from "../../utils/filterUtils";
|
|
12
|
+
|
|
13
|
+
interface MultiSelectFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
14
|
+
config: FilterFieldConfig;
|
|
15
|
+
value: ActiveFilterValue | undefined;
|
|
16
|
+
onChange: (value: ActiveFilterValue | undefined) => void;
|
|
17
|
+
labelProps?: React.ComponentProps<typeof Label>;
|
|
18
|
+
helpTextProps?: React.ComponentProps<"p">;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function MultiSelectFilter({
|
|
22
|
+
config,
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
className,
|
|
26
|
+
labelProps,
|
|
27
|
+
helpTextProps,
|
|
28
|
+
...props
|
|
29
|
+
}: MultiSelectFilterProps) {
|
|
30
|
+
const selected = value?.value ? value.value.split(",") : [];
|
|
31
|
+
|
|
32
|
+
const triggerLabel =
|
|
33
|
+
selected.length === 0
|
|
34
|
+
? `Select ${config.label.toLowerCase()}`
|
|
35
|
+
: selected.length === 1
|
|
36
|
+
? (config.options?.find((o) => o.value === selected[0])?.label ?? selected[0])
|
|
37
|
+
: `${selected.length} selected`;
|
|
38
|
+
|
|
39
|
+
function handleToggle(optionValue: string) {
|
|
40
|
+
const next = selected.includes(optionValue)
|
|
41
|
+
? selected.filter((v) => v !== optionValue)
|
|
42
|
+
: [...selected, optionValue];
|
|
43
|
+
|
|
44
|
+
if (next.length === 0) {
|
|
45
|
+
onChange(undefined);
|
|
46
|
+
} else {
|
|
47
|
+
onChange({
|
|
48
|
+
field: config.field,
|
|
49
|
+
label: config.label,
|
|
50
|
+
type: "multipicklist",
|
|
51
|
+
value: next.join(","),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className={cn("space-y-1.5", className)} {...props}>
|
|
58
|
+
<Label {...labelProps}>{labelProps?.children ?? config.label}</Label>
|
|
59
|
+
<Popover>
|
|
60
|
+
<PopoverTrigger asChild>
|
|
61
|
+
<Button
|
|
62
|
+
variant="outline"
|
|
63
|
+
role="combobox"
|
|
64
|
+
className={cn(
|
|
65
|
+
"w-full justify-between font-normal",
|
|
66
|
+
selected.length === 0 && "text-muted-foreground",
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
<span className="truncate">{triggerLabel}</span>
|
|
70
|
+
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
71
|
+
</Button>
|
|
72
|
+
</PopoverTrigger>
|
|
73
|
+
<PopoverContent className="p-2" align="start">
|
|
74
|
+
<div className="max-h-48 overflow-y-auto space-y-1">
|
|
75
|
+
{config.options?.map((opt) => {
|
|
76
|
+
const id = `filter-${config.field}-${opt.value}`;
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
key={opt.value}
|
|
80
|
+
className="flex items-center gap-2 rounded px-1 py-0.5 hover:bg-accent"
|
|
81
|
+
>
|
|
82
|
+
<Checkbox
|
|
83
|
+
id={id}
|
|
84
|
+
checked={selected.includes(opt.value)}
|
|
85
|
+
onCheckedChange={() => handleToggle(opt.value)}
|
|
86
|
+
/>
|
|
87
|
+
<Label htmlFor={id} className="text-sm font-normal cursor-pointer w-full">
|
|
88
|
+
{opt.label}
|
|
89
|
+
</Label>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
</PopoverContent>
|
|
95
|
+
</Popover>
|
|
96
|
+
{config.helpText && (
|
|
97
|
+
<p
|
|
98
|
+
{...helpTextProps}
|
|
99
|
+
className={cn("text-xs text-muted-foreground", helpTextProps?.className)}
|
|
100
|
+
>
|
|
101
|
+
{helpTextProps?.children ?? config.helpText}
|
|
102
|
+
</p>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Input } from "../../../../components/ui/input";
|
|
2
|
+
import { Label } from "../../../../components/ui/label";
|
|
3
|
+
import { cn } from "../../../../lib/utils";
|
|
4
|
+
import type { FilterFieldConfig, ActiveFilterValue } from "../../utils/filterUtils";
|
|
5
|
+
|
|
6
|
+
interface NumericRangeFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
7
|
+
config: FilterFieldConfig;
|
|
8
|
+
value: ActiveFilterValue | undefined;
|
|
9
|
+
onChange: (value: ActiveFilterValue | undefined) => void;
|
|
10
|
+
labelProps?: React.ComponentProps<typeof Label>;
|
|
11
|
+
controlProps?: Omit<
|
|
12
|
+
React.ComponentProps<typeof NumericRangeFilterInputs>,
|
|
13
|
+
"config" | "value" | "onChange"
|
|
14
|
+
>;
|
|
15
|
+
helpTextProps?: React.ComponentProps<"p">;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function NumericRangeFilter({
|
|
19
|
+
config,
|
|
20
|
+
value,
|
|
21
|
+
onChange,
|
|
22
|
+
className,
|
|
23
|
+
labelProps,
|
|
24
|
+
controlProps,
|
|
25
|
+
helpTextProps,
|
|
26
|
+
...props
|
|
27
|
+
}: NumericRangeFilterProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div className={cn("space-y-1.5", className)} {...props}>
|
|
30
|
+
<Label {...labelProps}>{labelProps?.children ?? config.label}</Label>
|
|
31
|
+
<NumericRangeFilterInputs
|
|
32
|
+
config={config}
|
|
33
|
+
value={value}
|
|
34
|
+
onChange={onChange}
|
|
35
|
+
{...controlProps}
|
|
36
|
+
/>
|
|
37
|
+
{config.helpText && (
|
|
38
|
+
<p
|
|
39
|
+
{...helpTextProps}
|
|
40
|
+
className={cn("text-xs text-muted-foreground", helpTextProps?.className)}
|
|
41
|
+
>
|
|
42
|
+
{helpTextProps?.children ?? config.helpText}
|
|
43
|
+
</p>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface NumericRangeFilterInputsProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
50
|
+
config: FilterFieldConfig;
|
|
51
|
+
value: ActiveFilterValue | undefined;
|
|
52
|
+
onChange: (value: ActiveFilterValue | undefined) => void;
|
|
53
|
+
minInputProps?: React.ComponentProps<typeof Input>;
|
|
54
|
+
maxInputProps?: React.ComponentProps<typeof Input>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function NumericRangeFilterInputs({
|
|
58
|
+
config,
|
|
59
|
+
value,
|
|
60
|
+
onChange,
|
|
61
|
+
className,
|
|
62
|
+
minInputProps,
|
|
63
|
+
maxInputProps,
|
|
64
|
+
...props
|
|
65
|
+
}: NumericRangeFilterInputsProps) {
|
|
66
|
+
const handleChange = (field: "min" | "max", v: string) => {
|
|
67
|
+
const next = {
|
|
68
|
+
field: config.field,
|
|
69
|
+
label: config.label,
|
|
70
|
+
type: "numeric" as const,
|
|
71
|
+
min: value?.min ?? "",
|
|
72
|
+
max: value?.max ?? "",
|
|
73
|
+
[field]: v,
|
|
74
|
+
};
|
|
75
|
+
if (!next.min && !next.max) {
|
|
76
|
+
onChange(undefined);
|
|
77
|
+
} else {
|
|
78
|
+
onChange(next);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className={cn("flex gap-2", className)} {...props}>
|
|
84
|
+
<Input
|
|
85
|
+
type="number"
|
|
86
|
+
placeholder="Min"
|
|
87
|
+
value={value?.min ?? ""}
|
|
88
|
+
onChange={(e) => handleChange("min", e.target.value)}
|
|
89
|
+
aria-label={`${config.label} minimum`}
|
|
90
|
+
{...minInputProps}
|
|
91
|
+
/>
|
|
92
|
+
<Input
|
|
93
|
+
type="number"
|
|
94
|
+
placeholder="Max"
|
|
95
|
+
value={value?.max ?? ""}
|
|
96
|
+
onChange={(e) => handleChange("max", e.target.value)}
|
|
97
|
+
aria-label={`${config.label} maximum`}
|
|
98
|
+
{...maxInputProps}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Label } from "../../../../components/ui/label";
|
|
2
|
+
import { cn } from "../../../../lib/utils";
|
|
3
|
+
import { SearchBar } from "../SearchBar";
|
|
4
|
+
import type { FilterFieldConfig, ActiveFilterValue } from "../../utils/filterUtils";
|
|
5
|
+
|
|
6
|
+
interface SearchFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
7
|
+
config: FilterFieldConfig;
|
|
8
|
+
value: ActiveFilterValue | undefined;
|
|
9
|
+
onChange: (value: ActiveFilterValue | undefined) => void;
|
|
10
|
+
labelProps?: React.ComponentProps<typeof Label>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SearchFilter({
|
|
14
|
+
config,
|
|
15
|
+
value,
|
|
16
|
+
onChange,
|
|
17
|
+
className,
|
|
18
|
+
labelProps,
|
|
19
|
+
...props
|
|
20
|
+
}: SearchFilterProps) {
|
|
21
|
+
return (
|
|
22
|
+
<div className={cn("space-y-1.5", className)} {...props}>
|
|
23
|
+
<Label htmlFor={`filter-${config.field}`} {...labelProps}>
|
|
24
|
+
{labelProps?.children ?? config.label}
|
|
25
|
+
</Label>
|
|
26
|
+
<SearchBar
|
|
27
|
+
value={value?.value ?? ""}
|
|
28
|
+
handleChange={(v) => {
|
|
29
|
+
if (v) {
|
|
30
|
+
onChange({ field: config.field, label: config.label, type: "search", value: v });
|
|
31
|
+
} else {
|
|
32
|
+
onChange(undefined);
|
|
33
|
+
}
|
|
34
|
+
}}
|
|
35
|
+
placeholder={config.placeholder}
|
|
36
|
+
inputProps={{ id: `filter-${config.field}` }}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Select,
|
|
3
|
+
SelectContent,
|
|
4
|
+
SelectItem,
|
|
5
|
+
SelectTrigger,
|
|
6
|
+
SelectValue,
|
|
7
|
+
} from "../../../../components/ui/select";
|
|
8
|
+
import { Label } from "../../../../components/ui/label";
|
|
9
|
+
import { cn } from "../../../../lib/utils";
|
|
10
|
+
import type { FilterFieldConfig, ActiveFilterValue } from "../../utils/filterUtils";
|
|
11
|
+
|
|
12
|
+
const ALL_VALUE = "__all__";
|
|
13
|
+
|
|
14
|
+
interface SelectFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
15
|
+
config: FilterFieldConfig;
|
|
16
|
+
value: ActiveFilterValue | undefined;
|
|
17
|
+
onChange: (value: ActiveFilterValue | undefined) => void;
|
|
18
|
+
labelProps?: React.ComponentProps<typeof Label>;
|
|
19
|
+
controlProps?: Omit<
|
|
20
|
+
React.ComponentProps<typeof SelectFilterControl>,
|
|
21
|
+
"config" | "value" | "onChange"
|
|
22
|
+
>;
|
|
23
|
+
helpTextProps?: React.ComponentProps<"p">;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function SelectFilter({
|
|
27
|
+
config,
|
|
28
|
+
value,
|
|
29
|
+
onChange,
|
|
30
|
+
className,
|
|
31
|
+
labelProps,
|
|
32
|
+
controlProps,
|
|
33
|
+
helpTextProps,
|
|
34
|
+
...props
|
|
35
|
+
}: SelectFilterProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className={cn("space-y-1.5", className)} {...props}>
|
|
38
|
+
<Label htmlFor={`filter-${config.field}`} {...labelProps}>
|
|
39
|
+
{labelProps?.children ?? config.label}
|
|
40
|
+
</Label>
|
|
41
|
+
<SelectFilterControl config={config} value={value} onChange={onChange} {...controlProps} />
|
|
42
|
+
{config.helpText && (
|
|
43
|
+
<p
|
|
44
|
+
{...helpTextProps}
|
|
45
|
+
className={cn("text-xs text-muted-foreground", helpTextProps?.className)}
|
|
46
|
+
>
|
|
47
|
+
{helpTextProps?.children ?? config.helpText}
|
|
48
|
+
</p>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SelectFilterControlProps {
|
|
55
|
+
config: FilterFieldConfig;
|
|
56
|
+
value: ActiveFilterValue | undefined;
|
|
57
|
+
onChange: (value: ActiveFilterValue | undefined) => void;
|
|
58
|
+
triggerProps?: React.ComponentProps<typeof SelectTrigger>;
|
|
59
|
+
contentProps?: React.ComponentProps<typeof SelectContent>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function SelectFilterControl({
|
|
63
|
+
config,
|
|
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: config.field, label: config.label, type: "picklist", value: v });
|
|
77
|
+
}
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<SelectTrigger
|
|
81
|
+
id={`filter-${config.field}`}
|
|
82
|
+
{...triggerProps}
|
|
83
|
+
className={cn("w-full", triggerProps?.className)}
|
|
84
|
+
>
|
|
85
|
+
<SelectValue placeholder={`Select ${config.label.toLowerCase()}`} />
|
|
86
|
+
</SelectTrigger>
|
|
87
|
+
<SelectContent {...contentProps}>
|
|
88
|
+
<SelectItem value={ALL_VALUE}>All</SelectItem>
|
|
89
|
+
{config.options?.map((opt) => (
|
|
90
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
91
|
+
{opt.label}
|
|
92
|
+
</SelectItem>
|
|
93
|
+
))}
|
|
94
|
+
</SelectContent>
|
|
95
|
+
</Select>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Input } from "../../../../components/ui/input";
|
|
2
|
+
import { Label } from "../../../../components/ui/label";
|
|
3
|
+
import { cn } from "../../../../lib/utils";
|
|
4
|
+
import type { FilterFieldConfig, ActiveFilterValue } from "../../utils/filterUtils";
|
|
5
|
+
|
|
6
|
+
interface TextFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
7
|
+
config: FilterFieldConfig;
|
|
8
|
+
value: ActiveFilterValue | undefined;
|
|
9
|
+
onChange: (value: ActiveFilterValue | undefined) => void;
|
|
10
|
+
labelProps?: React.ComponentProps<typeof Label>;
|
|
11
|
+
inputProps?: Omit<React.ComponentProps<typeof TextFilterInput>, "config" | "value" | "onChange">;
|
|
12
|
+
helpTextProps?: React.ComponentProps<"p">;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function TextFilter({
|
|
16
|
+
config,
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
className,
|
|
20
|
+
labelProps,
|
|
21
|
+
inputProps,
|
|
22
|
+
helpTextProps,
|
|
23
|
+
...props
|
|
24
|
+
}: TextFilterProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div className={cn("space-y-1.5", className)} {...props}>
|
|
27
|
+
<Label htmlFor={`filter-${config.field}`} {...labelProps}>
|
|
28
|
+
{labelProps?.children ?? config.label}
|
|
29
|
+
</Label>
|
|
30
|
+
<TextFilterInput config={config} value={value} onChange={onChange} {...inputProps} />
|
|
31
|
+
{config.helpText && (
|
|
32
|
+
<p
|
|
33
|
+
{...helpTextProps}
|
|
34
|
+
className={cn("text-xs text-muted-foreground", helpTextProps?.className)}
|
|
35
|
+
>
|
|
36
|
+
{helpTextProps?.children ?? config.helpText}
|
|
37
|
+
</p>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TextFilterInputProps extends Omit<
|
|
44
|
+
React.ComponentProps<typeof Input>,
|
|
45
|
+
"onChange" | "value"
|
|
46
|
+
> {
|
|
47
|
+
config: FilterFieldConfig;
|
|
48
|
+
value: ActiveFilterValue | undefined;
|
|
49
|
+
onChange: (value: ActiveFilterValue | undefined) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function TextFilterInput({
|
|
53
|
+
config,
|
|
54
|
+
value,
|
|
55
|
+
onChange,
|
|
56
|
+
className,
|
|
57
|
+
...props
|
|
58
|
+
}: TextFilterInputProps) {
|
|
59
|
+
return (
|
|
60
|
+
<Input
|
|
61
|
+
id={`filter-${config.field}`}
|
|
62
|
+
type="text"
|
|
63
|
+
placeholder={config.placeholder ?? `Filter by ${config.label.toLowerCase()}...`}
|
|
64
|
+
value={value?.value ?? ""}
|
|
65
|
+
onChange={(e) => {
|
|
66
|
+
const v = e.target.value;
|
|
67
|
+
if (v) {
|
|
68
|
+
onChange({ field: config.field, label: config.label, type: "text", value: v });
|
|
69
|
+
} else {
|
|
70
|
+
onChange(undefined);
|
|
71
|
+
}
|
|
72
|
+
}}
|
|
73
|
+
className={cn(className)}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface UseAsyncDataResult<T> {
|
|
4
|
+
data: T | null;
|
|
5
|
+
loading: boolean;
|
|
6
|
+
error: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs an async fetcher on mount and whenever `deps` change.
|
|
11
|
+
* Returns the loading/error/data state. Does not cache — every call
|
|
12
|
+
* to the fetcher hits the source directly.
|
|
13
|
+
*
|
|
14
|
+
* A cleanup flag prevents state updates if the component unmounts
|
|
15
|
+
* or deps change before the fetch completes (avoids React warnings
|
|
16
|
+
* and stale updates from out-of-order responses).
|
|
17
|
+
*/
|
|
18
|
+
export function useAsyncData<T>(
|
|
19
|
+
fetcher: () => Promise<T>,
|
|
20
|
+
deps: React.DependencyList,
|
|
21
|
+
): UseAsyncDataResult<T> {
|
|
22
|
+
const [data, setData] = useState<T | null>(null);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
|
|
26
|
+
// Re-create the fetcher reference only when deps change.
|
|
27
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps --- deps are explicitly managed by the caller
|
|
28
|
+
const memoizedFetcher = useCallback(fetcher, deps);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
// Guard against setting state after unmount or dep change.
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
setLoading(true);
|
|
34
|
+
setError(null);
|
|
35
|
+
|
|
36
|
+
memoizedFetcher()
|
|
37
|
+
.then((result) => {
|
|
38
|
+
if (!cancelled) setData(result);
|
|
39
|
+
})
|
|
40
|
+
.catch((err) => {
|
|
41
|
+
if (!cancelled) setError(err instanceof Error ? err.message : "An error occurred");
|
|
42
|
+
})
|
|
43
|
+
.finally(() => {
|
|
44
|
+
if (!cancelled) setLoading(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
}, [memoizedFetcher]);
|
|
51
|
+
|
|
52
|
+
return { data, loading, error };
|
|
53
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface UseAsyncDataResult<T> {
|
|
4
|
+
data: T | null;
|
|
5
|
+
loading: boolean;
|
|
6
|
+
error: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface CacheOptions {
|
|
10
|
+
/** Unique cache key. Used for lookups and invalidation via `clearCacheEntry`. */
|
|
11
|
+
key: string;
|
|
12
|
+
/** Time-to-live in ms. Default: 30_000 (30s) */
|
|
13
|
+
ttl?: number;
|
|
14
|
+
/** Max entries in the cache. Default: 50. Evicts oldest entry when exceeded. */
|
|
15
|
+
maxSize?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CacheEntry {
|
|
19
|
+
data: unknown;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Module-level cache shared across all useCachedAsyncData consumers.
|
|
25
|
+
* Cleared automatically on page reload since it only lives in memory.
|
|
26
|
+
* Keys are caller-provided so different hook call-sites get independent entries.
|
|
27
|
+
*/
|
|
28
|
+
const cache = new Map<string, CacheEntry>();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns a cached entry if it exists and hasn't exceeded its TTL.
|
|
32
|
+
* Expired entries are deleted lazily here rather than on a timer,
|
|
33
|
+
* so there's no background cleanup overhead.
|
|
34
|
+
*/
|
|
35
|
+
function getValidEntry(key: string, ttl: number): CacheEntry | undefined {
|
|
36
|
+
const entry = cache.get(key);
|
|
37
|
+
if (!entry) return undefined;
|
|
38
|
+
if (Date.now() - entry.timestamp > ttl) {
|
|
39
|
+
cache.delete(key);
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return entry;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Stores a result in the cache. If the cache is at capacity and the key
|
|
47
|
+
* is new, the oldest entry (first in Map insertion order — FIFO) is evicted.
|
|
48
|
+
*/
|
|
49
|
+
function setEntry(key: string, data: unknown, maxSize: number): void {
|
|
50
|
+
if (!cache.has(key) && cache.size >= maxSize) {
|
|
51
|
+
const firstKey = cache.keys().next().value;
|
|
52
|
+
if (firstKey !== undefined) cache.delete(firstKey);
|
|
53
|
+
}
|
|
54
|
+
cache.set(key, { data, timestamp: Date.now() });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Removes a single cache entry by key. The key must match the
|
|
59
|
+
* `options.key` passed to `useCachedAsyncData`.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* // Hook:
|
|
63
|
+
* useCachedAsyncData(() => fetchAccountDetail(id), [id], { key: `account:${id}` });
|
|
64
|
+
*
|
|
65
|
+
* // Invalidate after a mutation:
|
|
66
|
+
* await updateAccount(id, fields);
|
|
67
|
+
* clearCacheEntry(`account:${id}`);
|
|
68
|
+
*/
|
|
69
|
+
export function clearCacheEntry(key: string): void {
|
|
70
|
+
cache.delete(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Removes all cache entries. Useful for global invalidation scenarios
|
|
75
|
+
* like user logout or after a bulk operation.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* async function handleLogout() {
|
|
79
|
+
* await logout();
|
|
80
|
+
* clearCache();
|
|
81
|
+
* navigate("/login");
|
|
82
|
+
* }
|
|
83
|
+
*/
|
|
84
|
+
export function clearCache(): void {
|
|
85
|
+
cache.clear();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Async data hook with in-memory caching. Works like `useAsyncData` but
|
|
90
|
+
* avoids redundant network calls when the same data is requested again
|
|
91
|
+
* (e.g. navigating away from a page and back).
|
|
92
|
+
*
|
|
93
|
+
* Cache behaviour:
|
|
94
|
+
* - **Key**: Provided via `options.key`. Must be unique per logical data source.
|
|
95
|
+
* Use the same key with `clearCacheEntry` to invalidate.
|
|
96
|
+
* - **Hit**: Data is returned synchronously on the initial render —
|
|
97
|
+
* `loading` starts as `false`, so there's no flash of a loading state.
|
|
98
|
+
* - **Miss**: The fetcher runs, and the result is stored for future hits.
|
|
99
|
+
* - **TTL**: Entries expire after `options.ttl` ms (default 30 s).
|
|
100
|
+
* Expiry is checked lazily on read, not on a timer.
|
|
101
|
+
* - **Max size**: Oldest entries are evicted FIFO when the cache exceeds
|
|
102
|
+
* `options.maxSize` (default 50).
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* // Cache picklist options for 5 minutes (data rarely changes)
|
|
106
|
+
* const { data: types } = useCachedAsyncData(fetchDistinctTypes, [], {
|
|
107
|
+
* key: "distinctTypes",
|
|
108
|
+
* ttl: 300_000,
|
|
109
|
+
* });
|
|
110
|
+
*
|
|
111
|
+
* // Cache search results with default 30 s TTL (back-nav returns instantly)
|
|
112
|
+
* const { data } = useCachedAsyncData(
|
|
113
|
+
* () => searchAccounts({ where, orderBy, first, after }),
|
|
114
|
+
* [where, orderBy, first, after],
|
|
115
|
+
* { key: `accounts:${JSON.stringify({ where, orderBy, first, after })}` },
|
|
116
|
+
* );
|
|
117
|
+
*
|
|
118
|
+
* // Invalidate a specific entry after a mutation
|
|
119
|
+
* await updateAccount(id, fields);
|
|
120
|
+
* clearCacheEntry(`account:${id}`);
|
|
121
|
+
*
|
|
122
|
+
* // Or clear everything (e.g. on logout)
|
|
123
|
+
* clearCache();
|
|
124
|
+
*/
|
|
125
|
+
export function useCachedAsyncData<T>(
|
|
126
|
+
fetcher: () => Promise<T>,
|
|
127
|
+
deps: React.DependencyList,
|
|
128
|
+
options: CacheOptions,
|
|
129
|
+
): UseAsyncDataResult<T> {
|
|
130
|
+
const ttl = options.ttl ?? 30_000;
|
|
131
|
+
const maxSize = options.maxSize ?? 50;
|
|
132
|
+
const cacheKey = options.key;
|
|
133
|
+
|
|
134
|
+
// Synchronous cache check during state initialization so a cache hit
|
|
135
|
+
// never triggers a loading → loaded transition (avoids UI flicker).
|
|
136
|
+
const cached = getValidEntry(cacheKey, ttl);
|
|
137
|
+
|
|
138
|
+
const [data, setData] = useState<T | null>((cached?.data as T) ?? null);
|
|
139
|
+
const [loading, setLoading] = useState(!cached);
|
|
140
|
+
const [error, setError] = useState<string | null>(null);
|
|
141
|
+
|
|
142
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps --- deps are explicitly managed by the caller
|
|
143
|
+
const memoizedFetcher = useCallback(fetcher, deps);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
// Re-check the cache inside the effect because deps may have changed
|
|
147
|
+
// since the initial render (e.g. StrictMode double-invoke).
|
|
148
|
+
const entry = getValidEntry(cacheKey, ttl);
|
|
149
|
+
if (entry) {
|
|
150
|
+
setData(entry.data as T);
|
|
151
|
+
setLoading(false);
|
|
152
|
+
setError(null);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// No cache hit — fetch from the network.
|
|
157
|
+
let cancelled = false;
|
|
158
|
+
setLoading(true);
|
|
159
|
+
setError(null);
|
|
160
|
+
|
|
161
|
+
memoizedFetcher()
|
|
162
|
+
.then((result) => {
|
|
163
|
+
if (!cancelled) {
|
|
164
|
+
setEntry(cacheKey, result, maxSize);
|
|
165
|
+
setData(result);
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
.catch((err) => {
|
|
169
|
+
if (!cancelled) setError(err instanceof Error ? err.message : "An error occurred");
|
|
170
|
+
})
|
|
171
|
+
.finally(() => {
|
|
172
|
+
if (!cancelled) setLoading(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Cleanup: if deps change or the component unmounts before the fetch
|
|
176
|
+
// completes, the cancelled flag prevents stale state updates.
|
|
177
|
+
return () => {
|
|
178
|
+
cancelled = true;
|
|
179
|
+
};
|
|
180
|
+
}, [memoizedFetcher, cacheKey, ttl, maxSize]);
|
|
181
|
+
|
|
182
|
+
return { data, loading, error };
|
|
183
|
+
}
|