@salesforce/ui-bundle-template-feature-react-search 11.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/LICENSE.txt +82 -0
- package/README.md +692 -0
- package/dist/.forceignore +15 -0
- package/dist/.husky/pre-commit +4 -0
- package/dist/.prettierignore +11 -0
- package/dist/.prettierrc +17 -0
- package/dist/CHANGELOG.md +3499 -0
- package/dist/README.md +28 -0
- package/dist/config/project-scratch-def.json +13 -0
- package/dist/eslint.config.js +7 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/.forceignore +15 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/.graphqlrc.yml +2 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/.prettierignore +9 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/.prettierrc +11 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/CHANGELOG.md +10 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/README.md +75 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/codegen.yml +95 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/components.json +18 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/e2e/app.spec.ts +17 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/eslint.config.js +169 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/feature-react-search.uibundle-meta.xml +7 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/index.html +12 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/package.json +76 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/playwright.config.ts +24 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/scripts/get-graphql-schema.mjs +71 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/scripts/rewrite-e2e-assets.mjs +23 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/api/graphqlClient.ts +44 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/app.tsx +17 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/appLayout.tsx +83 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/book.svg +3 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/copy.svg +4 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/rocket.svg +3 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/star.svg +3 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/codey-1.png +0 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/codey-2.png +0 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/codey-3.png +0 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/vibe-codey.svg +194 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/alerts/status-alert.tsx +52 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/layouts/card-layout.tsx +29 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/alert.tsx +76 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/avatar.tsx +109 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/badge.tsx +48 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/breadcrumb.tsx +109 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/button.tsx +67 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/calendar.tsx +232 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/card.tsx +103 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/checkbox.tsx +32 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/collapsible.tsx +33 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/datePicker.tsx +127 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/dialog.tsx +162 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/dropdown-menu.tsx +257 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/field.tsx +237 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/index.ts +109 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/input.tsx +19 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/label.tsx +22 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/pagination.tsx +132 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/popover.tsx +89 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/select.tsx +193 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/separator.tsx +26 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/skeleton.tsx +14 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/sonner.tsx +20 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/spinner.tsx +16 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/table.tsx +114 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/tabs.tsx +88 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/distinctValuesService.ts +84 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/searchService.ts +71 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +160 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +77 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +167 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +115 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/ScopeSelector.tsx +55 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SearchBar.tsx +55 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SortControl.tsx +62 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/ActiveFilters.tsx +61 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +122 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/FilterContext.tsx +70 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/BooleanFilter.tsx +50 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +50 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/FilterFieldWrapper.tsx +26 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +47 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/NumericRangeFilter.tsx +78 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/SelectFilter.tsx +46 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/TextFilter.tsx +52 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/results/DefaultResultRow.tsx +109 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +82 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useAsyncData.ts +56 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useDistinctValues.ts +59 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +442 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +80 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/loadConfig.ts +14 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +156 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +169 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/debounce.ts +17 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/fieldUtils.ts +14 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +272 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/sortUtils.ts +34 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/hooks/useAsyncData.ts +67 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/lib/utils.ts +6 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/navigationMenu.tsx +80 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/pages/Home.tsx +11 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/pages/NotFound.tsx +18 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/router-utils.tsx +35 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/routes.tsx +22 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/styles/global.css +135 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/tsconfig.json +45 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/tsconfig.node.json +13 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/ui-bundle.json +7 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/vite-env.d.ts +4 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/vite.config.ts +106 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/vitest-env.d.ts +2 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/vitest.config.ts +11 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/vitest.setup.ts +1 -0
- package/dist/jest.config.js +6 -0
- package/dist/package-lock.json +9995 -0
- package/dist/package.json +44 -0
- package/dist/scripts/apex/hello.apex +10 -0
- package/dist/scripts/gitignore-templates.json +4 -0
- package/dist/scripts/graphql-search.sh +191 -0
- package/dist/scripts/org-setup-config-schema.mjs +96 -0
- package/dist/scripts/org-setup.config.json +5 -0
- package/dist/scripts/org-setup.mjs +1392 -0
- package/dist/scripts/sf-project-setup.mjs +103 -0
- package/dist/scripts/soql/account.soql +6 -0
- package/dist/scripts/validate-org-setup-config.mjs +38 -0
- package/dist/sfdx-project.json +12 -0
- package/package.json +51 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/__inherit__appLayout.tsx +9 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__alert.tsx +39 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__button.tsx +45 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__checkbox.tsx +8 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__input.tsx +5 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__label.tsx +8 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__pagination.tsx +47 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__select.tsx +57 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__skeleton.tsx +5 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/distinctValuesService.ts +84 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/searchService.ts +71 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +160 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +77 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +167 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +115 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/ScopeSelector.tsx +55 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SearchBar.tsx +55 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SortControl.tsx +62 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/ActiveFilters.tsx +61 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +122 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/FilterContext.tsx +70 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/BooleanFilter.tsx +50 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +50 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/FilterFieldWrapper.tsx +26 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +47 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/NumericRangeFilter.tsx +78 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/SelectFilter.tsx +46 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/TextFilter.tsx +52 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/results/DefaultResultRow.tsx +109 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +82 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useAsyncData.ts +56 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useDistinctValues.ts +59 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +442 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +80 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/loadConfig.ts +14 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +156 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +169 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/debounce.ts +17 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/fieldUtils.ts +14 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +272 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/sortUtils.ts +34 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/pages/Home.tsx +11 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/routes.tsx +10 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches distinct values for a picklist field via the uiapi aggregate
|
|
3
|
+
* `groupBy` query.
|
|
4
|
+
*
|
|
5
|
+
* Used by the auto-render pathway when a picklist filter does not declare
|
|
6
|
+
* inline `options`. Each call hits the GraphQL endpoint once and returns the
|
|
7
|
+
* sorted set of distinct values plus their display labels.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createDataSDK } from "@salesforce/platform-sdk";
|
|
11
|
+
|
|
12
|
+
export interface PicklistOption {
|
|
13
|
+
value: string;
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
18
|
+
|
|
19
|
+
function assertValidIdentifier(name: string, kind: string): void {
|
|
20
|
+
if (!KEY_PATTERN.test(name)) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Invalid ${kind} "${name}". Must match /^[A-Za-z_][A-Za-z0-9_]*$/ to be a valid GraphQL identifier.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds and executes a `uiapi.aggregate.<objectName>(groupBy: { <field>: { group: true } })`
|
|
29
|
+
* query and extracts the distinct values.
|
|
30
|
+
*/
|
|
31
|
+
export async function fetchDistinctValues(
|
|
32
|
+
objectName: string,
|
|
33
|
+
fieldName: string,
|
|
34
|
+
): Promise<PicklistOption[]> {
|
|
35
|
+
assertValidIdentifier(objectName, "objectName");
|
|
36
|
+
assertValidIdentifier(fieldName, "fieldName");
|
|
37
|
+
|
|
38
|
+
const document = `query DistinctValues {
|
|
39
|
+
uiapi {
|
|
40
|
+
aggregate {
|
|
41
|
+
${objectName}(groupBy: { ${fieldName}: { group: true } }) {
|
|
42
|
+
edges {
|
|
43
|
+
node {
|
|
44
|
+
aggregate @optional {
|
|
45
|
+
${fieldName} @optional {
|
|
46
|
+
value
|
|
47
|
+
displayValue
|
|
48
|
+
label
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}`;
|
|
57
|
+
|
|
58
|
+
const data = await createDataSDK();
|
|
59
|
+
const response = await data.graphql!.query<unknown>({ query: document });
|
|
60
|
+
|
|
61
|
+
if (response.errors?.length) {
|
|
62
|
+
throw new Error(response.errors.map((e) => e.message).join("; "));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const root = response.data as Record<string, unknown> | undefined;
|
|
66
|
+
const aggregate = (root?.uiapi as Record<string, unknown> | undefined)?.aggregate as
|
|
67
|
+
| Record<string, unknown>
|
|
68
|
+
| undefined;
|
|
69
|
+
const objectAgg = aggregate?.[objectName] as
|
|
70
|
+
| { edges?: Array<{ node?: { aggregate?: Record<string, unknown> } }> }
|
|
71
|
+
| undefined;
|
|
72
|
+
const edges = objectAgg?.edges ?? [];
|
|
73
|
+
|
|
74
|
+
return edges
|
|
75
|
+
.map((edge) => {
|
|
76
|
+
const field = edge?.node?.aggregate?.[fieldName] as
|
|
77
|
+
| { value?: string | null; displayValue?: string | null; label?: string | null }
|
|
78
|
+
| undefined;
|
|
79
|
+
const value = field?.value;
|
|
80
|
+
if (value == null || value === "") return null;
|
|
81
|
+
return { value, label: field?.label ?? field?.displayValue ?? value };
|
|
82
|
+
})
|
|
83
|
+
.filter((opt): opt is PicklistOption => opt !== null);
|
|
84
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executes a search request and parses results into a per-source map.
|
|
3
|
+
*
|
|
4
|
+
* Today there's only one adapter — SObjects via the platform-sdk uiapi GraphQL
|
|
5
|
+
* bridge. Future adapters (CMS, REST, etc.) can run alongside this one and
|
|
6
|
+
* have their results merged into the same `Record<sourceKey, SourceResult>`
|
|
7
|
+
* by the hook.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createDataSDK } from "@salesforce/platform-sdk";
|
|
11
|
+
import { buildSearchQuery, type SourceRequest } from "../queryBuilder";
|
|
12
|
+
import type { SourceResult } from "../types";
|
|
13
|
+
|
|
14
|
+
interface RawSourceResult {
|
|
15
|
+
edges?: Array<{ node?: unknown }> | null;
|
|
16
|
+
pageInfo?: {
|
|
17
|
+
hasNextPage?: boolean | null;
|
|
18
|
+
hasPreviousPage?: boolean | null;
|
|
19
|
+
startCursor?: string | null;
|
|
20
|
+
endCursor?: string | null;
|
|
21
|
+
} | null;
|
|
22
|
+
totalCount?: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Runs a single multi-aliased GraphQL request against uiapi.query.
|
|
27
|
+
*
|
|
28
|
+
* Returns a plain object keyed by source.key. A source whose alias is missing
|
|
29
|
+
* from the response (e.g. due to a partial GraphQL error) is omitted; the
|
|
30
|
+
* caller can detect this by `result[sourceKey] == null`.
|
|
31
|
+
*/
|
|
32
|
+
export async function runSearch(requests: SourceRequest[]): Promise<Record<string, SourceResult>> {
|
|
33
|
+
const { document, variables } = buildSearchQuery(requests);
|
|
34
|
+
|
|
35
|
+
const data = await createDataSDK();
|
|
36
|
+
const response = await data.graphql!.query<unknown, Record<string, unknown>>({
|
|
37
|
+
query: document,
|
|
38
|
+
variables,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (response.errors?.length) {
|
|
42
|
+
throw new Error(response.errors.map((e) => e.message).join("; "));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const root = response.data as Record<string, unknown> | undefined;
|
|
46
|
+
const queryRoot = (root?.uiapi as Record<string, unknown> | undefined)?.query as
|
|
47
|
+
| Record<string, RawSourceResult | null | undefined>
|
|
48
|
+
| undefined;
|
|
49
|
+
|
|
50
|
+
const out: Record<string, SourceResult> = {};
|
|
51
|
+
for (const request of requests) {
|
|
52
|
+
const raw = queryRoot?.[request.source.key];
|
|
53
|
+
if (raw == null) continue;
|
|
54
|
+
const nodes = (raw.edges ?? [])
|
|
55
|
+
.map((edge) => edge?.node)
|
|
56
|
+
.filter((node): node is unknown => node != null);
|
|
57
|
+
out[request.source.key] = {
|
|
58
|
+
nodes,
|
|
59
|
+
pageInfo: raw.pageInfo
|
|
60
|
+
? {
|
|
61
|
+
hasNextPage: raw.pageInfo.hasNextPage ?? false,
|
|
62
|
+
hasPreviousPage: raw.pageInfo.hasPreviousPage ?? false,
|
|
63
|
+
startCursor: raw.pageInfo.startCursor ?? null,
|
|
64
|
+
endCursor: raw.pageInfo.endCursor ?? null,
|
|
65
|
+
}
|
|
66
|
+
: null,
|
|
67
|
+
totalCount: raw.totalCount ?? null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop-in search component. Given a `SearchConfig`, renders a complete
|
|
3
|
+
* search experience: search bar, an aggregate result count (shown only when
|
|
4
|
+
* 2+ sources are in scope), and one results section per configured source
|
|
5
|
+
* (with auto-rendered rows + filters).
|
|
6
|
+
*
|
|
7
|
+
* The simplest possible integration:
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { Search, config } from ".../features/search";
|
|
11
|
+
* export default function MySearchPage() {
|
|
12
|
+
* return <Search config={config} />;
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Override slots:
|
|
17
|
+
* - `renderResult[sourceKey]` — replace the default row renderer for one
|
|
18
|
+
* source. Pass `false` to hide that source entirely.
|
|
19
|
+
* - `renderFilters[sourceKey]` — replace the default filter sidebar for
|
|
20
|
+
* one source. Pass `false` to suppress filters for that source.
|
|
21
|
+
* - `renderHeader` — replace the default header (title + subtitle).
|
|
22
|
+
* - `searchPlaceholder` — placeholder text for the search input.
|
|
23
|
+
* - `title`, `subtitle` — defaults passed to the built-in header.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useMemo, type ReactNode } from "react";
|
|
27
|
+
import { useSearch } from "../hooks/useSearch";
|
|
28
|
+
import { SearchBar } from "./controls/SearchBar";
|
|
29
|
+
import { ScopeSelector } from "./controls/ScopeSelector";
|
|
30
|
+
import { SearchResults } from "./SearchResults";
|
|
31
|
+
import { ALL_SCOPE } from "../types";
|
|
32
|
+
import type { SearchConfig, SearchHandle, SObjectSourceConfig } from "../types";
|
|
33
|
+
|
|
34
|
+
type ResultRenderer = ((node: unknown) => ReactNode) | false;
|
|
35
|
+
type FilterRenderer = (() => ReactNode) | false;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Locks the search to a single source. Reuses the same shared `config.json`
|
|
39
|
+
* but shows only results matching the given (kind, key) tuple.
|
|
40
|
+
*
|
|
41
|
+
* When set:
|
|
42
|
+
* - The scope dropdown is hidden.
|
|
43
|
+
* - URL `?scope=` is not written (the page route already implies the source).
|
|
44
|
+
* - Only the matching source is fetched and rendered.
|
|
45
|
+
*/
|
|
46
|
+
export interface RestrictTo {
|
|
47
|
+
kind: SObjectSourceConfig["kind"];
|
|
48
|
+
key: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface SearchProps {
|
|
52
|
+
config: SearchConfig;
|
|
53
|
+
renderResult?: Record<string, ResultRenderer>;
|
|
54
|
+
renderFilters?: Record<string, FilterRenderer>;
|
|
55
|
+
emptyMessages?: Record<string, string>;
|
|
56
|
+
searchPlaceholder?: string;
|
|
57
|
+
title?: string;
|
|
58
|
+
subtitle?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Lock the search to a single source by `(kind, key)`. Hides the scope
|
|
61
|
+
* dropdown and forces results to that one source. Useful for embedding
|
|
62
|
+
* a single-object search inside a page where the source is implicit.
|
|
63
|
+
*/
|
|
64
|
+
restrictTo?: RestrictTo;
|
|
65
|
+
/**
|
|
66
|
+
* Show the source-scope dropdown next to the search bar.
|
|
67
|
+
* Defaults to `true` when the config has 2+ sources, `false` otherwise
|
|
68
|
+
* (a one-source dropdown would be redundant). Always hidden when
|
|
69
|
+
* `restrictTo` is set.
|
|
70
|
+
*/
|
|
71
|
+
showScopeSelector?: boolean;
|
|
72
|
+
/** Label for the "search everything" entry in the scope dropdown. */
|
|
73
|
+
allScopeLabel?: string;
|
|
74
|
+
/** Optional override for the entire header region. */
|
|
75
|
+
renderHeader?: (handle: SearchHandle) => ReactNode;
|
|
76
|
+
className?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function Search({
|
|
80
|
+
config,
|
|
81
|
+
renderResult,
|
|
82
|
+
renderFilters,
|
|
83
|
+
emptyMessages,
|
|
84
|
+
searchPlaceholder = "Search…",
|
|
85
|
+
title = "Search",
|
|
86
|
+
subtitle,
|
|
87
|
+
restrictTo,
|
|
88
|
+
showScopeSelector,
|
|
89
|
+
allScopeLabel,
|
|
90
|
+
renderHeader,
|
|
91
|
+
className,
|
|
92
|
+
}: SearchProps) {
|
|
93
|
+
// Resolve restrictTo to a lockedScope value, surfacing a clear error if
|
|
94
|
+
// the (kind, key) pair doesn't match any source. Memoize so it's
|
|
95
|
+
// stable across renders.
|
|
96
|
+
const lockedScope = useMemo<string | undefined>(() => {
|
|
97
|
+
if (!restrictTo) return undefined;
|
|
98
|
+
const match = config.sources.find(
|
|
99
|
+
(s) => s.kind === restrictTo.kind && s.key === restrictTo.key,
|
|
100
|
+
);
|
|
101
|
+
if (!match) {
|
|
102
|
+
console.warn(
|
|
103
|
+
`<Search restrictTo>: no source with kind="${restrictTo.kind}" and key="${restrictTo.key}" in config. Falling back to "all".`,
|
|
104
|
+
);
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return match.key;
|
|
108
|
+
}, [restrictTo, config.sources]);
|
|
109
|
+
|
|
110
|
+
const handle = useSearch(config, { lockedScope });
|
|
111
|
+
const totalResults = Object.values(handle.sources).reduce(
|
|
112
|
+
(acc, controller) => acc + (controller.result?.totalCount ?? 0),
|
|
113
|
+
0,
|
|
114
|
+
);
|
|
115
|
+
const shouldShowScope = !handle.scopeLocked && (showScopeSelector ?? config.sources.length >= 2);
|
|
116
|
+
// The aggregate count is only meaningful when results from more than one
|
|
117
|
+
// source are combined. With a single configured source, a locked scope, or
|
|
118
|
+
// a specific source picked in the dropdown, exactly one source is on screen
|
|
119
|
+
// and that source's own section already shows its count — so suppress the
|
|
120
|
+
// redundant total here.
|
|
121
|
+
const sourcesInScope = handle.scope === ALL_SCOPE ? config.sources.length : 1;
|
|
122
|
+
const showTotalResults = sourcesInScope >= 2;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className={`max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6 ${className ?? ""}`}>
|
|
126
|
+
{renderHeader ? (
|
|
127
|
+
renderHeader(handle)
|
|
128
|
+
) : (
|
|
129
|
+
<header className="space-y-2">
|
|
130
|
+
<h1 className="text-2xl font-bold">{title}</h1>
|
|
131
|
+
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
|
132
|
+
</header>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
136
|
+
<SearchBar value={handle.q} onChange={handle.setQ} placeholder={searchPlaceholder} />
|
|
137
|
+
{shouldShowScope && (
|
|
138
|
+
<ScopeSelector
|
|
139
|
+
config={config}
|
|
140
|
+
scope={handle.scope}
|
|
141
|
+
onScopeChange={handle.setScope}
|
|
142
|
+
allLabel={allScopeLabel}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
{showTotalResults && !handle.loading && (
|
|
146
|
+
<span className="text-sm text-muted-foreground">
|
|
147
|
+
{totalResults} total result{totalResults === 1 ? "" : "s"}
|
|
148
|
+
</span>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<SearchResults
|
|
153
|
+
handle={handle}
|
|
154
|
+
renderResult={renderResult}
|
|
155
|
+
renderFilters={renderFilters}
|
|
156
|
+
emptyMessages={emptyMessages}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -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
|
+
* `filterFields` 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.filterFields?.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,167 @@
|
|
|
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 = config.validPageSizes ?? [pagination.pageSize];
|
|
56
|
+
|
|
57
|
+
const showFilterPanel = !!renderFilters && !hideFilterChrome;
|
|
58
|
+
const showActiveChips = !hideFilterChrome && filters.active.length > 0;
|
|
59
|
+
const showSortControl = !hideFilterChrome && (config.sortFields?.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
|
+
{totalCount != null && (
|
|
86
|
+
<span className="text-sm text-muted-foreground">
|
|
87
|
+
{totalCount} {totalCount === 1 ? "result" : "results"}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
</header>
|
|
91
|
+
|
|
92
|
+
{(showSortControl || showActiveChips) && (
|
|
93
|
+
<div className="flex flex-wrap items-center gap-2 mb-3">
|
|
94
|
+
{showSortControl && config.sortFields ? (
|
|
95
|
+
<SortControl
|
|
96
|
+
configs={config.sortFields}
|
|
97
|
+
sort={sort.current}
|
|
98
|
+
onSortChange={sort.set}
|
|
99
|
+
/>
|
|
100
|
+
) : null}
|
|
101
|
+
{showActiveChips && (
|
|
102
|
+
<ActiveFilters filters={filters.active} onRemove={filters.remove} />
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
<div className="min-h-32">
|
|
108
|
+
{loading && (
|
|
109
|
+
<div className="space-y-2">
|
|
110
|
+
{Array.from({ length: Math.min(pagination.pageSize, 5) }, (_, i) => (
|
|
111
|
+
<Skeleton key={i} className="h-12 w-full" />
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{error && (
|
|
117
|
+
<Alert variant="destructive" role="alert">
|
|
118
|
+
<AlertCircle />
|
|
119
|
+
<AlertTitle>Failed to load {config.label}</AlertTitle>
|
|
120
|
+
<AlertDescription>{error}</AlertDescription>
|
|
121
|
+
</Alert>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{showResults && (
|
|
125
|
+
<ul className="divide-y">
|
|
126
|
+
{nodes.map((node, i) => (
|
|
127
|
+
<li key={getNodeKey(node, config.idField, i)} className="py-3">
|
|
128
|
+
{renderResult(node)}
|
|
129
|
+
</li>
|
|
130
|
+
))}
|
|
131
|
+
</ul>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{showEmpty && (
|
|
135
|
+
<div className="flex flex-col items-center justify-center py-10 text-center">
|
|
136
|
+
<SearchX className="size-10 text-muted-foreground mb-3" />
|
|
137
|
+
<p className="text-sm text-muted-foreground">
|
|
138
|
+
{emptyMessage ?? `No ${config.label.toLowerCase()} found.`}
|
|
139
|
+
</p>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<PaginationControls
|
|
145
|
+
pageIndex={pagination.pageIndex}
|
|
146
|
+
hasNextPage={pagination.hasNextPage}
|
|
147
|
+
hasPreviousPage={pagination.hasPreviousPage}
|
|
148
|
+
pageSize={pagination.pageSize}
|
|
149
|
+
pageSizeOptions={pageSizeOptions}
|
|
150
|
+
onNextPage={pagination.goToNextPage}
|
|
151
|
+
onPreviousPage={pagination.goToPreviousPage}
|
|
152
|
+
onPageSizeChange={pagination.setPageSize}
|
|
153
|
+
disabled={loading || !!error}
|
|
154
|
+
idPrefix={`page-size-${config.key}`}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
</section>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getNodeKey(node: unknown, idField: string | undefined, fallback: number): string | number {
|
|
162
|
+
if (node && typeof node === "object") {
|
|
163
|
+
const id = (node as Record<string, unknown>)[idField ?? "Id"];
|
|
164
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
165
|
+
}
|
|
166
|
+
return fallback;
|
|
167
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Pagination,
|
|
3
|
+
PaginationContent,
|
|
4
|
+
PaginationItem,
|
|
5
|
+
PaginationNext,
|
|
6
|
+
PaginationPrevious,
|
|
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
|
+
import type { KeyboardEvent } from "react";
|
|
17
|
+
|
|
18
|
+
interface PaginationControlsProps {
|
|
19
|
+
pageIndex: number;
|
|
20
|
+
hasNextPage: boolean;
|
|
21
|
+
hasPreviousPage: boolean;
|
|
22
|
+
pageSize: number;
|
|
23
|
+
pageSizeOptions: readonly number[];
|
|
24
|
+
onNextPage: () => void;
|
|
25
|
+
onPreviousPage: () => void;
|
|
26
|
+
onPageSizeChange: (size: number) => void;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
idPrefix?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function PaginationControls({
|
|
32
|
+
pageIndex,
|
|
33
|
+
hasNextPage,
|
|
34
|
+
hasPreviousPage,
|
|
35
|
+
pageSize,
|
|
36
|
+
pageSizeOptions,
|
|
37
|
+
onNextPage,
|
|
38
|
+
onPreviousPage,
|
|
39
|
+
onPageSizeChange,
|
|
40
|
+
disabled = false,
|
|
41
|
+
idPrefix = "page-size",
|
|
42
|
+
}: PaginationControlsProps) {
|
|
43
|
+
const handlePageSizeChange = (next: string) => {
|
|
44
|
+
const size = parseInt(next, 10);
|
|
45
|
+
if (!isNaN(size) && size !== pageSize) onPageSizeChange(size);
|
|
46
|
+
};
|
|
47
|
+
const prevDisabled = disabled || !hasPreviousPage;
|
|
48
|
+
const nextDisabled = disabled || !hasNextPage;
|
|
49
|
+
const selectId = `${idPrefix}-select`;
|
|
50
|
+
|
|
51
|
+
// The underlying pagination control renders an <a> without an href, so it is
|
|
52
|
+
// not keyboard-operable on its own. Activate it on Enter/Space to match the
|
|
53
|
+
// behaviour of a native button (WCAG 2.2 SC 2.1.1 Keyboard).
|
|
54
|
+
const handleActivationKey = (action: () => void) => (event: KeyboardEvent) => {
|
|
55
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
action();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="w-full grid grid-cols-1 sm:grid-cols-2 items-center gap-4 py-2">
|
|
63
|
+
<div className="flex justify-center sm:justify-start items-center gap-2">
|
|
64
|
+
<Label htmlFor={selectId} className="text-sm font-normal whitespace-nowrap">
|
|
65
|
+
Results per page:
|
|
66
|
+
</Label>
|
|
67
|
+
<Select
|
|
68
|
+
value={pageSize.toString()}
|
|
69
|
+
onValueChange={handlePageSizeChange}
|
|
70
|
+
disabled={disabled}
|
|
71
|
+
>
|
|
72
|
+
<SelectTrigger id={selectId} className="w-16">
|
|
73
|
+
<SelectValue />
|
|
74
|
+
</SelectTrigger>
|
|
75
|
+
<SelectContent>
|
|
76
|
+
{pageSizeOptions.map((size) => (
|
|
77
|
+
<SelectItem key={size} value={size.toString()}>
|
|
78
|
+
{size}
|
|
79
|
+
</SelectItem>
|
|
80
|
+
))}
|
|
81
|
+
</SelectContent>
|
|
82
|
+
</Select>
|
|
83
|
+
</div>
|
|
84
|
+
<Pagination className="w-full mx-0 sm:justify-end">
|
|
85
|
+
<PaginationContent>
|
|
86
|
+
<PaginationItem>
|
|
87
|
+
<PaginationPrevious
|
|
88
|
+
role="button"
|
|
89
|
+
tabIndex={prevDisabled ? -1 : 0}
|
|
90
|
+
onClick={prevDisabled ? undefined : onPreviousPage}
|
|
91
|
+
onKeyDown={prevDisabled ? undefined : handleActivationKey(onPreviousPage)}
|
|
92
|
+
aria-disabled={prevDisabled}
|
|
93
|
+
className={prevDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
94
|
+
/>
|
|
95
|
+
</PaginationItem>
|
|
96
|
+
<PaginationItem>
|
|
97
|
+
<span className="min-w-16 text-center text-sm text-muted-foreground px-2">
|
|
98
|
+
Page {pageIndex + 1}
|
|
99
|
+
</span>
|
|
100
|
+
</PaginationItem>
|
|
101
|
+
<PaginationItem>
|
|
102
|
+
<PaginationNext
|
|
103
|
+
role="button"
|
|
104
|
+
tabIndex={nextDisabled ? -1 : 0}
|
|
105
|
+
onClick={nextDisabled ? undefined : onNextPage}
|
|
106
|
+
onKeyDown={nextDisabled ? undefined : handleActivationKey(onNextPage)}
|
|
107
|
+
aria-disabled={nextDisabled}
|
|
108
|
+
className={nextDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
109
|
+
/>
|
|
110
|
+
</PaginationItem>
|
|
111
|
+
</PaginationContent>
|
|
112
|
+
</Pagination>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|