@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.
Files changed (169) hide show
  1. package/LICENSE.txt +82 -0
  2. package/README.md +692 -0
  3. package/dist/.forceignore +15 -0
  4. package/dist/.husky/pre-commit +4 -0
  5. package/dist/.prettierignore +11 -0
  6. package/dist/.prettierrc +17 -0
  7. package/dist/CHANGELOG.md +3499 -0
  8. package/dist/README.md +28 -0
  9. package/dist/config/project-scratch-def.json +13 -0
  10. package/dist/eslint.config.js +7 -0
  11. package/dist/force-app/main/default/uiBundles/feature-react-search/.forceignore +15 -0
  12. package/dist/force-app/main/default/uiBundles/feature-react-search/.graphqlrc.yml +2 -0
  13. package/dist/force-app/main/default/uiBundles/feature-react-search/.prettierignore +9 -0
  14. package/dist/force-app/main/default/uiBundles/feature-react-search/.prettierrc +11 -0
  15. package/dist/force-app/main/default/uiBundles/feature-react-search/CHANGELOG.md +10 -0
  16. package/dist/force-app/main/default/uiBundles/feature-react-search/README.md +75 -0
  17. package/dist/force-app/main/default/uiBundles/feature-react-search/codegen.yml +95 -0
  18. package/dist/force-app/main/default/uiBundles/feature-react-search/components.json +18 -0
  19. package/dist/force-app/main/default/uiBundles/feature-react-search/e2e/app.spec.ts +17 -0
  20. package/dist/force-app/main/default/uiBundles/feature-react-search/eslint.config.js +169 -0
  21. package/dist/force-app/main/default/uiBundles/feature-react-search/feature-react-search.uibundle-meta.xml +7 -0
  22. package/dist/force-app/main/default/uiBundles/feature-react-search/index.html +12 -0
  23. package/dist/force-app/main/default/uiBundles/feature-react-search/package.json +76 -0
  24. package/dist/force-app/main/default/uiBundles/feature-react-search/playwright.config.ts +24 -0
  25. package/dist/force-app/main/default/uiBundles/feature-react-search/scripts/get-graphql-schema.mjs +71 -0
  26. package/dist/force-app/main/default/uiBundles/feature-react-search/scripts/rewrite-e2e-assets.mjs +23 -0
  27. package/dist/force-app/main/default/uiBundles/feature-react-search/src/api/graphqlClient.ts +44 -0
  28. package/dist/force-app/main/default/uiBundles/feature-react-search/src/app.tsx +17 -0
  29. package/dist/force-app/main/default/uiBundles/feature-react-search/src/appLayout.tsx +83 -0
  30. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/book.svg +3 -0
  31. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/copy.svg +4 -0
  32. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/rocket.svg +3 -0
  33. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/star.svg +3 -0
  34. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/codey-1.png +0 -0
  35. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/codey-2.png +0 -0
  36. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/codey-3.png +0 -0
  37. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/vibe-codey.svg +194 -0
  38. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/alerts/status-alert.tsx +52 -0
  39. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/layouts/card-layout.tsx +29 -0
  40. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/alert.tsx +76 -0
  41. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/avatar.tsx +109 -0
  42. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/badge.tsx +48 -0
  43. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/breadcrumb.tsx +109 -0
  44. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/button.tsx +67 -0
  45. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/calendar.tsx +232 -0
  46. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/card.tsx +103 -0
  47. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/checkbox.tsx +32 -0
  48. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/collapsible.tsx +33 -0
  49. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/datePicker.tsx +127 -0
  50. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/dialog.tsx +162 -0
  51. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/dropdown-menu.tsx +257 -0
  52. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/field.tsx +237 -0
  53. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/index.ts +109 -0
  54. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/input.tsx +19 -0
  55. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/label.tsx +22 -0
  56. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/pagination.tsx +132 -0
  57. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/popover.tsx +89 -0
  58. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/select.tsx +193 -0
  59. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/separator.tsx +26 -0
  60. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/skeleton.tsx +14 -0
  61. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/sonner.tsx +20 -0
  62. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/spinner.tsx +16 -0
  63. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/table.tsx +114 -0
  64. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/tabs.tsx +88 -0
  65. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/distinctValuesService.ts +84 -0
  66. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/searchService.ts +71 -0
  67. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +160 -0
  68. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +77 -0
  69. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +167 -0
  70. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +115 -0
  71. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/ScopeSelector.tsx +55 -0
  72. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SearchBar.tsx +55 -0
  73. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SortControl.tsx +62 -0
  74. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/ActiveFilters.tsx +61 -0
  75. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +122 -0
  76. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/FilterContext.tsx +70 -0
  77. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/BooleanFilter.tsx +50 -0
  78. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +50 -0
  79. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/FilterFieldWrapper.tsx +26 -0
  80. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +47 -0
  81. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/NumericRangeFilter.tsx +78 -0
  82. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/SelectFilter.tsx +46 -0
  83. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/TextFilter.tsx +52 -0
  84. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/results/DefaultResultRow.tsx +109 -0
  85. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +82 -0
  86. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useAsyncData.ts +56 -0
  87. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useDistinctValues.ts +59 -0
  88. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +442 -0
  89. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +80 -0
  90. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/loadConfig.ts +14 -0
  91. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +156 -0
  92. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +169 -0
  93. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/debounce.ts +17 -0
  94. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/fieldUtils.ts +14 -0
  95. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +272 -0
  96. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/sortUtils.ts +34 -0
  97. package/dist/force-app/main/default/uiBundles/feature-react-search/src/hooks/useAsyncData.ts +67 -0
  98. package/dist/force-app/main/default/uiBundles/feature-react-search/src/lib/utils.ts +6 -0
  99. package/dist/force-app/main/default/uiBundles/feature-react-search/src/navigationMenu.tsx +80 -0
  100. package/dist/force-app/main/default/uiBundles/feature-react-search/src/pages/Home.tsx +11 -0
  101. package/dist/force-app/main/default/uiBundles/feature-react-search/src/pages/NotFound.tsx +18 -0
  102. package/dist/force-app/main/default/uiBundles/feature-react-search/src/router-utils.tsx +35 -0
  103. package/dist/force-app/main/default/uiBundles/feature-react-search/src/routes.tsx +22 -0
  104. package/dist/force-app/main/default/uiBundles/feature-react-search/src/styles/global.css +135 -0
  105. package/dist/force-app/main/default/uiBundles/feature-react-search/tsconfig.json +45 -0
  106. package/dist/force-app/main/default/uiBundles/feature-react-search/tsconfig.node.json +13 -0
  107. package/dist/force-app/main/default/uiBundles/feature-react-search/ui-bundle.json +7 -0
  108. package/dist/force-app/main/default/uiBundles/feature-react-search/vite-env.d.ts +4 -0
  109. package/dist/force-app/main/default/uiBundles/feature-react-search/vite.config.ts +106 -0
  110. package/dist/force-app/main/default/uiBundles/feature-react-search/vitest-env.d.ts +2 -0
  111. package/dist/force-app/main/default/uiBundles/feature-react-search/vitest.config.ts +11 -0
  112. package/dist/force-app/main/default/uiBundles/feature-react-search/vitest.setup.ts +1 -0
  113. package/dist/jest.config.js +6 -0
  114. package/dist/package-lock.json +9995 -0
  115. package/dist/package.json +44 -0
  116. package/dist/scripts/apex/hello.apex +10 -0
  117. package/dist/scripts/gitignore-templates.json +4 -0
  118. package/dist/scripts/graphql-search.sh +191 -0
  119. package/dist/scripts/org-setup-config-schema.mjs +96 -0
  120. package/dist/scripts/org-setup.config.json +5 -0
  121. package/dist/scripts/org-setup.mjs +1392 -0
  122. package/dist/scripts/sf-project-setup.mjs +103 -0
  123. package/dist/scripts/soql/account.soql +6 -0
  124. package/dist/scripts/validate-org-setup-config.mjs +38 -0
  125. package/dist/sfdx-project.json +12 -0
  126. package/package.json +51 -0
  127. package/src/force-app/main/default/uiBundles/feature-react-search/src/__inherit__appLayout.tsx +9 -0
  128. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__alert.tsx +39 -0
  129. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__button.tsx +45 -0
  130. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__checkbox.tsx +8 -0
  131. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__input.tsx +5 -0
  132. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__label.tsx +8 -0
  133. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__pagination.tsx +47 -0
  134. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__select.tsx +57 -0
  135. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__skeleton.tsx +5 -0
  136. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/distinctValuesService.ts +84 -0
  137. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/searchService.ts +71 -0
  138. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +160 -0
  139. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +77 -0
  140. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +167 -0
  141. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +115 -0
  142. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/ScopeSelector.tsx +55 -0
  143. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SearchBar.tsx +55 -0
  144. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SortControl.tsx +62 -0
  145. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/ActiveFilters.tsx +61 -0
  146. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +122 -0
  147. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/FilterContext.tsx +70 -0
  148. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/BooleanFilter.tsx +50 -0
  149. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +50 -0
  150. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/FilterFieldWrapper.tsx +26 -0
  151. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +47 -0
  152. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/NumericRangeFilter.tsx +78 -0
  153. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/SelectFilter.tsx +46 -0
  154. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/TextFilter.tsx +52 -0
  155. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/results/DefaultResultRow.tsx +109 -0
  156. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +82 -0
  157. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useAsyncData.ts +56 -0
  158. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useDistinctValues.ts +59 -0
  159. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +442 -0
  160. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +80 -0
  161. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/loadConfig.ts +14 -0
  162. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +156 -0
  163. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +169 -0
  164. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/debounce.ts +17 -0
  165. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/fieldUtils.ts +14 -0
  166. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +272 -0
  167. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/sortUtils.ts +34 -0
  168. package/src/force-app/main/default/uiBundles/feature-react-search/src/pages/Home.tsx +11 -0
  169. package/src/force-app/main/default/uiBundles/feature-react-search/src/routes.tsx +10 -0
@@ -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
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Default filter panel used when no `renderFilters` is supplied for a source.
3
+ *
4
+ * Renders one input per entry in `source.filterFields`, picking the right
5
+ * component for each `FilterFieldType`:
6
+ *
7
+ * - text → TextFilter
8
+ * - picklist → SelectFilter (inline options or auto-fetched)
9
+ * - multipicklist → MultiSelectFilter (inline options or auto-fetched)
10
+ * - numeric → NumericRangeFilter
11
+ * - boolean → BooleanFilter
12
+ * - date / daterange / datetime / datetimerange → DateRangeFilter
13
+ *
14
+ * Applications can still pass `renderFilters[sourceKey]` to override the
15
+ * entire panel for a specific source.
16
+ */
17
+
18
+ import { TextFilter } from "./inputs/TextFilter";
19
+ import { SelectFilter } from "./inputs/SelectFilter";
20
+ import { MultiSelectFilter } from "./inputs/MultiSelectFilter";
21
+ import { NumericRangeFilter } from "./inputs/NumericRangeFilter";
22
+ import { BooleanFilter } from "./inputs/BooleanFilter";
23
+ import { DateRangeFilter } from "./inputs/DateRangeFilter";
24
+ import { useDistinctValues } from "../../hooks/useDistinctValues";
25
+ import type { FilterFieldConfig } from "../../utils/filterUtils";
26
+ import type { SObjectSourceConfig } from "../../types";
27
+
28
+ interface DefaultFilterPanelProps {
29
+ source: SObjectSourceConfig;
30
+ }
31
+
32
+ export function DefaultFilterPanel({ source }: DefaultFilterPanelProps) {
33
+ const filters = source.filterFields ?? [];
34
+ if (filters.length === 0) return null;
35
+ return (
36
+ <>
37
+ {filters.map((filter) => (
38
+ <DefaultFilterField key={filter.field} filter={filter} source={source} />
39
+ ))}
40
+ </>
41
+ );
42
+ }
43
+
44
+ interface DefaultFilterFieldProps {
45
+ filter: FilterFieldConfig;
46
+ source: SObjectSourceConfig;
47
+ }
48
+
49
+ function DefaultFilterField({ filter, source }: DefaultFilterFieldProps) {
50
+ switch (filter.type) {
51
+ case "text":
52
+ return (
53
+ <TextFilter
54
+ field={filter.field}
55
+ label={filter.label}
56
+ placeholder={filter.placeholder}
57
+ helpText={filter.helpText}
58
+ />
59
+ );
60
+ case "picklist":
61
+ return <PicklistField filter={filter} source={source} multi={false} />;
62
+ case "multipicklist":
63
+ return <PicklistField filter={filter} source={source} multi={true} />;
64
+ case "numeric":
65
+ return (
66
+ <NumericRangeFilter field={filter.field} label={filter.label} helpText={filter.helpText} />
67
+ );
68
+ case "boolean":
69
+ return <BooleanFilter field={filter.field} label={filter.label} helpText={filter.helpText} />;
70
+ case "date":
71
+ case "daterange":
72
+ return (
73
+ <DateRangeFilter
74
+ field={filter.field}
75
+ label={filter.label}
76
+ helpText={filter.helpText}
77
+ filterType="daterange"
78
+ />
79
+ );
80
+ case "datetime":
81
+ case "datetimerange":
82
+ return (
83
+ <DateRangeFilter
84
+ field={filter.field}
85
+ label={filter.label}
86
+ helpText={filter.helpText}
87
+ filterType="datetimerange"
88
+ />
89
+ );
90
+ default:
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Resolves picklist options from inline config first; falls back to fetching
97
+ * distinct values from the GraphQL aggregate API on first render.
98
+ */
99
+ function PicklistField({
100
+ filter,
101
+ source,
102
+ multi,
103
+ }: {
104
+ filter: FilterFieldConfig;
105
+ source: SObjectSourceConfig;
106
+ multi: boolean;
107
+ }) {
108
+ const inline = filter.options;
109
+ const fetched = useDistinctValues(
110
+ inline ? null : { objectName: source.objectName, fieldName: filter.field },
111
+ );
112
+ const options = inline ?? fetched ?? [];
113
+ const Component = multi ? MultiSelectFilter : SelectFilter;
114
+ return (
115
+ <Component
116
+ field={filter.field}
117
+ label={filter.label}
118
+ options={options}
119
+ helpText={filter.helpText}
120
+ />
121
+ );
122
+ }
@@ -0,0 +1,70 @@
1
+ import { createContext, useCallback, useContext, type ReactNode } from "react";
2
+ import { Button } from "../../../../components/ui/button";
3
+ import type { ActiveFilterValue } from "../../utils/filterUtils";
4
+
5
+ interface FilterContextValue {
6
+ filters: ActiveFilterValue[];
7
+ onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
8
+ onFilterRemove: (field: string) => void;
9
+ onReset: () => void;
10
+ }
11
+
12
+ const FilterContext = createContext<FilterContextValue | null>(null);
13
+
14
+ interface FilterProviderProps {
15
+ filters: ActiveFilterValue[];
16
+ onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
17
+ onFilterRemove: (field: string) => void;
18
+ onReset: () => void;
19
+ children: ReactNode;
20
+ }
21
+
22
+ export function FilterProvider({
23
+ filters,
24
+ onFilterChange,
25
+ onFilterRemove,
26
+ onReset,
27
+ children,
28
+ }: FilterProviderProps) {
29
+ return (
30
+ <FilterContext.Provider value={{ filters, onFilterChange, onFilterRemove, onReset }}>
31
+ {children}
32
+ </FilterContext.Provider>
33
+ );
34
+ }
35
+
36
+ function useFilterContext() {
37
+ const ctx = useContext(FilterContext);
38
+ if (!ctx) throw new Error("useFilterField must be used within a FilterProvider");
39
+ return ctx;
40
+ }
41
+
42
+ export function useFilterField(field: string) {
43
+ const { filters, onFilterChange, onFilterRemove } = useFilterContext();
44
+ const value = filters.find((f) => f.field === field);
45
+ const onChange = useCallback(
46
+ (next: ActiveFilterValue | undefined) => {
47
+ if (next) onFilterChange(field, next);
48
+ else onFilterRemove(field);
49
+ },
50
+ [field, onFilterChange, onFilterRemove],
51
+ );
52
+ return { value, onChange };
53
+ }
54
+
55
+ export function useFilterPanel() {
56
+ const { filters, onReset } = useFilterContext();
57
+ return { hasActiveFilters: filters.length > 0, resetAll: onReset };
58
+ }
59
+
60
+ type FilterResetButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick">;
61
+
62
+ export function FilterResetButton({ children, ...props }: FilterResetButtonProps) {
63
+ const { hasActiveFilters, resetAll } = useFilterPanel();
64
+ if (!hasActiveFilters) return null;
65
+ return (
66
+ <Button onClick={resetAll} aria-label="Reset filters" variant="destructive" {...props}>
67
+ {children ?? "Reset"}
68
+ </Button>
69
+ );
70
+ }
@@ -0,0 +1,50 @@
1
+ import {
2
+ Select,
3
+ SelectContent,
4
+ SelectItem,
5
+ SelectTrigger,
6
+ SelectValue,
7
+ } from "../../../../../components/ui/select";
8
+ import { useFilterField } from "../FilterContext";
9
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
10
+
11
+ const ANY_VALUE = "__any__";
12
+
13
+ interface BooleanFilterProps {
14
+ field: string;
15
+ label: string;
16
+ trueLabel?: string;
17
+ falseLabel?: string;
18
+ helpText?: string;
19
+ }
20
+
21
+ export function BooleanFilter({
22
+ field,
23
+ label,
24
+ trueLabel = "Yes",
25
+ falseLabel = "No",
26
+ helpText,
27
+ }: BooleanFilterProps) {
28
+ const { value, onChange } = useFilterField(field);
29
+ const id = `filter-${field}`;
30
+ return (
31
+ <FilterFieldWrapper label={label} htmlFor={id} helpText={helpText}>
32
+ <Select
33
+ value={value?.value ?? ANY_VALUE}
34
+ onValueChange={(v) => {
35
+ if (v === ANY_VALUE) onChange(undefined);
36
+ else onChange({ field, label, type: "boolean", value: v });
37
+ }}
38
+ >
39
+ <SelectTrigger id={id} className="w-full">
40
+ <SelectValue />
41
+ </SelectTrigger>
42
+ <SelectContent>
43
+ <SelectItem value={ANY_VALUE}>Any</SelectItem>
44
+ <SelectItem value="true">{trueLabel}</SelectItem>
45
+ <SelectItem value="false">{falseLabel}</SelectItem>
46
+ </SelectContent>
47
+ </Select>
48
+ </FilterFieldWrapper>
49
+ );
50
+ }
@@ -0,0 +1,50 @@
1
+ import { Input } from "../../../../../components/ui/input";
2
+ import { useFilterField } from "../FilterContext";
3
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
4
+ import type { FilterFieldType } from "../../../utils/filterUtils";
5
+
6
+ interface DateRangeFilterProps {
7
+ field: string;
8
+ label: string;
9
+ helpText?: string;
10
+ /** "daterange" (Date scalar) or "datetimerange" (DateTime scalar). */
11
+ filterType?: Extract<FilterFieldType, "daterange" | "datetimerange">;
12
+ }
13
+
14
+ /**
15
+ * Two `<input type="date">` controls bound to `min` / `max` of the active
16
+ * filter. Uses the platform-native picker — no popover/calendar dependency.
17
+ */
18
+ export function DateRangeFilter({
19
+ field,
20
+ label,
21
+ helpText,
22
+ filterType = "daterange",
23
+ }: DateRangeFilterProps) {
24
+ const { value, onChange } = useFilterField(field);
25
+
26
+ const update = (nextMin: string | undefined, nextMax: string | undefined) => {
27
+ if (!nextMin && !nextMax) onChange(undefined);
28
+ else onChange({ field, label, type: filterType, min: nextMin, max: nextMax });
29
+ };
30
+
31
+ return (
32
+ <FilterFieldWrapper label={label} helpText={helpText}>
33
+ <div className="flex items-center gap-2">
34
+ <Input
35
+ type="date"
36
+ value={value?.min ?? ""}
37
+ onChange={(e) => update(e.target.value || undefined, value?.max)}
38
+ aria-label={`${label} from`}
39
+ />
40
+ <span className="text-sm text-muted-foreground">–</span>
41
+ <Input
42
+ type="date"
43
+ value={value?.max ?? ""}
44
+ onChange={(e) => update(value?.min, e.target.value || undefined)}
45
+ aria-label={`${label} to`}
46
+ />
47
+ </div>
48
+ </FilterFieldWrapper>
49
+ );
50
+ }
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from "react";
2
+ import { Label } from "../../../../../components/ui/label";
3
+
4
+ interface FilterFieldWrapperProps {
5
+ label: string;
6
+ htmlFor?: string;
7
+ helpText?: string;
8
+ className?: string;
9
+ children: ReactNode;
10
+ }
11
+
12
+ export function FilterFieldWrapper({
13
+ label,
14
+ htmlFor,
15
+ helpText,
16
+ className,
17
+ children,
18
+ }: FilterFieldWrapperProps) {
19
+ return (
20
+ <div className={`space-y-1 ${className ?? ""}`}>
21
+ <Label htmlFor={htmlFor}>{label}</Label>
22
+ {children}
23
+ {helpText && <p className="text-xs text-muted-foreground min-h-4">{helpText}</p>}
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,47 @@
1
+ import { Checkbox } from "../../../../../components/ui/checkbox";
2
+ import { Label } from "../../../../../components/ui/label";
3
+ import { useFilterField } from "../FilterContext";
4
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
5
+
6
+ interface MultiSelectFilterProps {
7
+ field: string;
8
+ label: string;
9
+ options: Array<{ value: string; label: string }>;
10
+ helpText?: string;
11
+ }
12
+
13
+ export function MultiSelectFilter({ field, label, options, helpText }: MultiSelectFilterProps) {
14
+ const { value, onChange } = useFilterField(field);
15
+ const selected = new Set(value?.value ? value.value.split(",").filter(Boolean) : []);
16
+
17
+ const toggle = (v: string, on: boolean) => {
18
+ const next = new Set(selected);
19
+ if (on) next.add(v);
20
+ else next.delete(v);
21
+ const joined = Array.from(next).join(",");
22
+ if (joined === "") onChange(undefined);
23
+ else onChange({ field, label, type: "multipicklist", value: joined });
24
+ };
25
+
26
+ return (
27
+ <FilterFieldWrapper label={label} helpText={helpText}>
28
+ <div className="flex flex-col gap-1">
29
+ {options.map((opt) => {
30
+ const id = `filter-${field}-${opt.value}`;
31
+ return (
32
+ <div key={opt.value} className="flex items-center gap-2">
33
+ <Checkbox
34
+ id={id}
35
+ checked={selected.has(opt.value)}
36
+ onCheckedChange={(checked) => toggle(opt.value, checked === true)}
37
+ />
38
+ <Label htmlFor={id} className="text-sm font-normal cursor-pointer">
39
+ {opt.label}
40
+ </Label>
41
+ </div>
42
+ );
43
+ })}
44
+ </div>
45
+ </FilterFieldWrapper>
46
+ );
47
+ }
@@ -0,0 +1,78 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Input } from "../../../../../components/ui/input";
3
+ import { useFilterField } from "../FilterContext";
4
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
5
+ import { debounce, FILTER_DEBOUNCE_MS } from "../../../utils/debounce";
6
+
7
+ interface NumericRangeFilterProps {
8
+ field: string;
9
+ label: string;
10
+ helpText?: string;
11
+ min?: number;
12
+ max?: number;
13
+ }
14
+
15
+ export function NumericRangeFilter({ field, label, helpText, min, max }: NumericRangeFilterProps) {
16
+ const { value, onChange } = useFilterField(field);
17
+ const [draftMin, setDraftMin] = useState(value?.min ?? "");
18
+ const [draftMax, setDraftMax] = useState(value?.max ?? "");
19
+
20
+ const onChangeRef = useRef(onChange);
21
+ useEffect(() => {
22
+ onChangeRef.current = onChange;
23
+ });
24
+ // Build the debounced caller in a mount effect, not during render: creating it
25
+ // inline would read onChangeRef.current at render time (react-hooks/refs). It
26
+ // stays a single stable instance and always calls the latest onChange via the ref.
27
+ const debouncedRef = useRef<((nextMin: string, nextMax: string) => void) | undefined>(undefined);
28
+ useEffect(() => {
29
+ debouncedRef.current = debounce((nextMin: string, nextMax: string) => {
30
+ if (!nextMin && !nextMax) onChangeRef.current(undefined);
31
+ else
32
+ onChangeRef.current({
33
+ field,
34
+ label,
35
+ type: "numeric",
36
+ min: nextMin || undefined,
37
+ max: nextMax || undefined,
38
+ });
39
+ }, FILTER_DEBOUNCE_MS);
40
+ }, [field, label]);
41
+
42
+ useEffect(() => {
43
+ setDraftMin(value?.min ?? "");
44
+ setDraftMax(value?.max ?? "");
45
+ }, [value?.min, value?.max]);
46
+
47
+ return (
48
+ <FilterFieldWrapper label={label} helpText={helpText}>
49
+ <div className="flex items-center gap-2">
50
+ <Input
51
+ type="number"
52
+ placeholder="Min"
53
+ min={min}
54
+ max={max}
55
+ value={draftMin}
56
+ onChange={(e) => {
57
+ setDraftMin(e.target.value);
58
+ debouncedRef.current?.(e.target.value, draftMax);
59
+ }}
60
+ aria-label={`${label} minimum`}
61
+ />
62
+ <span className="text-sm text-muted-foreground">–</span>
63
+ <Input
64
+ type="number"
65
+ placeholder="Max"
66
+ min={min}
67
+ max={max}
68
+ value={draftMax}
69
+ onChange={(e) => {
70
+ setDraftMax(e.target.value);
71
+ debouncedRef.current?.(draftMin, e.target.value);
72
+ }}
73
+ aria-label={`${label} maximum`}
74
+ />
75
+ </div>
76
+ </FilterFieldWrapper>
77
+ );
78
+ }