@schandlergarcia/sf-web-components 2.2.1 → 2.3.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/CHANGELOG.md +11 -2
- package/brands/engine/app/api/graphql-operations-types.ts +11260 -0
- package/brands/engine/app/api/graphqlClient.ts +25 -0
- package/brands/engine/app/api/partnerQueries.ts +212 -0
- package/brands/engine/app/appLayout.tsx +13 -0
- package/brands/engine/app/components/AgentforceConversationClient.tsx +201 -0
- package/brands/engine/app/components/__inherit_AgentforceConversationClient.tsx +3 -0
- package/brands/engine/app/components/alerts/status-alert.tsx +49 -0
- package/brands/engine/app/components/layouts/card-layout.tsx +29 -0
- package/brands/engine/app/components/workspace/CommandCenter.tsx +16 -0
- package/brands/engine/app/config/agentApi.ts +36 -0
- package/brands/engine/app/features/object-search/__examples__/api/accountSearchService.ts +46 -0
- package/brands/engine/app/features/object-search/__examples__/api/query/distinctAccountIndustries.graphql +19 -0
- package/brands/engine/app/features/object-search/__examples__/api/query/distinctAccountTypes.graphql +19 -0
- package/brands/engine/app/features/object-search/__examples__/api/query/getAccountDetail.graphql +121 -0
- package/brands/engine/app/features/object-search/__examples__/api/query/searchAccounts.graphql +51 -0
- package/brands/engine/app/features/object-search/__examples__/pages/AccountObjectDetailPage.tsx +357 -0
- package/brands/engine/app/features/object-search/__examples__/pages/AccountSearch.tsx +312 -0
- package/brands/engine/app/features/object-search/__examples__/pages/Home.tsx +34 -0
- package/brands/engine/app/features/object-search/api/objectSearchService.ts +84 -0
- package/brands/engine/app/features/object-search/components/ActiveFilters.tsx +89 -0
- package/brands/engine/app/features/object-search/components/FilterContext.tsx +83 -0
- package/brands/engine/app/features/object-search/components/ObjectBreadcrumb.tsx +66 -0
- package/brands/engine/app/features/object-search/components/PaginationControls.tsx +109 -0
- package/brands/engine/app/features/object-search/components/SearchBar.tsx +41 -0
- package/brands/engine/app/features/object-search/components/SortControl.tsx +143 -0
- package/brands/engine/app/features/object-search/components/filters/BooleanFilter.tsx +78 -0
- package/brands/engine/app/features/object-search/components/filters/DateFilter.tsx +128 -0
- package/brands/engine/app/features/object-search/components/filters/DateRangeFilter.tsx +70 -0
- package/brands/engine/app/features/object-search/components/filters/FilterFieldWrapper.tsx +33 -0
- package/brands/engine/app/features/object-search/components/filters/MultiSelectFilter.tsx +97 -0
- package/brands/engine/app/features/object-search/components/filters/NumericRangeFilter.tsx +163 -0
- package/brands/engine/app/features/object-search/components/filters/SearchFilter.tsx +50 -0
- package/brands/engine/app/features/object-search/components/filters/SelectFilter.tsx +97 -0
- package/brands/engine/app/features/object-search/components/filters/TextFilter.tsx +91 -0
- package/brands/engine/app/features/object-search/hooks/useAsyncData.ts +54 -0
- package/brands/engine/app/features/object-search/hooks/useCachedAsyncData.ts +184 -0
- package/brands/engine/app/features/object-search/hooks/useDebouncedCallback.ts +34 -0
- package/brands/engine/app/features/object-search/hooks/useObjectSearchParams.ts +252 -0
- package/brands/engine/app/features/object-search/utils/debounce.ts +25 -0
- package/brands/engine/app/features/object-search/utils/fieldUtils.ts +29 -0
- package/brands/engine/app/features/object-search/utils/filterUtils.ts +404 -0
- package/brands/engine/app/features/object-search/utils/sortUtils.ts +38 -0
- package/brands/engine/app/hooks/useEngineLiveData.ts +49 -0
- package/brands/engine/app/hooks/useEvaAgent.ts +288 -0
- package/brands/engine/app/hooks/usePartnerDashboardData.ts +141 -0
- package/brands/engine/app/navigationMenu.tsx +80 -0
- package/brands/engine/app/pages/AccountObjectDetailPage.tsx +361 -0
- package/brands/engine/app/pages/AccountSearch.tsx +305 -0
- package/brands/engine/app/pages/BlankDashboard.tsx +15 -0
- package/brands/engine/app/pages/DataTest.tsx +78 -0
- package/brands/engine/app/pages/Home.tsx +5 -0
- package/brands/engine/app/pages/NotFound.tsx +19 -0
- package/brands/engine/app/pages/PartnerHubDashboard.tsx +2010 -0
- package/brands/engine/app/pages/Search.tsx +13 -0
- package/brands/engine/app/router-utils.tsx +35 -0
- package/brands/engine/app/routes.tsx +39 -0
- package/brands/engine/app/styles/global.css +270 -0
- package/package.json +1 -1
- package/scripts/apply-brand.mjs +159 -76
- package/scripts/postinstall.mjs +6 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Pagination,
|
|
3
|
+
PaginationContent,
|
|
4
|
+
PaginationItem,
|
|
5
|
+
PaginationPrevious,
|
|
6
|
+
PaginationNext,
|
|
7
|
+
} from "../../../components/ui/pagination";
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "../../../components/ui/select";
|
|
15
|
+
import { Label } from "../../../components/ui/label";
|
|
16
|
+
|
|
17
|
+
interface PaginationControlsProps {
|
|
18
|
+
pageIndex: number;
|
|
19
|
+
hasNextPage: boolean;
|
|
20
|
+
hasPreviousPage: boolean;
|
|
21
|
+
pageSize: number;
|
|
22
|
+
pageSizeOptions: readonly number[];
|
|
23
|
+
onNextPage: () => void;
|
|
24
|
+
onPreviousPage: () => void;
|
|
25
|
+
onPageSizeChange: (newPageSize: number) => void;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function PaginationControls({
|
|
30
|
+
pageIndex,
|
|
31
|
+
hasNextPage,
|
|
32
|
+
hasPreviousPage,
|
|
33
|
+
pageSize,
|
|
34
|
+
pageSizeOptions,
|
|
35
|
+
onNextPage,
|
|
36
|
+
onPreviousPage,
|
|
37
|
+
onPageSizeChange,
|
|
38
|
+
disabled = false,
|
|
39
|
+
}: PaginationControlsProps) {
|
|
40
|
+
const handlePageSizeChange = (newValue: string) => {
|
|
41
|
+
const newSize = parseInt(newValue, 10);
|
|
42
|
+
if (!isNaN(newSize) && newSize !== pageSize) {
|
|
43
|
+
onPageSizeChange(newSize);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const currentPage = pageIndex + 1;
|
|
47
|
+
const prevDisabled = disabled || !hasPreviousPage;
|
|
48
|
+
const nextDisabled = disabled || !hasNextPage;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="w-full grid grid-cols-1 sm:grid-cols-2 items-center justify-center gap-4 py-2">
|
|
52
|
+
<div
|
|
53
|
+
className="flex justify-center sm:justify-start items-center gap-2 shrink-0 row-2 sm:row-1"
|
|
54
|
+
role="group"
|
|
55
|
+
aria-label="Page size selector"
|
|
56
|
+
>
|
|
57
|
+
<Label htmlFor="page-size-select" className="text-sm font-normal whitespace-nowrap">
|
|
58
|
+
Results per page:
|
|
59
|
+
</Label>
|
|
60
|
+
<Select
|
|
61
|
+
value={pageSize.toString()}
|
|
62
|
+
onValueChange={handlePageSizeChange}
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
>
|
|
65
|
+
<SelectTrigger
|
|
66
|
+
id="page-size-select"
|
|
67
|
+
className="w-16"
|
|
68
|
+
aria-label="Select number of results per page"
|
|
69
|
+
>
|
|
70
|
+
<SelectValue />
|
|
71
|
+
</SelectTrigger>
|
|
72
|
+
<SelectContent>
|
|
73
|
+
{pageSizeOptions.map((size) => (
|
|
74
|
+
<SelectItem key={size} value={size.toString()}>
|
|
75
|
+
{size}
|
|
76
|
+
</SelectItem>
|
|
77
|
+
))}
|
|
78
|
+
</SelectContent>
|
|
79
|
+
</Select>
|
|
80
|
+
</div>
|
|
81
|
+
<Pagination className="w-full mx-0 sm:justify-end">
|
|
82
|
+
<PaginationContent>
|
|
83
|
+
<PaginationItem>
|
|
84
|
+
<PaginationPrevious
|
|
85
|
+
onClick={prevDisabled ? undefined : onPreviousPage}
|
|
86
|
+
aria-disabled={prevDisabled}
|
|
87
|
+
className={prevDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
88
|
+
/>
|
|
89
|
+
</PaginationItem>
|
|
90
|
+
<PaginationItem>
|
|
91
|
+
<span
|
|
92
|
+
className="min-w-16 text-center text-sm text-muted-foreground px-2"
|
|
93
|
+
aria-current="page"
|
|
94
|
+
>
|
|
95
|
+
Page {currentPage}
|
|
96
|
+
</span>
|
|
97
|
+
</PaginationItem>
|
|
98
|
+
<PaginationItem>
|
|
99
|
+
<PaginationNext
|
|
100
|
+
onClick={nextDisabled ? undefined : onNextPage}
|
|
101
|
+
aria-disabled={nextDisabled}
|
|
102
|
+
className={nextDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
103
|
+
/>
|
|
104
|
+
</PaginationItem>
|
|
105
|
+
</PaginationContent>
|
|
106
|
+
</Pagination>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Search } from "lucide-react";
|
|
2
|
+
import { Input } from "../../../components/ui/input";
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
|
|
5
|
+
interface SearchBarProps extends React.ComponentProps<"div"> {
|
|
6
|
+
value: string;
|
|
7
|
+
handleChange: (value: string) => void;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
iconProps?: React.ComponentProps<typeof Search>;
|
|
10
|
+
inputProps?: Omit<React.ComponentProps<typeof Input>, "value">;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SearchBar({
|
|
14
|
+
value,
|
|
15
|
+
handleChange,
|
|
16
|
+
placeholder,
|
|
17
|
+
className,
|
|
18
|
+
iconProps,
|
|
19
|
+
inputProps,
|
|
20
|
+
...props
|
|
21
|
+
}: SearchBarProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div className={cn("relative flex-1", className)} title={placeholder} {...props}>
|
|
24
|
+
<Search
|
|
25
|
+
{...iconProps}
|
|
26
|
+
className={cn(
|
|
27
|
+
"absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground",
|
|
28
|
+
iconProps?.className,
|
|
29
|
+
)}
|
|
30
|
+
/>
|
|
31
|
+
<Input
|
|
32
|
+
type="text"
|
|
33
|
+
value={value}
|
|
34
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
35
|
+
placeholder={placeholder}
|
|
36
|
+
{...inputProps}
|
|
37
|
+
className={cn("pl-9", inputProps?.className)}
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { ArrowUp, ArrowDown } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
Select,
|
|
4
|
+
SelectContent,
|
|
5
|
+
SelectItem,
|
|
6
|
+
SelectTrigger,
|
|
7
|
+
SelectValue,
|
|
8
|
+
} from "../../../components/ui/select";
|
|
9
|
+
import { Button } from "../../../components/ui/button";
|
|
10
|
+
import { cn } from "../../../lib/utils";
|
|
11
|
+
import type { SortFieldConfig, SortState } from "../utils/sortUtils";
|
|
12
|
+
|
|
13
|
+
const NONE_VALUE = "__none__";
|
|
14
|
+
|
|
15
|
+
interface SortControlProps extends React.ComponentProps<"div"> {
|
|
16
|
+
configs: SortFieldConfig[];
|
|
17
|
+
sort: SortState | null;
|
|
18
|
+
onSortChange: (sort: SortState | null) => void;
|
|
19
|
+
labelProps?: React.ComponentProps<"span">;
|
|
20
|
+
selectProps?: Omit<
|
|
21
|
+
React.ComponentProps<typeof SortControlSelect>,
|
|
22
|
+
"configs" | "sort" | "onSortChange"
|
|
23
|
+
>;
|
|
24
|
+
directionButtonProps?: Omit<
|
|
25
|
+
React.ComponentProps<typeof SortDirectionButton>,
|
|
26
|
+
"sort" | "onSortChange"
|
|
27
|
+
>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function SortControl({
|
|
31
|
+
configs,
|
|
32
|
+
sort,
|
|
33
|
+
onSortChange,
|
|
34
|
+
className,
|
|
35
|
+
labelProps,
|
|
36
|
+
selectProps,
|
|
37
|
+
directionButtonProps,
|
|
38
|
+
...props
|
|
39
|
+
}: SortControlProps) {
|
|
40
|
+
return (
|
|
41
|
+
<div className={cn("flex items-center gap-2", className)} {...props}>
|
|
42
|
+
<span
|
|
43
|
+
{...labelProps}
|
|
44
|
+
className={cn("text-sm text-muted-foreground whitespace-nowrap", labelProps?.className)}
|
|
45
|
+
>
|
|
46
|
+
{labelProps?.children ?? "Sort by"}
|
|
47
|
+
</span>
|
|
48
|
+
<SortControlSelect
|
|
49
|
+
configs={configs}
|
|
50
|
+
sort={sort}
|
|
51
|
+
onSortChange={onSortChange}
|
|
52
|
+
{...selectProps}
|
|
53
|
+
/>
|
|
54
|
+
{sort && (
|
|
55
|
+
<SortDirectionButton sort={sort} onSortChange={onSortChange} {...directionButtonProps} />
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface SortControlSelectProps {
|
|
62
|
+
configs: SortFieldConfig[];
|
|
63
|
+
sort: SortState | null;
|
|
64
|
+
onSortChange: (sort: SortState | null) => void;
|
|
65
|
+
triggerProps?: React.ComponentProps<typeof SelectTrigger>;
|
|
66
|
+
contentProps?: React.ComponentProps<typeof SelectContent>;
|
|
67
|
+
selectValueProps?: React.ComponentProps<typeof SelectValue>;
|
|
68
|
+
selectItemProps?: Omit<React.ComponentProps<typeof SelectItem>, "value">;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function SortControlSelect({
|
|
72
|
+
configs,
|
|
73
|
+
sort,
|
|
74
|
+
onSortChange,
|
|
75
|
+
triggerProps,
|
|
76
|
+
contentProps,
|
|
77
|
+
selectValueProps,
|
|
78
|
+
selectItemProps,
|
|
79
|
+
}: SortControlSelectProps) {
|
|
80
|
+
return (
|
|
81
|
+
<Select
|
|
82
|
+
value={sort?.field ?? NONE_VALUE}
|
|
83
|
+
onValueChange={(v) => {
|
|
84
|
+
if (v === NONE_VALUE) {
|
|
85
|
+
onSortChange(null);
|
|
86
|
+
} else {
|
|
87
|
+
onSortChange({
|
|
88
|
+
field: v,
|
|
89
|
+
direction: sort?.direction ?? "ASC",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<SelectTrigger
|
|
95
|
+
size="sm"
|
|
96
|
+
{...triggerProps}
|
|
97
|
+
className={cn("w-[160px]", triggerProps?.className)}
|
|
98
|
+
>
|
|
99
|
+
<SelectValue placeholder="Default" {...selectValueProps} />
|
|
100
|
+
</SelectTrigger>
|
|
101
|
+
<SelectContent {...contentProps}>
|
|
102
|
+
<SelectItem value={NONE_VALUE} {...selectItemProps}>
|
|
103
|
+
Default
|
|
104
|
+
</SelectItem>
|
|
105
|
+
{configs.map((c) => (
|
|
106
|
+
<SelectItem key={c.field} value={c.field} {...selectItemProps}>
|
|
107
|
+
{c.label}
|
|
108
|
+
</SelectItem>
|
|
109
|
+
))}
|
|
110
|
+
</SelectContent>
|
|
111
|
+
</Select>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface SortDirectionButtonProps extends React.ComponentProps<typeof Button> {
|
|
116
|
+
sort: SortState;
|
|
117
|
+
onSortChange: (sort: SortState) => void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function SortDirectionButton({
|
|
121
|
+
sort,
|
|
122
|
+
onSortChange,
|
|
123
|
+
className,
|
|
124
|
+
...props
|
|
125
|
+
}: SortDirectionButtonProps) {
|
|
126
|
+
return (
|
|
127
|
+
<Button
|
|
128
|
+
variant="ghost"
|
|
129
|
+
size="icon-sm"
|
|
130
|
+
className={cn(className)}
|
|
131
|
+
onClick={() =>
|
|
132
|
+
onSortChange({
|
|
133
|
+
...sort,
|
|
134
|
+
direction: sort.direction === "ASC" ? "DESC" : "ASC",
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
aria-label={`Sort ${sort.direction === "ASC" ? "descending" : "ascending"}`}
|
|
138
|
+
{...props}
|
|
139
|
+
>
|
|
140
|
+
{sort.direction === "ASC" ? <ArrowUp /> : <ArrowDown />}
|
|
141
|
+
</Button>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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 BooleanFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
16
|
+
field: string;
|
|
17
|
+
label: string;
|
|
18
|
+
helpText?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function BooleanFilter({ field, label, helpText, className, ...props }: BooleanFilterProps) {
|
|
22
|
+
const { value, onChange } = useFilterField(field);
|
|
23
|
+
return (
|
|
24
|
+
<FilterFieldWrapper
|
|
25
|
+
label={label}
|
|
26
|
+
htmlFor={`filter-${field}`}
|
|
27
|
+
helpText={helpText}
|
|
28
|
+
className={className}
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
<BooleanFilterSelect field={field} label={label} value={value} onChange={onChange} />
|
|
32
|
+
</FilterFieldWrapper>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface BooleanFilterSelectProps {
|
|
37
|
+
field: string;
|
|
38
|
+
label: string;
|
|
39
|
+
value: ActiveFilterValue | undefined;
|
|
40
|
+
onChange: (value: ActiveFilterValue | undefined) => void;
|
|
41
|
+
triggerProps?: React.ComponentProps<typeof SelectTrigger>;
|
|
42
|
+
contentProps?: React.ComponentProps<typeof SelectContent>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function BooleanFilterSelect({
|
|
46
|
+
field,
|
|
47
|
+
label,
|
|
48
|
+
value,
|
|
49
|
+
onChange,
|
|
50
|
+
triggerProps,
|
|
51
|
+
contentProps,
|
|
52
|
+
}: BooleanFilterSelectProps) {
|
|
53
|
+
return (
|
|
54
|
+
<Select
|
|
55
|
+
value={value?.value ?? ALL_VALUE}
|
|
56
|
+
onValueChange={(v) => {
|
|
57
|
+
if (v === ALL_VALUE) {
|
|
58
|
+
onChange(undefined);
|
|
59
|
+
} else {
|
|
60
|
+
onChange({ field, label, type: "boolean", value: v });
|
|
61
|
+
}
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<SelectTrigger
|
|
65
|
+
id={`filter-${field}`}
|
|
66
|
+
{...triggerProps}
|
|
67
|
+
className={cn("w-full", triggerProps?.className)}
|
|
68
|
+
>
|
|
69
|
+
<SelectValue />
|
|
70
|
+
</SelectTrigger>
|
|
71
|
+
<SelectContent {...contentProps}>
|
|
72
|
+
<SelectItem value={ALL_VALUE}>All</SelectItem>
|
|
73
|
+
<SelectItem value="true">Yes</SelectItem>
|
|
74
|
+
<SelectItem value="false">No</SelectItem>
|
|
75
|
+
</SelectContent>
|
|
76
|
+
</Select>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { parseISO } from "date-fns";
|
|
3
|
+
import {
|
|
4
|
+
DatePicker,
|
|
5
|
+
DatePickerTrigger,
|
|
6
|
+
DatePickerContent,
|
|
7
|
+
DatePickerCalendar,
|
|
8
|
+
} from "../../../../components/ui/datePicker";
|
|
9
|
+
|
|
10
|
+
import { useFilterField } from "../FilterContext";
|
|
11
|
+
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
12
|
+
import type { FilterFieldType } from "../../utils/filterUtils";
|
|
13
|
+
import {
|
|
14
|
+
Select,
|
|
15
|
+
SelectContent,
|
|
16
|
+
SelectItem,
|
|
17
|
+
SelectTrigger,
|
|
18
|
+
SelectValue,
|
|
19
|
+
} from "../../../../components/ui/select";
|
|
20
|
+
|
|
21
|
+
type DateOperator = "gt" | "lt";
|
|
22
|
+
|
|
23
|
+
const OPERATOR_OPTIONS: { value: DateOperator; label: string }[] = [
|
|
24
|
+
{ value: "gt", label: "After" },
|
|
25
|
+
{ value: "lt", label: "Before" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function operatorToField(op: DateOperator): "min" | "max" {
|
|
29
|
+
return op === "gt" ? "min" : "max";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DateFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
33
|
+
field: string;
|
|
34
|
+
label: string;
|
|
35
|
+
helpText?: string;
|
|
36
|
+
filterType?: FilterFieldType;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function DateFilter({
|
|
40
|
+
field,
|
|
41
|
+
label,
|
|
42
|
+
helpText,
|
|
43
|
+
filterType = "date",
|
|
44
|
+
className,
|
|
45
|
+
...props
|
|
46
|
+
}: DateFilterProps) {
|
|
47
|
+
const { value, onChange } = useFilterField(field);
|
|
48
|
+
|
|
49
|
+
const initialOp: DateOperator = value?.min ? "gt" : "lt";
|
|
50
|
+
const [operator, setOperator] = useState<DateOperator>(initialOp);
|
|
51
|
+
|
|
52
|
+
const currentDate = toDate(value?.min ?? value?.max);
|
|
53
|
+
|
|
54
|
+
function handleOperatorChange(op: DateOperator) {
|
|
55
|
+
setOperator(op);
|
|
56
|
+
if (currentDate) {
|
|
57
|
+
emitChange(op, currentDate);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleDateChange(date: Date | undefined) {
|
|
62
|
+
if (!date) {
|
|
63
|
+
onChange(undefined);
|
|
64
|
+
} else {
|
|
65
|
+
emitChange(operator, date);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function emitChange(op: DateOperator, date: Date) {
|
|
70
|
+
const dateStr = toDateString(date);
|
|
71
|
+
const f = operatorToField(op);
|
|
72
|
+
onChange({
|
|
73
|
+
field,
|
|
74
|
+
label,
|
|
75
|
+
type: filterType,
|
|
76
|
+
value: op,
|
|
77
|
+
min: f === "min" ? dateStr : undefined,
|
|
78
|
+
max: f === "max" ? dateStr : undefined,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
|
|
84
|
+
<div className="flex gap-2">
|
|
85
|
+
<Select value={operator} onValueChange={(v) => handleOperatorChange(v as DateOperator)}>
|
|
86
|
+
<SelectTrigger className="w-full flex-1">
|
|
87
|
+
<SelectValue />
|
|
88
|
+
</SelectTrigger>
|
|
89
|
+
<SelectContent>
|
|
90
|
+
{OPERATOR_OPTIONS.map((opt) => (
|
|
91
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
92
|
+
{opt.label}
|
|
93
|
+
</SelectItem>
|
|
94
|
+
))}
|
|
95
|
+
</SelectContent>
|
|
96
|
+
</Select>
|
|
97
|
+
<DatePicker>
|
|
98
|
+
<DatePickerTrigger
|
|
99
|
+
className="w-full flex-2"
|
|
100
|
+
date={currentDate}
|
|
101
|
+
dateFormat="MMM do, yyyy"
|
|
102
|
+
placeholder="Pick a date"
|
|
103
|
+
aria-label={label}
|
|
104
|
+
/>
|
|
105
|
+
<DatePickerContent>
|
|
106
|
+
<DatePickerCalendar
|
|
107
|
+
mode="single"
|
|
108
|
+
captionLayout="dropdown"
|
|
109
|
+
selected={currentDate}
|
|
110
|
+
onSelect={handleDateChange}
|
|
111
|
+
/>
|
|
112
|
+
</DatePickerContent>
|
|
113
|
+
</DatePicker>
|
|
114
|
+
</div>
|
|
115
|
+
</FilterFieldWrapper>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function toDate(value: string | undefined): Date | undefined {
|
|
120
|
+
if (!value) return undefined;
|
|
121
|
+
const parsed = parseISO(value);
|
|
122
|
+
return isNaN(parsed.getTime()) ? undefined : parsed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function toDateString(date: Date | undefined): string {
|
|
126
|
+
if (!date) return "";
|
|
127
|
+
return date.toISOString().split("T")[0];
|
|
128
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { DateRange } from "react-day-picker";
|
|
2
|
+
import {
|
|
3
|
+
DatePicker,
|
|
4
|
+
DatePickerRangeTrigger,
|
|
5
|
+
DatePickerContent,
|
|
6
|
+
DatePickerCalendar,
|
|
7
|
+
} from "../../../../components/ui/datePicker";
|
|
8
|
+
|
|
9
|
+
import { useFilterField } from "../FilterContext";
|
|
10
|
+
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
11
|
+
import type { FilterFieldType } from "../../utils/filterUtils";
|
|
12
|
+
import { toDate, toDateString } from "./DateFilter";
|
|
13
|
+
|
|
14
|
+
interface DateRangeFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
15
|
+
field: string;
|
|
16
|
+
label: string;
|
|
17
|
+
helpText?: string;
|
|
18
|
+
filterType?: FilterFieldType;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function DateRangeFilter({
|
|
22
|
+
field,
|
|
23
|
+
label,
|
|
24
|
+
helpText,
|
|
25
|
+
filterType = "daterange",
|
|
26
|
+
className,
|
|
27
|
+
...props
|
|
28
|
+
}: DateRangeFilterProps) {
|
|
29
|
+
const { value, onChange } = useFilterField(field);
|
|
30
|
+
|
|
31
|
+
const dateRange: DateRange | undefined =
|
|
32
|
+
value?.min || value?.max ? { from: toDate(value?.min), to: toDate(value?.max) } : undefined;
|
|
33
|
+
|
|
34
|
+
function handleRangeSelect(range: DateRange | undefined) {
|
|
35
|
+
if (!range?.from && !range?.to) {
|
|
36
|
+
onChange(undefined);
|
|
37
|
+
} else {
|
|
38
|
+
onChange({
|
|
39
|
+
field,
|
|
40
|
+
label,
|
|
41
|
+
type: filterType,
|
|
42
|
+
min: toDateString(range?.from),
|
|
43
|
+
max: toDateString(range?.to),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
|
|
50
|
+
<DatePicker>
|
|
51
|
+
<DatePickerRangeTrigger
|
|
52
|
+
className="w-full"
|
|
53
|
+
dateRange={dateRange}
|
|
54
|
+
placeholder="Pick a date range"
|
|
55
|
+
aria-label={label}
|
|
56
|
+
/>
|
|
57
|
+
<DatePickerContent align="start">
|
|
58
|
+
<DatePickerCalendar
|
|
59
|
+
mode="range"
|
|
60
|
+
captionLayout="dropdown"
|
|
61
|
+
defaultMonth={dateRange?.from}
|
|
62
|
+
selected={dateRange}
|
|
63
|
+
onSelect={handleRangeSelect}
|
|
64
|
+
numberOfMonths={2}
|
|
65
|
+
/>
|
|
66
|
+
</DatePickerContent>
|
|
67
|
+
</DatePicker>
|
|
68
|
+
</FilterFieldWrapper>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Label } from "../../../../components/ui/label";
|
|
2
|
+
import { cn } from "../../../../lib/utils";
|
|
3
|
+
|
|
4
|
+
interface FilterFieldWrapperProps extends React.ComponentProps<"div"> {
|
|
5
|
+
label: string;
|
|
6
|
+
htmlFor?: string;
|
|
7
|
+
helpText?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function FilterFieldWrapper({
|
|
12
|
+
label,
|
|
13
|
+
htmlFor,
|
|
14
|
+
helpText,
|
|
15
|
+
error,
|
|
16
|
+
className,
|
|
17
|
+
children,
|
|
18
|
+
...props
|
|
19
|
+
}: FilterFieldWrapperProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className={cn("space-y-1", className)} {...props}>
|
|
22
|
+
<Label htmlFor={htmlFor}>{label}</Label>
|
|
23
|
+
{children}
|
|
24
|
+
<div className="min-h-4">
|
|
25
|
+
{error ? (
|
|
26
|
+
<p className="text-xs text-destructive">{error}</p>
|
|
27
|
+
) : (
|
|
28
|
+
helpText && <p className="text-xs text-muted-foreground">{helpText}</p>
|
|
29
|
+
)}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Popover,
|
|
3
|
+
PopoverContent,
|
|
4
|
+
PopoverTrigger,
|
|
5
|
+
} from "../../../../components/ui/popover";
|
|
6
|
+
import { Checkbox } from "../../../../components/ui/checkbox";
|
|
7
|
+
import { Button } from "../../../../components/ui/button";
|
|
8
|
+
import { cn } from "../../../../lib/utils";
|
|
9
|
+
import { Label } from "../../../../components/ui/label";
|
|
10
|
+
import { ChevronDown } from "lucide-react";
|
|
11
|
+
import { useFilterField } from "../FilterContext";
|
|
12
|
+
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
13
|
+
|
|
14
|
+
interface MultiSelectFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
15
|
+
field: string;
|
|
16
|
+
label: string;
|
|
17
|
+
options: Array<{ value: string; label: string }>;
|
|
18
|
+
helpText?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function MultiSelectFilter({
|
|
22
|
+
field,
|
|
23
|
+
label,
|
|
24
|
+
options,
|
|
25
|
+
helpText,
|
|
26
|
+
className,
|
|
27
|
+
...props
|
|
28
|
+
}: MultiSelectFilterProps) {
|
|
29
|
+
const { value, onChange } = useFilterField(field);
|
|
30
|
+
const selected = value?.value ? value.value.split(",") : [];
|
|
31
|
+
|
|
32
|
+
const triggerLabel =
|
|
33
|
+
selected.length === 0
|
|
34
|
+
? `Select ${label.toLowerCase()}`
|
|
35
|
+
: selected.length === 1
|
|
36
|
+
? (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,
|
|
49
|
+
label,
|
|
50
|
+
type: "multipicklist",
|
|
51
|
+
value: next.join(","),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
|
|
58
|
+
<Popover>
|
|
59
|
+
<PopoverTrigger asChild>
|
|
60
|
+
<Button
|
|
61
|
+
variant="outline"
|
|
62
|
+
role="combobox"
|
|
63
|
+
className={cn(
|
|
64
|
+
"w-full justify-between font-normal",
|
|
65
|
+
selected.length === 0 && "text-muted-foreground",
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
<span className="truncate">{triggerLabel}</span>
|
|
69
|
+
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
70
|
+
</Button>
|
|
71
|
+
</PopoverTrigger>
|
|
72
|
+
<PopoverContent className="p-2" align="start">
|
|
73
|
+
<div className="max-h-48 overflow-y-auto space-y-1">
|
|
74
|
+
{options.map((opt) => {
|
|
75
|
+
const id = `filter-${field}-${opt.value}`;
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
key={opt.value}
|
|
79
|
+
className="flex items-center gap-2 rounded px-1 py-0.5 hover:bg-accent"
|
|
80
|
+
>
|
|
81
|
+
<Checkbox
|
|
82
|
+
id={id}
|
|
83
|
+
checked={selected.includes(opt.value)}
|
|
84
|
+
onCheckedChange={() => handleToggle(opt.value)}
|
|
85
|
+
/>
|
|
86
|
+
<Label htmlFor={id} className="text-sm font-normal cursor-pointer w-full">
|
|
87
|
+
{opt.label}
|
|
88
|
+
</Label>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
</PopoverContent>
|
|
94
|
+
</Popover>
|
|
95
|
+
</FilterFieldWrapper>
|
|
96
|
+
);
|
|
97
|
+
}
|