@salesforce/ui-bundle-template-app-react-template-b2x 11.17.1 → 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 +16 -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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders one SourceSection per configured source, in declaration order.
|
|
3
|
+
*
|
|
4
|
+
* Customization is optional — sensible defaults kick in for any source you
|
|
5
|
+
* don't override:
|
|
6
|
+
* - `renderResult[sourceKey]` — per-node renderer. Default: title from
|
|
7
|
+
* `displayFields[0]`, subtitle from the rest, optional `<Link>` driven
|
|
8
|
+
* by `routePattern`.
|
|
9
|
+
* - `renderFilters[sourceKey]` — sidebar filter UI. Default: one input per
|
|
10
|
+
* `filterBy` entry, with picklist options resolved from inline
|
|
11
|
+
* config or auto-fetched from the GraphQL aggregate API.
|
|
12
|
+
*
|
|
13
|
+
* Pass `false` for either entry to suppress the default for that source.
|
|
14
|
+
*
|
|
15
|
+
* Filter chrome (sidebar + active-filter chips + sort dropdown) is
|
|
16
|
+
* automatically hidden when `handle.scope === "all"` — per-source controls
|
|
17
|
+
* would be unreachable with every source on screen, and dangling chips
|
|
18
|
+
* would be confusing. Pre-existing filter / sort selections stay in state,
|
|
19
|
+
* so narrowing the scope brings them back unchanged.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ReactNode } from "react";
|
|
23
|
+
import { SourceSection } from "./SourceSection";
|
|
24
|
+
import { DefaultResultRow } from "./results/DefaultResultRow";
|
|
25
|
+
import { DefaultFilterPanel } from "./filters/DefaultFilterPanel";
|
|
26
|
+
import { ALL_SCOPE, type SearchHandle } from "../types";
|
|
27
|
+
|
|
28
|
+
type ResultRenderer = ((node: unknown) => ReactNode) | false;
|
|
29
|
+
type FilterRenderer = (() => ReactNode) | false;
|
|
30
|
+
|
|
31
|
+
interface SearchResultsProps {
|
|
32
|
+
handle: SearchHandle;
|
|
33
|
+
renderResult?: Record<string, ResultRenderer>;
|
|
34
|
+
renderFilters?: Record<string, FilterRenderer>;
|
|
35
|
+
emptyMessages?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function SearchResults({
|
|
39
|
+
handle,
|
|
40
|
+
renderResult,
|
|
41
|
+
renderFilters,
|
|
42
|
+
emptyMessages,
|
|
43
|
+
}: SearchResultsProps) {
|
|
44
|
+
const filtersHidden = handle.scope === ALL_SCOPE;
|
|
45
|
+
return (
|
|
46
|
+
<div className="space-y-10">
|
|
47
|
+
{Object.entries(handle.sources).map(([key, controller]) => {
|
|
48
|
+
if (handle.scope !== ALL_SCOPE && handle.scope !== key) return null;
|
|
49
|
+
const overrideResult = renderResult?.[key];
|
|
50
|
+
if (overrideResult === false) return null;
|
|
51
|
+
const resolvedRenderResult: (node: unknown) => ReactNode =
|
|
52
|
+
overrideResult ?? ((node) => <DefaultResultRow node={node} source={controller.config} />);
|
|
53
|
+
|
|
54
|
+
const overrideFilters = renderFilters?.[key];
|
|
55
|
+
const hasFilterFields = (controller.config.filterBy?.length ?? 0) > 0;
|
|
56
|
+
const resolvedRenderFilters: (() => ReactNode) | undefined =
|
|
57
|
+
overrideFilters === false
|
|
58
|
+
? undefined
|
|
59
|
+
: (overrideFilters ??
|
|
60
|
+
(hasFilterFields
|
|
61
|
+
? () => <DefaultFilterPanel source={controller.config} />
|
|
62
|
+
: undefined));
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<SourceSection
|
|
66
|
+
key={key}
|
|
67
|
+
controller={controller}
|
|
68
|
+
renderResult={resolvedRenderResult}
|
|
69
|
+
renderFilters={resolvedRenderFilters}
|
|
70
|
+
hideFilterChrome={filtersHidden}
|
|
71
|
+
emptyMessage={emptyMessages?.[key]}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders one source's slice of a search result: optional filter
|
|
3
|
+
* panel, sort dropdown, active-filter chips, the result list (delegated to
|
|
4
|
+
* the caller via `renderResult`), and pagination controls.
|
|
5
|
+
*
|
|
6
|
+
* The component is "smart" only about orchestration. The actual list-item UI
|
|
7
|
+
* is owned by the application via the `renderResult(node)` callback —
|
|
8
|
+
* search never decides how an Account or Contact card looks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
import { AlertCircle, SearchX } from "lucide-react";
|
|
13
|
+
import { Alert, AlertDescription, AlertTitle } from "../../../components/ui/alert";
|
|
14
|
+
import { Skeleton } from "../../../components/ui/skeleton";
|
|
15
|
+
import { ActiveFilters } from "./filters/ActiveFilters";
|
|
16
|
+
import { FilterProvider, FilterResetButton } from "./filters/FilterContext";
|
|
17
|
+
import { PaginationControls } from "./controls/PaginationControls";
|
|
18
|
+
import { SortControl } from "./controls/SortControl";
|
|
19
|
+
import type { SourceController } from "../types";
|
|
20
|
+
|
|
21
|
+
interface SourceSectionProps {
|
|
22
|
+
controller: SourceController;
|
|
23
|
+
renderResult: (node: unknown) => ReactNode;
|
|
24
|
+
/** Optional filter UI (TextFilter, SelectFilter, ...) rendered above the results. */
|
|
25
|
+
renderFilters?: () => ReactNode;
|
|
26
|
+
/**
|
|
27
|
+
* Suppress the filter panel, the active-filter chips, and the sort
|
|
28
|
+
* dropdown. Pre-existing filter / sort selections stay in state (and
|
|
29
|
+
* apply to the GraphQL query) so the caller can restore the chrome
|
|
30
|
+
* without losing the user's selections, but no chrome surface is rendered.
|
|
31
|
+
*
|
|
32
|
+
* Set by `<SearchResults>` when the current scope is "all" — per-source
|
|
33
|
+
* controls would be unreachable while every source is on screen, and
|
|
34
|
+
* dangling chips would be confusing.
|
|
35
|
+
*/
|
|
36
|
+
hideFilterChrome?: boolean;
|
|
37
|
+
emptyMessage?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function SourceSection({
|
|
42
|
+
controller,
|
|
43
|
+
renderResult,
|
|
44
|
+
renderFilters,
|
|
45
|
+
hideFilterChrome,
|
|
46
|
+
emptyMessage,
|
|
47
|
+
className,
|
|
48
|
+
}: SourceSectionProps) {
|
|
49
|
+
const { config, result, loading, error, filters, sort, pagination } = controller;
|
|
50
|
+
const nodes = result?.nodes ?? [];
|
|
51
|
+
const totalCount = result?.totalCount;
|
|
52
|
+
const showResults = !loading && !error && nodes.length > 0;
|
|
53
|
+
const showEmpty = !loading && !error && nodes.length === 0;
|
|
54
|
+
|
|
55
|
+
const pageSizeOptions = pagination.pageSizeOptions ?? [pagination.pageSize];
|
|
56
|
+
|
|
57
|
+
const showFilterPanel = !!renderFilters && !hideFilterChrome;
|
|
58
|
+
const showActiveChips = !hideFilterChrome && filters.active.length > 0;
|
|
59
|
+
const showSortControl = !hideFilterChrome && (config.sortBy?.length ?? 0) > 0;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<section className={`flex flex-col gap-6 ${className ?? ""}`}>
|
|
63
|
+
{showFilterPanel && (
|
|
64
|
+
<FilterProvider
|
|
65
|
+
filters={filters.active}
|
|
66
|
+
onFilterChange={filters.set}
|
|
67
|
+
onFilterRemove={filters.remove}
|
|
68
|
+
onReset={() => filters.active.forEach((f) => filters.remove(f.field))}
|
|
69
|
+
>
|
|
70
|
+
<div className="rounded-md border p-3 space-y-3">
|
|
71
|
+
<div className="flex items-center justify-between">
|
|
72
|
+
<h3 className="text-sm font-semibold">Filters</h3>
|
|
73
|
+
<FilterResetButton size="sm" />
|
|
74
|
+
</div>
|
|
75
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
76
|
+
{renderFilters!()}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</FilterProvider>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
<div className="min-w-0">
|
|
83
|
+
<header className="flex flex-wrap items-baseline gap-3 mb-3">
|
|
84
|
+
<h2 className="text-lg font-semibold">{config.label}</h2>
|
|
85
|
+
</header>
|
|
86
|
+
|
|
87
|
+
{(showSortControl || showActiveChips) && (
|
|
88
|
+
<div className="flex flex-wrap items-center gap-2 mb-3">
|
|
89
|
+
{showSortControl && config.sortBy ? (
|
|
90
|
+
<SortControl configs={config.sortBy} sort={sort.current} onSortChange={sort.set} />
|
|
91
|
+
) : null}
|
|
92
|
+
{showActiveChips && (
|
|
93
|
+
<ActiveFilters filters={filters.active} onRemove={filters.remove} />
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{/* Result count, shown just above the results in every scope —
|
|
99
|
+
including a locked / single-source (restrictTo) view. */}
|
|
100
|
+
{!loading && !error && totalCount != null && (
|
|
101
|
+
<p className="text-sm text-muted-foreground mb-3">
|
|
102
|
+
{totalCount} {totalCount === 1 ? "result" : "results"}
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<div className="min-h-32">
|
|
107
|
+
{loading && (
|
|
108
|
+
<div className="space-y-2">
|
|
109
|
+
{Array.from({ length: Math.min(pagination.pageSize, 5) }, (_, i) => (
|
|
110
|
+
<Skeleton key={i} className="h-12 w-full" />
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{error && (
|
|
116
|
+
<Alert variant="destructive" role="alert">
|
|
117
|
+
<AlertCircle />
|
|
118
|
+
<AlertTitle>Failed to load {config.label}</AlertTitle>
|
|
119
|
+
<AlertDescription>{error}</AlertDescription>
|
|
120
|
+
</Alert>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{showResults && (
|
|
124
|
+
<ul className="divide-y">
|
|
125
|
+
{nodes.map((node, i) => (
|
|
126
|
+
<li key={getNodeKey(node, config.idField, i)} className="py-3">
|
|
127
|
+
{renderResult(node)}
|
|
128
|
+
</li>
|
|
129
|
+
))}
|
|
130
|
+
</ul>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{showEmpty && (
|
|
134
|
+
<div className="flex flex-col items-center justify-center py-10 text-center">
|
|
135
|
+
<SearchX className="size-10 text-muted-foreground mb-3" />
|
|
136
|
+
<p className="text-sm text-muted-foreground">
|
|
137
|
+
{emptyMessage ?? `No ${config.label.toLowerCase()} found.`}
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<PaginationControls
|
|
144
|
+
pageIndex={pagination.pageIndex}
|
|
145
|
+
hasNextPage={pagination.hasNextPage}
|
|
146
|
+
hasPreviousPage={pagination.hasPreviousPage}
|
|
147
|
+
pageSize={pagination.pageSize}
|
|
148
|
+
pageSizeOptions={pageSizeOptions}
|
|
149
|
+
onNextPage={pagination.goToNextPage}
|
|
150
|
+
onPreviousPage={pagination.goToPreviousPage}
|
|
151
|
+
onPageSizeChange={pagination.setPageSize}
|
|
152
|
+
disabled={loading || !!error}
|
|
153
|
+
idPrefix={`page-size-${config.key}`}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
</section>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getNodeKey(node: unknown, idField: string | undefined, fallback: number): string | number {
|
|
161
|
+
if (node && typeof node === "object") {
|
|
162
|
+
const id = (node as Record<string, unknown>)[idField ?? "Id"];
|
|
163
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
164
|
+
}
|
|
165
|
+
return fallback;
|
|
166
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Pagination,
|
|
3
|
+
PaginationContent,
|
|
4
|
+
PaginationEllipsis,
|
|
5
|
+
PaginationItem,
|
|
6
|
+
PaginationLink,
|
|
7
|
+
PaginationNext,
|
|
8
|
+
PaginationPrevious,
|
|
9
|
+
} from "../../../../components/ui/pagination";
|
|
10
|
+
import {
|
|
11
|
+
Select,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
SelectTrigger,
|
|
15
|
+
SelectValue,
|
|
16
|
+
} from "../../../../components/ui/select";
|
|
17
|
+
import { Label } from "../../../../components/ui/label";
|
|
18
|
+
import type { KeyboardEvent } from "react";
|
|
19
|
+
|
|
20
|
+
interface PaginationControlsProps {
|
|
21
|
+
pageIndex: number;
|
|
22
|
+
hasNextPage: boolean;
|
|
23
|
+
hasPreviousPage: boolean;
|
|
24
|
+
pageSize: number;
|
|
25
|
+
pageSizeOptions: readonly number[];
|
|
26
|
+
onNextPage: () => void;
|
|
27
|
+
onPreviousPage: () => void;
|
|
28
|
+
onPageSizeChange: (size: number) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Total number of pages. When provided (with {@link onGoToPage}) and `> 1`,
|
|
31
|
+
* the control renders numbered page buttons with ellipsis truncation instead
|
|
32
|
+
* of a plain "Page N" label. Omit for cursor-style sources where the total
|
|
33
|
+
* page count is unknown.
|
|
34
|
+
*/
|
|
35
|
+
pageCount?: number;
|
|
36
|
+
/** Jump to a 0-based page. Required to render numbered page buttons. */
|
|
37
|
+
onGoToPage?: (pageIndex: number) => void;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
idPrefix?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Builds the list of page tokens to render: 0-based page numbers plus
|
|
44
|
+
* `"ellipsis"` gap markers. Always shows the first and last page, a window
|
|
45
|
+
* around the current page, and collapses the rest. e.g. for current=5,
|
|
46
|
+
* total=12 → [0, ellipsis, 4, 5, 6, ellipsis, 11].
|
|
47
|
+
*/
|
|
48
|
+
function getPageTokens(currentIndex: number, pageCount: number): Array<number | "ellipsis"> {
|
|
49
|
+
const SIBLINGS = 1; // pages shown on each side of the current page
|
|
50
|
+
const last = pageCount - 1;
|
|
51
|
+
const pages = new Set<number>([0, last]);
|
|
52
|
+
for (let p = currentIndex - SIBLINGS; p <= currentIndex + SIBLINGS; p++) {
|
|
53
|
+
if (p >= 0 && p <= last) pages.add(p);
|
|
54
|
+
}
|
|
55
|
+
const sorted = [...pages].sort((a, b) => a - b);
|
|
56
|
+
const tokens: Array<number | "ellipsis"> = [];
|
|
57
|
+
let prev = -1;
|
|
58
|
+
for (const p of sorted) {
|
|
59
|
+
if (prev !== -1 && p - prev > 1) tokens.push("ellipsis");
|
|
60
|
+
tokens.push(p);
|
|
61
|
+
prev = p;
|
|
62
|
+
}
|
|
63
|
+
return tokens;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function PaginationControls({
|
|
67
|
+
pageIndex,
|
|
68
|
+
hasNextPage,
|
|
69
|
+
hasPreviousPage,
|
|
70
|
+
pageSize,
|
|
71
|
+
pageSizeOptions,
|
|
72
|
+
onNextPage,
|
|
73
|
+
onPreviousPage,
|
|
74
|
+
onPageSizeChange,
|
|
75
|
+
pageCount,
|
|
76
|
+
onGoToPage,
|
|
77
|
+
disabled = false,
|
|
78
|
+
idPrefix = "page-size",
|
|
79
|
+
}: PaginationControlsProps) {
|
|
80
|
+
const handlePageSizeChange = (next: string) => {
|
|
81
|
+
const size = parseInt(next, 10);
|
|
82
|
+
if (!isNaN(size) && size !== pageSize) onPageSizeChange(size);
|
|
83
|
+
};
|
|
84
|
+
const prevDisabled = disabled || !hasPreviousPage;
|
|
85
|
+
const nextDisabled = disabled || !hasNextPage;
|
|
86
|
+
const selectId = `${idPrefix}-select`;
|
|
87
|
+
|
|
88
|
+
// Numbered page buttons require both a known page count and a seek callback.
|
|
89
|
+
const showNumberedPages = pageCount != null && pageCount > 1 && onGoToPage != null;
|
|
90
|
+
|
|
91
|
+
// The underlying pagination control renders an <a> without an href, so it is
|
|
92
|
+
// not keyboard-operable on its own. Activate it on Enter/Space to match the
|
|
93
|
+
// behaviour of a native button (WCAG 2.2 SC 2.1.1 Keyboard).
|
|
94
|
+
const handleActivationKey = (action: () => void) => (event: KeyboardEvent) => {
|
|
95
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
action();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="w-full grid grid-cols-1 sm:grid-cols-2 items-center gap-4 py-2">
|
|
103
|
+
<div className="flex justify-center sm:justify-start items-center gap-2">
|
|
104
|
+
<Label htmlFor={selectId} className="text-sm font-normal whitespace-nowrap">
|
|
105
|
+
Results per page:
|
|
106
|
+
</Label>
|
|
107
|
+
<Select
|
|
108
|
+
value={pageSize.toString()}
|
|
109
|
+
onValueChange={handlePageSizeChange}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
>
|
|
112
|
+
<SelectTrigger id={selectId} className="w-16">
|
|
113
|
+
<SelectValue />
|
|
114
|
+
</SelectTrigger>
|
|
115
|
+
<SelectContent>
|
|
116
|
+
{pageSizeOptions.map((size) => (
|
|
117
|
+
<SelectItem key={size} value={size.toString()}>
|
|
118
|
+
{size}
|
|
119
|
+
</SelectItem>
|
|
120
|
+
))}
|
|
121
|
+
</SelectContent>
|
|
122
|
+
</Select>
|
|
123
|
+
</div>
|
|
124
|
+
<Pagination className="w-full mx-0 sm:justify-end">
|
|
125
|
+
<PaginationContent>
|
|
126
|
+
<PaginationItem>
|
|
127
|
+
<PaginationPrevious
|
|
128
|
+
role="button"
|
|
129
|
+
tabIndex={prevDisabled ? -1 : 0}
|
|
130
|
+
onClick={prevDisabled ? undefined : onPreviousPage}
|
|
131
|
+
onKeyDown={prevDisabled ? undefined : handleActivationKey(onPreviousPage)}
|
|
132
|
+
aria-disabled={prevDisabled}
|
|
133
|
+
className={prevDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
134
|
+
/>
|
|
135
|
+
</PaginationItem>
|
|
136
|
+
|
|
137
|
+
{showNumberedPages ? (
|
|
138
|
+
getPageTokens(pageIndex, pageCount).map((token, i) =>
|
|
139
|
+
token === "ellipsis" ? (
|
|
140
|
+
<PaginationItem key={`ellipsis-${i}`}>
|
|
141
|
+
<PaginationEllipsis />
|
|
142
|
+
</PaginationItem>
|
|
143
|
+
) : (
|
|
144
|
+
<PaginationItem key={token}>
|
|
145
|
+
<PaginationLink
|
|
146
|
+
role="button"
|
|
147
|
+
tabIndex={disabled ? -1 : 0}
|
|
148
|
+
isActive={token === pageIndex}
|
|
149
|
+
aria-label={`Go to page ${token + 1}`}
|
|
150
|
+
aria-current={token === pageIndex ? "page" : undefined}
|
|
151
|
+
onClick={disabled ? undefined : () => onGoToPage(token)}
|
|
152
|
+
onKeyDown={disabled ? undefined : handleActivationKey(() => onGoToPage(token))}
|
|
153
|
+
aria-disabled={disabled}
|
|
154
|
+
className={disabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
155
|
+
>
|
|
156
|
+
{token + 1}
|
|
157
|
+
</PaginationLink>
|
|
158
|
+
</PaginationItem>
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
) : (
|
|
162
|
+
<PaginationItem>
|
|
163
|
+
<span className="min-w-16 text-center text-sm text-muted-foreground px-2">
|
|
164
|
+
{pageCount != null && pageCount > 0
|
|
165
|
+
? `Page ${pageIndex + 1} of ${pageCount}`
|
|
166
|
+
: `Page ${pageIndex + 1}`}
|
|
167
|
+
</span>
|
|
168
|
+
</PaginationItem>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
<PaginationItem>
|
|
172
|
+
<PaginationNext
|
|
173
|
+
role="button"
|
|
174
|
+
tabIndex={nextDisabled ? -1 : 0}
|
|
175
|
+
onClick={nextDisabled ? undefined : onNextPage}
|
|
176
|
+
onKeyDown={nextDisabled ? undefined : handleActivationKey(onNextPage)}
|
|
177
|
+
aria-disabled={nextDisabled}
|
|
178
|
+
className={nextDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
179
|
+
/>
|
|
180
|
+
</PaginationItem>
|
|
181
|
+
</PaginationContent>
|
|
182
|
+
</Pagination>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dropdown that narrows the search to a single source (or "All").
|
|
3
|
+
*
|
|
4
|
+
* Sits next to the search bar in the default {@link Search} layout. The
|
|
5
|
+
* options are derived from the configured sources: an "All" entry plus one
|
|
6
|
+
* entry per source (using each source's `label`).
|
|
7
|
+
*
|
|
8
|
+
* Selecting a non-"All" scope tells {@link useSearch} to fetch and render
|
|
9
|
+
* only that source. Switching scope resets per-source pagination cursors —
|
|
10
|
+
* the visible result set is changing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
Select,
|
|
15
|
+
SelectContent,
|
|
16
|
+
SelectItem,
|
|
17
|
+
SelectTrigger,
|
|
18
|
+
SelectValue,
|
|
19
|
+
} from "../../../../components/ui/select";
|
|
20
|
+
import { ALL_SCOPE, type SearchConfig, type SearchScope } from "../../types";
|
|
21
|
+
|
|
22
|
+
interface ScopeSelectorProps {
|
|
23
|
+
config: SearchConfig;
|
|
24
|
+
scope: SearchScope;
|
|
25
|
+
onScopeChange: (scope: SearchScope) => void;
|
|
26
|
+
allLabel?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ScopeSelector({
|
|
31
|
+
config,
|
|
32
|
+
scope,
|
|
33
|
+
onScopeChange,
|
|
34
|
+
allLabel = "All",
|
|
35
|
+
className,
|
|
36
|
+
}: ScopeSelectorProps) {
|
|
37
|
+
if (config.sources.length === 0) return null;
|
|
38
|
+
return (
|
|
39
|
+
<div className={className}>
|
|
40
|
+
<Select value={scope} onValueChange={onScopeChange}>
|
|
41
|
+
<SelectTrigger size="sm" className="min-w-[140px]" aria-label="Search scope">
|
|
42
|
+
<SelectValue />
|
|
43
|
+
</SelectTrigger>
|
|
44
|
+
<SelectContent>
|
|
45
|
+
<SelectItem value={ALL_SCOPE}>{allLabel}</SelectItem>
|
|
46
|
+
{config.sources.map((source) => (
|
|
47
|
+
<SelectItem key={source.key} value={source.key}>
|
|
48
|
+
{source.label}
|
|
49
|
+
</SelectItem>
|
|
50
|
+
))}
|
|
51
|
+
</SelectContent>
|
|
52
|
+
</Select>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Search } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Input } from "../../../../components/ui/input";
|
|
4
|
+
import { debounce } from "../../utils/debounce";
|
|
5
|
+
|
|
6
|
+
const TYPE_DEBOUNCE_MS = 250;
|
|
7
|
+
|
|
8
|
+
interface SearchBarProps {
|
|
9
|
+
value: string;
|
|
10
|
+
onChange: (next: string) => void;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Debounced text input for the global `q` term. Keeps a local `draft` so
|
|
17
|
+
* keystrokes feel responsive while the upstream `onChange` is rate-limited.
|
|
18
|
+
*/
|
|
19
|
+
export function SearchBar({ value, onChange, placeholder = "Search…", className }: SearchBarProps) {
|
|
20
|
+
const [draft, setDraft] = useState(value);
|
|
21
|
+
const onChangeRef = useRef(onChange);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
onChangeRef.current = onChange;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Build the debounced caller in a mount effect, not during render: creating it
|
|
27
|
+
// inline would read onChangeRef.current at render time (react-hooks/refs). It
|
|
28
|
+
// stays a single stable instance and always calls the latest onChange via the ref.
|
|
29
|
+
const debouncedRef = useRef<((next: string) => void) | undefined>(undefined);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
debouncedRef.current = debounce((next: string) => onChangeRef.current(next), TYPE_DEBOUNCE_MS);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
// External value can change (URL restore, reset). Keep draft in sync.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setDraft(value);
|
|
37
|
+
}, [value]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className={`relative flex-1 ${className ?? ""}`}>
|
|
41
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
42
|
+
<Input
|
|
43
|
+
type="text"
|
|
44
|
+
value={draft}
|
|
45
|
+
onChange={(e) => {
|
|
46
|
+
setDraft(e.target.value);
|
|
47
|
+
debouncedRef.current?.(e.target.value);
|
|
48
|
+
}}
|
|
49
|
+
placeholder={placeholder}
|
|
50
|
+
className="pl-9"
|
|
51
|
+
aria-label="Unified search"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ArrowDown, ArrowUp } 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 type { SortFieldConfig, SortState } from "../../utils/sortUtils";
|
|
11
|
+
|
|
12
|
+
const NONE_VALUE = "__none__";
|
|
13
|
+
|
|
14
|
+
interface SortControlProps {
|
|
15
|
+
configs: SortFieldConfig[];
|
|
16
|
+
sort: SortState | null;
|
|
17
|
+
onSortChange: (sort: SortState | null) => void;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SortControl({ configs, sort, onSortChange, className }: SortControlProps) {
|
|
22
|
+
if (configs.length === 0) return null;
|
|
23
|
+
return (
|
|
24
|
+
<div className={`flex items-center gap-2 ${className ?? ""}`}>
|
|
25
|
+
<span className="text-sm text-muted-foreground whitespace-nowrap">Sort by</span>
|
|
26
|
+
<Select
|
|
27
|
+
value={sort?.field ?? NONE_VALUE}
|
|
28
|
+
onValueChange={(v) => {
|
|
29
|
+
if (v === NONE_VALUE) onSortChange(null);
|
|
30
|
+
else onSortChange({ field: v, direction: sort?.direction ?? "ASC" });
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<SelectTrigger size="sm" className="w-[160px]">
|
|
34
|
+
<SelectValue placeholder="Default" />
|
|
35
|
+
</SelectTrigger>
|
|
36
|
+
<SelectContent>
|
|
37
|
+
<SelectItem value={NONE_VALUE}>Default</SelectItem>
|
|
38
|
+
{configs.map((c) => (
|
|
39
|
+
<SelectItem key={c.field} value={c.field}>
|
|
40
|
+
{c.label}
|
|
41
|
+
</SelectItem>
|
|
42
|
+
))}
|
|
43
|
+
</SelectContent>
|
|
44
|
+
</Select>
|
|
45
|
+
{sort && (
|
|
46
|
+
<Button
|
|
47
|
+
variant="ghost"
|
|
48
|
+
size="icon-sm"
|
|
49
|
+
onClick={() =>
|
|
50
|
+
onSortChange({
|
|
51
|
+
...sort,
|
|
52
|
+
direction: sort.direction === "ASC" ? "DESC" : "ASC",
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
aria-label={`Sort ${sort.direction === "ASC" ? "descending" : "ascending"}`}
|
|
56
|
+
>
|
|
57
|
+
{sort.direction === "ASC" ? <ArrowUp /> : <ArrowDown />}
|
|
58
|
+
</Button>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { Button } from "../../../../components/ui/button";
|
|
3
|
+
import type { ActiveFilterValue } from "../../utils/filterUtils";
|
|
4
|
+
|
|
5
|
+
function formatLabel(filter: ActiveFilterValue): string {
|
|
6
|
+
const { label, type, value, min, max } = filter;
|
|
7
|
+
switch (type) {
|
|
8
|
+
case "text":
|
|
9
|
+
case "picklist":
|
|
10
|
+
return `${label}: ${value ?? ""}`;
|
|
11
|
+
case "multipicklist": {
|
|
12
|
+
const values = value ? value.split(",") : [];
|
|
13
|
+
if (values.length <= 2) return `${label}: ${values.join(", ")}`;
|
|
14
|
+
return `${label}: ${values.length} selected`;
|
|
15
|
+
}
|
|
16
|
+
case "boolean":
|
|
17
|
+
return `${label}: ${value === "true" ? "Yes" : "No"}`;
|
|
18
|
+
case "numeric": {
|
|
19
|
+
if (min && max) return `${label}: ${min} – ${max}`;
|
|
20
|
+
if (min) return `${label}: ≥ ${min}`;
|
|
21
|
+
return `${label}: ≤ ${max ?? ""}`;
|
|
22
|
+
}
|
|
23
|
+
case "date":
|
|
24
|
+
case "daterange":
|
|
25
|
+
case "datetime":
|
|
26
|
+
case "datetimerange": {
|
|
27
|
+
if (min && max) return `${label}: ${min} → ${max}`;
|
|
28
|
+
if (min) return `${label}: from ${min}`;
|
|
29
|
+
return `${label}: until ${max ?? ""}`;
|
|
30
|
+
}
|
|
31
|
+
default:
|
|
32
|
+
return label;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ActiveFiltersProps {
|
|
37
|
+
filters: ActiveFilterValue[];
|
|
38
|
+
onRemove: (field: string) => void;
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ActiveFilters({ filters, onRemove, className }: ActiveFiltersProps) {
|
|
43
|
+
if (filters.length === 0) return null;
|
|
44
|
+
return (
|
|
45
|
+
<div className={`flex flex-wrap gap-2 ${className ?? ""}`}>
|
|
46
|
+
{filters.map((filter) => (
|
|
47
|
+
<Button
|
|
48
|
+
key={filter.field}
|
|
49
|
+
variant="outline"
|
|
50
|
+
size="sm"
|
|
51
|
+
className="gap-1 h-7 text-xs"
|
|
52
|
+
aria-label={`Remove ${formatLabel(filter)}`}
|
|
53
|
+
onClick={() => onRemove(filter.field)}
|
|
54
|
+
>
|
|
55
|
+
{formatLabel(filter)}
|
|
56
|
+
<X className="h-3 w-3" aria-hidden="true" />
|
|
57
|
+
</Button>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|