@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
package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration types for unified (multi-source) search.
|
|
3
|
+
*
|
|
4
|
+
* The application developer declares a SearchConfig with one entry per
|
|
5
|
+
* searchable backend. Today every source is an SObject queried via the uiapi
|
|
6
|
+
* GraphQL bridge; future adapters (CMS, REST, etc.) can plug in by introducing
|
|
7
|
+
* a new `kind` and a matching runtime adapter without changing any consumer.
|
|
8
|
+
*
|
|
9
|
+
* The library handles query construction, filter/sort/pagination state, URL
|
|
10
|
+
* sync, and result fan-out. The application owns rendering and routing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { FilterFieldConfig, ActiveFilterValue } from "./utils/filterUtils";
|
|
14
|
+
import type { SortFieldConfig, SortState } from "./utils/sortUtils";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A single field to include in the GraphQL selection set of an SObject source.
|
|
18
|
+
*
|
|
19
|
+
* - `"Name"` (string shorthand): emits `Name @optional { value displayValue }`.
|
|
20
|
+
* - `{ name, subfields }`: emits a nested selection (relationship traversal).
|
|
21
|
+
* - `{ name, raw: true }`: emits the bare field name (e.g. for Id-like fields
|
|
22
|
+
* that are not wrapped in `{ value displayValue }`).
|
|
23
|
+
*
|
|
24
|
+
* The configured `idField` (default: "Id") is added automatically; do not list
|
|
25
|
+
* it explicitly.
|
|
26
|
+
*/
|
|
27
|
+
export type DisplayField =
|
|
28
|
+
| string
|
|
29
|
+
| { name: string; raw: true }
|
|
30
|
+
| { name: string; subfields: DisplayField[] };
|
|
31
|
+
|
|
32
|
+
/** Configuration for one SObject source in a search experience. */
|
|
33
|
+
export interface SObjectSourceConfig {
|
|
34
|
+
/** Discriminator — selects the runtime adapter. */
|
|
35
|
+
kind: "sobject";
|
|
36
|
+
/**
|
|
37
|
+
* Stable identifier. Used as the GraphQL alias, the URL namespace, and the
|
|
38
|
+
* result-map key. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`.
|
|
39
|
+
*/
|
|
40
|
+
key: string;
|
|
41
|
+
/** GraphQL object name (e.g. "Account", "Contact"). */
|
|
42
|
+
objectName: string;
|
|
43
|
+
/** Display label used in section headers. */
|
|
44
|
+
label: string;
|
|
45
|
+
/** Field name that uniquely identifies a record. Defaults to "Id". */
|
|
46
|
+
idField?: string;
|
|
47
|
+
/**
|
|
48
|
+
* URL pattern used by the default renderer to build a result link.
|
|
49
|
+
*
|
|
50
|
+
* Tokens of the form `:fieldName` are replaced with the corresponding
|
|
51
|
+
* field's value at render time (e.g. `:id` resolves to the record's
|
|
52
|
+
* `idField` value, `:Name` resolves to the Name field's `value`).
|
|
53
|
+
*
|
|
54
|
+
* Example: `"/accounts/:id"` produces `<Link to="/accounts/001xx0000003DGb">`.
|
|
55
|
+
*
|
|
56
|
+
* Optional — when omitted, the default renderer emits non-clickable rows.
|
|
57
|
+
*/
|
|
58
|
+
routePattern?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Fields the global `q` term should match against.
|
|
61
|
+
* Built into an `or` of `like %q%`. Supports dot-notation for relationship
|
|
62
|
+
* fields (e.g. "Owner.Name").
|
|
63
|
+
*/
|
|
64
|
+
searchableFields: string[];
|
|
65
|
+
/**
|
|
66
|
+
* Fields to include in the GraphQL selection set. The id field is added
|
|
67
|
+
* automatically; do not list it.
|
|
68
|
+
*/
|
|
69
|
+
displayFields: DisplayField[];
|
|
70
|
+
/** Optional structured filters surfaced in the per-source filter panel. */
|
|
71
|
+
filterFields?: FilterFieldConfig[];
|
|
72
|
+
/** Optional sort options surfaced in the per-source sort dropdown. */
|
|
73
|
+
sortFields?: SortFieldConfig[];
|
|
74
|
+
/** Initial sort applied when no URL sort is present. */
|
|
75
|
+
defaultSort?: SortState;
|
|
76
|
+
/** Default page size for this source. */
|
|
77
|
+
pageSize?: number;
|
|
78
|
+
/** Allowed page sizes. */
|
|
79
|
+
validPageSizes?: number[];
|
|
80
|
+
/**
|
|
81
|
+
* GraphQL type name for the `where` variable.
|
|
82
|
+
* Defaults to `${objectName}_Filter` — override only for non-conventional schemas.
|
|
83
|
+
*/
|
|
84
|
+
whereTypeName?: string;
|
|
85
|
+
/** GraphQL type name for the `orderBy` variable. Defaults to `${objectName}_OrderBy`. */
|
|
86
|
+
orderByTypeName?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Top-level config passed to {@link useSearch}. */
|
|
90
|
+
export interface SearchConfig {
|
|
91
|
+
sources: SObjectSourceConfig[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Runtime types
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export interface SourcePageInfo {
|
|
99
|
+
hasNextPage: boolean;
|
|
100
|
+
hasPreviousPage: boolean;
|
|
101
|
+
endCursor: string | null;
|
|
102
|
+
startCursor: string | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Per-source result. Nodes are typed as `unknown` at the library boundary —
|
|
107
|
+
* the application narrows them in its `renderResult` callback (typically by
|
|
108
|
+
* casting to a codegen-generated node type).
|
|
109
|
+
*/
|
|
110
|
+
export interface SourceResult {
|
|
111
|
+
nodes: unknown[];
|
|
112
|
+
pageInfo: SourcePageInfo | null;
|
|
113
|
+
totalCount: number | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface SourceController {
|
|
117
|
+
config: SObjectSourceConfig;
|
|
118
|
+
result: SourceResult | null;
|
|
119
|
+
loading: boolean;
|
|
120
|
+
error: string | null;
|
|
121
|
+
filters: {
|
|
122
|
+
active: ActiveFilterValue[];
|
|
123
|
+
set: (field: string, value: ActiveFilterValue | undefined) => void;
|
|
124
|
+
remove: (field: string) => void;
|
|
125
|
+
};
|
|
126
|
+
sort: {
|
|
127
|
+
current: SortState | null;
|
|
128
|
+
set: (sort: SortState | null) => void;
|
|
129
|
+
};
|
|
130
|
+
pagination: {
|
|
131
|
+
pageSize: number;
|
|
132
|
+
pageIndex: number;
|
|
133
|
+
hasNextPage: boolean;
|
|
134
|
+
hasPreviousPage: boolean;
|
|
135
|
+
setPageSize: (size: number) => void;
|
|
136
|
+
goToNextPage: () => void;
|
|
137
|
+
goToPreviousPage: () => void;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The currently-selected search scope.
|
|
143
|
+
*
|
|
144
|
+
* - `"all"`: every source in the config is queried and rendered.
|
|
145
|
+
* - any source `key`: only that source is queried and rendered. Other sources
|
|
146
|
+
* skip both the network request and the UI section.
|
|
147
|
+
*/
|
|
148
|
+
export type SearchScope = "all" | string;
|
|
149
|
+
|
|
150
|
+
export const ALL_SCOPE = "all" as const;
|
|
151
|
+
|
|
152
|
+
export interface SearchHandle {
|
|
153
|
+
q: string;
|
|
154
|
+
setQ: (q: string) => void;
|
|
155
|
+
scope: SearchScope;
|
|
156
|
+
setScope: (scope: SearchScope) => void;
|
|
157
|
+
/**
|
|
158
|
+
* True when the caller passed `lockedScope` to `useSearch`. Consumers
|
|
159
|
+
* should hide the scope dropdown and read-only display the locked source.
|
|
160
|
+
*/
|
|
161
|
+
scopeLocked: boolean;
|
|
162
|
+
sources: Record<string, SourceController>;
|
|
163
|
+
loading: boolean;
|
|
164
|
+
error: string | null;
|
|
165
|
+
resetAll: () => void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Type alias for a render callback per source key. */
|
|
169
|
+
export type RenderResultFn = (sourceKey: string, node: unknown) => React.ReactNode;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Default debounce delay for keystroke-driven inputs. */
|
|
2
|
+
export const FILTER_DEBOUNCE_MS = 300;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a debounced version of `fn`. Each call resets the internal timer;
|
|
6
|
+
* `fn` runs only after the timer expires without being reset.
|
|
7
|
+
*/
|
|
8
|
+
export function debounce<T extends (...args: any[]) => void>(
|
|
9
|
+
fn: T,
|
|
10
|
+
ms: number,
|
|
11
|
+
): (...args: Parameters<T>) => void {
|
|
12
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
13
|
+
return (...args: Parameters<T>) => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
timer = setTimeout(() => fn(...args), ms);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for reading values out of uiapi GraphQL response nodes.
|
|
3
|
+
*
|
|
4
|
+
* Nodes typically come back wrapped as `{ value, displayValue }`. `fieldValue`
|
|
5
|
+
* picks the user-facing string (preferring displayValue) or returns null.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function fieldValue(
|
|
9
|
+
field: { displayValue?: string | null; value?: unknown } | null | undefined,
|
|
10
|
+
): string | null {
|
|
11
|
+
if (field?.displayValue != null) return field.displayValue;
|
|
12
|
+
if (field?.value != null) return String(field.value);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter primitives for search.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities:
|
|
5
|
+
* 1. Build a GraphQL `where` clause from a list of active filter values.
|
|
6
|
+
* 2. Serialize/deserialize active filters to/from URLSearchParams under a
|
|
7
|
+
* per-source namespace prefix (so multiple sources can coexist on one URL).
|
|
8
|
+
*
|
|
9
|
+
* The URL helpers are namespaced: each source declares its key, and filter
|
|
10
|
+
* params are written as `s.<key>.f.<field>=...`. Sort uses
|
|
11
|
+
* `s.<key>.sort=<field>&s.<key>.dir=ASC|DESC`. Page size and page index use
|
|
12
|
+
* `s.<key>.ps=<size>` and `s.<key>.page=<n>`.
|
|
13
|
+
*
|
|
14
|
+
* The global search term `q=...` is **not** scoped to a source — it's broadcast
|
|
15
|
+
* to every source's `searchableFields` by the query builder.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { SortState } from "./sortUtils";
|
|
19
|
+
|
|
20
|
+
export type FilterFieldType =
|
|
21
|
+
| "text"
|
|
22
|
+
| "picklist"
|
|
23
|
+
| "numeric"
|
|
24
|
+
| "boolean"
|
|
25
|
+
| "date"
|
|
26
|
+
| "daterange"
|
|
27
|
+
| "datetime"
|
|
28
|
+
| "datetimerange"
|
|
29
|
+
| "multipicklist";
|
|
30
|
+
|
|
31
|
+
export type FilterFieldConfig<TFieldName extends string = string> = {
|
|
32
|
+
field: TFieldName;
|
|
33
|
+
label: string;
|
|
34
|
+
type: FilterFieldType;
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
/** Required for picklist / multipicklist. */
|
|
37
|
+
options?: Array<{ value: string; label: string }>;
|
|
38
|
+
helpText?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ActiveFilterValue<TFieldName extends string = string> = {
|
|
42
|
+
field: TFieldName;
|
|
43
|
+
label: string;
|
|
44
|
+
type: FilterFieldType;
|
|
45
|
+
value?: string;
|
|
46
|
+
min?: string;
|
|
47
|
+
max?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// URL serialization (per-source namespace)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const SOURCE_PREFIX = "s.";
|
|
55
|
+
const FILTER_INFIX = ".f.";
|
|
56
|
+
const SORT_SUFFIX = ".sort";
|
|
57
|
+
const DIR_SUFFIX = ".dir";
|
|
58
|
+
const PAGE_SIZE_SUFFIX = ".ps";
|
|
59
|
+
const PAGE_SUFFIX = ".page";
|
|
60
|
+
|
|
61
|
+
/** Global search term key (not source-scoped). */
|
|
62
|
+
export const GLOBAL_QUERY_KEY = "q";
|
|
63
|
+
|
|
64
|
+
function filterKey(sourceKey: string, field: string, suffix?: string) {
|
|
65
|
+
return `${SOURCE_PREFIX}${sourceKey}${FILTER_INFIX}${field}${suffix ? `.${suffix}` : ""}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The source's own default sort / page size. When a source's current state
|
|
70
|
+
* matches these, the corresponding URL params are omitted so the query string
|
|
71
|
+
* only ever reflects an *explicit* user choice — resetting filters or returning
|
|
72
|
+
* a source to its defaults yields a clean URL instead of one littered with the
|
|
73
|
+
* defaults. The read path falls back to these same defaults when the params are
|
|
74
|
+
* absent, so the round-trip is lossless.
|
|
75
|
+
*/
|
|
76
|
+
export interface SourceParamDefaults {
|
|
77
|
+
sort?: SortState | null;
|
|
78
|
+
pageSize?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sortMatchesDefault(sort: SortState, defaultSort: SortState | null | undefined): boolean {
|
|
82
|
+
return (
|
|
83
|
+
!!defaultSort && sort.field === defaultSort.field && sort.direction === defaultSort.direction
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeSourceParams(
|
|
88
|
+
params: URLSearchParams,
|
|
89
|
+
sourceKey: string,
|
|
90
|
+
filters: ActiveFilterValue[],
|
|
91
|
+
sort: SortState | null,
|
|
92
|
+
pageSize?: number,
|
|
93
|
+
pageIndex?: number,
|
|
94
|
+
defaults?: SourceParamDefaults,
|
|
95
|
+
): void {
|
|
96
|
+
for (const filter of filters) {
|
|
97
|
+
if (filter.value !== undefined && filter.value !== "") {
|
|
98
|
+
params.set(filterKey(sourceKey, filter.field), filter.value);
|
|
99
|
+
}
|
|
100
|
+
if (filter.min !== undefined && filter.min !== "") {
|
|
101
|
+
params.set(filterKey(sourceKey, filter.field, "min"), filter.min);
|
|
102
|
+
}
|
|
103
|
+
if (filter.max !== undefined && filter.max !== "") {
|
|
104
|
+
params.set(filterKey(sourceKey, filter.field, "max"), filter.max);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Only serialize sort when it differs from the source default — otherwise
|
|
108
|
+
// the URL would carry the default the user never chose.
|
|
109
|
+
if (sort && !sortMatchesDefault(sort, defaults?.sort)) {
|
|
110
|
+
params.set(`${SOURCE_PREFIX}${sourceKey}${SORT_SUFFIX}`, sort.field);
|
|
111
|
+
params.set(`${SOURCE_PREFIX}${sourceKey}${DIR_SUFFIX}`, sort.direction);
|
|
112
|
+
}
|
|
113
|
+
if (pageSize !== undefined && pageSize !== defaults?.pageSize) {
|
|
114
|
+
params.set(`${SOURCE_PREFIX}${sourceKey}${PAGE_SIZE_SUFFIX}`, String(pageSize));
|
|
115
|
+
}
|
|
116
|
+
if (pageIndex !== undefined && pageIndex > 0) {
|
|
117
|
+
params.set(`${SOURCE_PREFIX}${sourceKey}${PAGE_SUFFIX}`, String(pageIndex + 1));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ReadSourceParamsResult {
|
|
122
|
+
filters: ActiveFilterValue[];
|
|
123
|
+
sort: SortState | null;
|
|
124
|
+
pageSize: number | undefined;
|
|
125
|
+
pageIndex: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readSourceParams(
|
|
129
|
+
params: URLSearchParams,
|
|
130
|
+
sourceKey: string,
|
|
131
|
+
configs: FilterFieldConfig[],
|
|
132
|
+
): ReadSourceParamsResult {
|
|
133
|
+
const filters: ActiveFilterValue[] = [];
|
|
134
|
+
for (const config of configs) {
|
|
135
|
+
const { field, label, type } = config;
|
|
136
|
+
const value = params.get(filterKey(sourceKey, field)) ?? undefined;
|
|
137
|
+
const min = params.get(filterKey(sourceKey, field, "min")) ?? undefined;
|
|
138
|
+
const max = params.get(filterKey(sourceKey, field, "max")) ?? undefined;
|
|
139
|
+
|
|
140
|
+
const hasValue = value !== undefined && value !== "";
|
|
141
|
+
const hasRange = (min !== undefined && min !== "") || (max !== undefined && max !== "");
|
|
142
|
+
if (hasValue || hasRange) {
|
|
143
|
+
filters.push({ field, label, type, value, min, max });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let sort: SortState | null = null;
|
|
148
|
+
const sortField = params.get(`${SOURCE_PREFIX}${sourceKey}${SORT_SUFFIX}`);
|
|
149
|
+
const sortDir = params.get(`${SOURCE_PREFIX}${sourceKey}${DIR_SUFFIX}`);
|
|
150
|
+
if (sortField) {
|
|
151
|
+
sort = { field: sortField, direction: sortDir === "DESC" ? "DESC" : "ASC" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const pageSizeRaw = params.get(`${SOURCE_PREFIX}${sourceKey}${PAGE_SIZE_SUFFIX}`);
|
|
155
|
+
const pageSize = pageSizeRaw ? parseInt(pageSizeRaw, 10) : undefined;
|
|
156
|
+
const pageRaw = params.get(`${SOURCE_PREFIX}${sourceKey}${PAGE_SUFFIX}`);
|
|
157
|
+
const page = pageRaw ? parseInt(pageRaw, 10) : 1;
|
|
158
|
+
const pageIndex = !isNaN(page) && page > 1 ? page - 1 : 0;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
filters,
|
|
162
|
+
sort,
|
|
163
|
+
pageSize: pageSize && !isNaN(pageSize) ? pageSize : undefined,
|
|
164
|
+
pageIndex,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// GraphQL filter building
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Combines active filter values into a GraphQL `where` clause for one source.
|
|
174
|
+
* Returns `undefined` when no constraints are active.
|
|
175
|
+
*/
|
|
176
|
+
export function buildFilter(filters: ActiveFilterValue[], configs: FilterFieldConfig[]): unknown {
|
|
177
|
+
const configMap = new Map(configs.map((c) => [c.field, c]));
|
|
178
|
+
const clauses: unknown[] = [];
|
|
179
|
+
for (const filter of filters) {
|
|
180
|
+
const clause = buildSingleFilter(filter, configMap.get(filter.field));
|
|
181
|
+
if (clause) clauses.push(clause);
|
|
182
|
+
}
|
|
183
|
+
if (clauses.length === 0) return undefined;
|
|
184
|
+
if (clauses.length === 1) return clauses[0];
|
|
185
|
+
return { and: clauses };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function toStartOfDay(d: string) {
|
|
189
|
+
return `${d}T00:00:00.000Z`;
|
|
190
|
+
}
|
|
191
|
+
function toEndOfDay(d: string) {
|
|
192
|
+
return `${d}T23:59:59.999Z`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildSingleFilter(filter: ActiveFilterValue, _config?: FilterFieldConfig): unknown {
|
|
196
|
+
const { field, type, value, min, max } = filter;
|
|
197
|
+
switch (type) {
|
|
198
|
+
case "text": {
|
|
199
|
+
if (!value) return null;
|
|
200
|
+
return { [field]: { like: `%${value}%` } };
|
|
201
|
+
}
|
|
202
|
+
case "picklist": {
|
|
203
|
+
if (!value) return null;
|
|
204
|
+
return { [field]: { eq: value } };
|
|
205
|
+
}
|
|
206
|
+
case "numeric": {
|
|
207
|
+
if (!min && !max) return null;
|
|
208
|
+
const ops: Record<string, number> = {};
|
|
209
|
+
if (min) ops.gte = Number(min);
|
|
210
|
+
if (max) ops.lte = Number(max);
|
|
211
|
+
return { [field]: ops };
|
|
212
|
+
}
|
|
213
|
+
case "boolean": {
|
|
214
|
+
if (value === undefined || value === "") return null;
|
|
215
|
+
return { [field]: { eq: value === "true" } };
|
|
216
|
+
}
|
|
217
|
+
case "multipicklist": {
|
|
218
|
+
if (!value) return null;
|
|
219
|
+
const values = value.split(",").filter(Boolean);
|
|
220
|
+
if (values.length === 0) return null;
|
|
221
|
+
if (values.length === 1) return { [field]: { eq: values[0] } };
|
|
222
|
+
return { [field]: { in: values } };
|
|
223
|
+
}
|
|
224
|
+
case "date": {
|
|
225
|
+
if (!min && !max) return null;
|
|
226
|
+
const op = min ? "gte" : "lte";
|
|
227
|
+
const dateStr = min ?? max;
|
|
228
|
+
return { [field]: { [op]: { value: dateStr } } };
|
|
229
|
+
}
|
|
230
|
+
case "daterange": {
|
|
231
|
+
if (!min && !max) return null;
|
|
232
|
+
const clauses: unknown[] = [];
|
|
233
|
+
if (min) clauses.push({ [field]: { gte: { value: min } } });
|
|
234
|
+
if (max) clauses.push({ [field]: { lte: { value: max } } });
|
|
235
|
+
return clauses.length === 1 ? clauses[0] : { and: clauses };
|
|
236
|
+
}
|
|
237
|
+
case "datetime": {
|
|
238
|
+
if (!min && !max) return null;
|
|
239
|
+
const op = min ? "gte" : "lte";
|
|
240
|
+
const dateStr = min ?? max;
|
|
241
|
+
const isoStr = op === "gte" ? toStartOfDay(dateStr!) : toEndOfDay(dateStr!);
|
|
242
|
+
return { [field]: { [op]: { value: isoStr } } };
|
|
243
|
+
}
|
|
244
|
+
case "datetimerange": {
|
|
245
|
+
if (!min && !max) return null;
|
|
246
|
+
const clauses: unknown[] = [];
|
|
247
|
+
if (min) clauses.push({ [field]: { gte: { value: toStartOfDay(min) } } });
|
|
248
|
+
if (max) clauses.push({ [field]: { lte: { value: toEndOfDay(max) } } });
|
|
249
|
+
return clauses.length === 1 ? clauses[0] : { and: clauses };
|
|
250
|
+
}
|
|
251
|
+
default:
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Builds a global-q `or` clause across the source's searchable fields.
|
|
258
|
+
* Returns `undefined` when q is empty or the source has no searchable fields.
|
|
259
|
+
*/
|
|
260
|
+
export function buildGlobalQueryClause(q: string, searchableFields: string[]): unknown {
|
|
261
|
+
const trimmed = q.trim();
|
|
262
|
+
if (!trimmed || searchableFields.length === 0) return undefined;
|
|
263
|
+
const clauses = searchableFields.map((path) => {
|
|
264
|
+
const parts = path.split(".");
|
|
265
|
+
let clause: Record<string, unknown> = { like: `%${trimmed}%` };
|
|
266
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
267
|
+
clause = { [parts[i]]: clause };
|
|
268
|
+
}
|
|
269
|
+
return clause;
|
|
270
|
+
});
|
|
271
|
+
return clauses.length === 1 ? clauses[0] : { or: clauses };
|
|
272
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sort state and order-by builder for search.
|
|
3
|
+
*
|
|
4
|
+
* The order-by output shape matches the Salesforce uiapi GraphQL convention:
|
|
5
|
+
* { [field]: { order: ASC|DESC, nulls: LAST } }
|
|
6
|
+
*
|
|
7
|
+
* The constants are stringly-typed here (rather than imported from codegen)
|
|
8
|
+
* because search builds its query at runtime and isn't tied to a
|
|
9
|
+
* specific generated schema.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type SortFieldConfig<TFieldName extends string = string> = {
|
|
13
|
+
field: TFieldName;
|
|
14
|
+
label: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type SortState<TFieldName extends string = string> = {
|
|
18
|
+
field: TFieldName;
|
|
19
|
+
direction: "ASC" | "DESC";
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const ORDER_ASC = "ASC";
|
|
23
|
+
const ORDER_DESC = "DESC";
|
|
24
|
+
const NULLS_LAST = "LAST";
|
|
25
|
+
|
|
26
|
+
export function buildOrderBy(sort: SortState | null): unknown {
|
|
27
|
+
if (!sort) return undefined;
|
|
28
|
+
return {
|
|
29
|
+
[sort.field]: {
|
|
30
|
+
order: sort.direction === "ASC" ? ORDER_ASC : ORDER_DESC,
|
|
31
|
+
nulls: NULLS_LAST,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Search, config } from "../features/search";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Home page for the search feature. Renders the drop-in `<Search>` against the
|
|
5
|
+
* static `config.json` so the index route ("/") shows the full search
|
|
6
|
+
* experience. This file overrides the base app's placeholder Home page when the
|
|
7
|
+
* feature is applied.
|
|
8
|
+
*/
|
|
9
|
+
export default function Home() {
|
|
10
|
+
return <Search config={config} title="Search" />;
|
|
11
|
+
}
|