@salesforce/webapp-template-feature-react-global-search-experimental 1.3.3

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 (111) hide show
  1. package/LICENSE.txt +82 -0
  2. package/README.md +415 -0
  3. package/dist/.a4drules/build-validation.md +81 -0
  4. package/dist/.a4drules/code-quality.md +150 -0
  5. package/dist/.a4drules/graphql/tools/knowledge/lds-explore-graphql-schema.md +227 -0
  6. package/dist/.a4drules/graphql/tools/knowledge/lds-generate-graphql-mutationquery.md +211 -0
  7. package/dist/.a4drules/graphql/tools/knowledge/lds-generate-graphql-readquery.md +185 -0
  8. package/dist/.a4drules/graphql/tools/knowledge/lds-guide-graphql.md +205 -0
  9. package/dist/.a4drules/graphql/tools/schemas/shared.graphqls +1150 -0
  10. package/dist/.a4drules/graphql.md +98 -0
  11. package/dist/.a4drules/images.md +13 -0
  12. package/dist/.a4drules/react.md +361 -0
  13. package/dist/.a4drules/react_image_processing.md +45 -0
  14. package/dist/.a4drules/typescript.md +224 -0
  15. package/dist/.forceignore +15 -0
  16. package/dist/.husky/pre-commit +4 -0
  17. package/dist/.prettierignore +11 -0
  18. package/dist/.prettierrc +17 -0
  19. package/dist/CHANGELOG.md +11 -0
  20. package/dist/README.md +18 -0
  21. package/dist/config/project-scratch-def.json +13 -0
  22. package/dist/force-app/main/default/webapplications/feature-react-global-search/.prettierignore +9 -0
  23. package/dist/force-app/main/default/webapplications/feature-react-global-search/.prettierrc +11 -0
  24. package/dist/force-app/main/default/webapplications/feature-react-global-search/eslint.config.js +113 -0
  25. package/dist/force-app/main/default/webapplications/feature-react-global-search/feature-react-global-search.webapplication-meta.xml +7 -0
  26. package/dist/force-app/main/default/webapplications/feature-react-global-search/index.html +13 -0
  27. package/dist/force-app/main/default/webapplications/feature-react-global-search/package.json +42 -0
  28. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/api/graphql-operations-types.ts +127 -0
  29. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/api/objectInfoService.ts +229 -0
  30. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/api/utils/query/highRevenueAccountsQuery.graphql +29 -0
  31. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/app.tsx +13 -0
  32. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/appLayout.tsx +9 -0
  33. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/assets/icons/book.svg +3 -0
  34. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/assets/icons/copy.svg +4 -0
  35. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/assets/icons/rocket.svg +3 -0
  36. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/assets/icons/star.svg +3 -0
  37. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/assets/images/codey-1.png +0 -0
  38. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/assets/images/codey-2.png +0 -0
  39. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/assets/images/codey-3.png +0 -0
  40. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/assets/images/vibe-codey.svg +194 -0
  41. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/FiltersPanel.tsx +373 -0
  42. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/LoadingFallback.tsx +61 -0
  43. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/SearchResultCard.tsx +127 -0
  44. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/alerts/status-alert.tsx +45 -0
  45. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/DetailFields.tsx +57 -0
  46. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/DetailHeader.tsx +42 -0
  47. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/filters/FilterField.tsx +54 -0
  48. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/filters/FilterInput.tsx +55 -0
  49. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/filters/FilterSelect.tsx +72 -0
  50. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/forms/filters-form.tsx +114 -0
  51. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/forms/submit-button.tsx +47 -0
  52. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/layout/card-layout.tsx +19 -0
  53. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/search/ResultCardFields.tsx +71 -0
  54. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/search/SearchHeader.tsx +23 -0
  55. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/search/SearchPagination.tsx +162 -0
  56. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/search/SearchResultsPanel.tsx +184 -0
  57. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/shared/GlobalSearchInput.tsx +110 -0
  58. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/alert.tsx +65 -0
  59. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/button.tsx +56 -0
  60. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/card.tsx +77 -0
  61. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/field.tsx +111 -0
  62. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/index.ts +71 -0
  63. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/input.tsx +19 -0
  64. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/label.tsx +19 -0
  65. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/pagination.tsx +99 -0
  66. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/select.tsx +151 -0
  67. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/skeleton.tsx +7 -0
  68. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/spinner.tsx +21 -0
  69. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/table.tsx +114 -0
  70. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/ui/tabs.tsx +115 -0
  71. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/constants.ts +36 -0
  72. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/features/global-search/index.ts +65 -0
  73. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/hooks/form.tsx +208 -0
  74. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/hooks/useObjectSearchData.ts +419 -0
  75. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/hooks/useRecordDetail.ts +127 -0
  76. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/lib/utils.ts +6 -0
  77. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/pages/About.tsx +12 -0
  78. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/pages/DetailPage.tsx +128 -0
  79. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/pages/GlobalSearch.tsx +173 -0
  80. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/pages/Home.tsx +13 -0
  81. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/pages/NotFound.tsx +18 -0
  82. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/routes.tsx +50 -0
  83. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/styles/global.css +108 -0
  84. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/types/filters/filters.ts +122 -0
  85. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/types/filters/picklist.ts +32 -0
  86. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/types/objectInfo/objectInfo.ts +166 -0
  87. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/types/search/searchResults.ts +228 -0
  88. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/apiUtils.ts +125 -0
  89. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/cacheUtils.ts +76 -0
  90. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/debounce.ts +89 -0
  91. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/fieldUtils.ts +186 -0
  92. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/fieldValueExtractor.ts +67 -0
  93. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/filterUtils.ts +32 -0
  94. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/formUtils.ts +130 -0
  95. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/paginationUtils.ts +49 -0
  96. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/recordUtils.ts +75 -0
  97. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/sanitizationUtils.ts +49 -0
  98. package/dist/force-app/main/default/webapplications/feature-react-global-search/tsconfig.json +36 -0
  99. package/dist/force-app/main/default/webapplications/feature-react-global-search/tsconfig.node.json +13 -0
  100. package/dist/force-app/main/default/webapplications/feature-react-global-search/vite-env.d.ts +1 -0
  101. package/dist/force-app/main/default/webapplications/feature-react-global-search/vite.config.ts +82 -0
  102. package/dist/force-app/main/default/webapplications/feature-react-global-search/vitest-env.d.ts +2 -0
  103. package/dist/force-app/main/default/webapplications/feature-react-global-search/vitest.config.ts +11 -0
  104. package/dist/force-app/main/default/webapplications/feature-react-global-search/vitest.setup.ts +1 -0
  105. package/dist/force-app/main/default/webapplications/feature-react-global-search/webapplication.json +7 -0
  106. package/dist/jest.config.js +6 -0
  107. package/dist/package.json +37 -0
  108. package/dist/scripts/apex/hello.apex +10 -0
  109. package/dist/scripts/soql/account.soql +6 -0
  110. package/dist/sfdx-project.json +12 -0
  111. package/package.json +32 -0
@@ -0,0 +1,208 @@
1
+ import { useId } from "react";
2
+ import { createFormHookContexts, createFormHook } from "@tanstack/react-form";
3
+ import { Field, FieldDescription, FieldError, FieldLabel } from "../components/ui/field";
4
+ import { Input } from "../components/ui/input";
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from "../components/ui/select";
12
+ import { cn } from "../lib/utils";
13
+ import type { PicklistValue } from "../types/filters/picklist";
14
+ import { getUniqueErrors } from "../utils/formUtils";
15
+
16
+ export type { FormError } from "../utils/formUtils";
17
+ export { validateRangeValues } from "../utils/formUtils";
18
+
19
+ export const { fieldContext, formContext, useFieldContext, useFormContext } =
20
+ createFormHookContexts();
21
+
22
+ interface FilterTextFieldProps
23
+ extends Omit<
24
+ React.ComponentProps<typeof Input>,
25
+ "name" | "value" | "onBlur" | "onChange" | "aria-invalid"
26
+ > {
27
+ label: string;
28
+ description?: React.ReactNode;
29
+ placeholder?: string;
30
+ }
31
+
32
+ function FilterTextField({
33
+ label,
34
+ id: providedId,
35
+ description,
36
+ placeholder,
37
+ type = "text",
38
+ ...props
39
+ }: FilterTextFieldProps) {
40
+ const field = useFieldContext<string>();
41
+ const generatedId = useId();
42
+ const id = providedId ?? generatedId;
43
+ const descriptionId = `${id}-description`;
44
+ const errorId = `${id}-error`;
45
+ const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
46
+
47
+ const uniqueErrors = getUniqueErrors(field.state.meta.errors);
48
+
49
+ return (
50
+ <Field data-invalid={isInvalid}>
51
+ <FieldLabel htmlFor={id}>{label}</FieldLabel>
52
+ {description && <FieldDescription id={descriptionId}>{description}</FieldDescription>}
53
+ <Input
54
+ id={id}
55
+ name={field.name as string}
56
+ type={type}
57
+ value={field.state.value ?? ""}
58
+ onBlur={field.handleBlur}
59
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => field.handleChange(e.target.value)}
60
+ placeholder={placeholder}
61
+ aria-invalid={isInvalid}
62
+ aria-describedby={cn(description && descriptionId, isInvalid && errorId)}
63
+ {...props}
64
+ />
65
+ {isInvalid && uniqueErrors.length > 0 && <FieldError errors={uniqueErrors} />}
66
+ </Field>
67
+ );
68
+ }
69
+
70
+ interface FilterSelectFieldProps {
71
+ label: string;
72
+ id?: string;
73
+ description?: React.ReactNode;
74
+ placeholder?: string;
75
+ options: PicklistValue[];
76
+ }
77
+
78
+ function FilterSelectField({
79
+ label,
80
+ id: providedId,
81
+ description,
82
+ placeholder = "Select...",
83
+ options,
84
+ }: FilterSelectFieldProps) {
85
+ const field = useFieldContext<string>();
86
+ const generatedId = useId();
87
+ const id = providedId ?? generatedId;
88
+ const descriptionId = `${id}-description`;
89
+ const errorId = `${id}-error`;
90
+ const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
91
+
92
+ const uniqueErrors = getUniqueErrors(field.state.meta.errors);
93
+
94
+ return (
95
+ <Field data-invalid={isInvalid}>
96
+ <FieldLabel htmlFor={id}>{label}</FieldLabel>
97
+ {description && <FieldDescription id={descriptionId}>{description}</FieldDescription>}
98
+ <Select value={field.state.value ?? ""} onValueChange={(value) => field.handleChange(value)}>
99
+ <SelectTrigger
100
+ id={id}
101
+ aria-invalid={isInvalid}
102
+ aria-describedby={cn(description && descriptionId, isInvalid && errorId)}
103
+ >
104
+ <SelectValue placeholder={placeholder} />
105
+ </SelectTrigger>
106
+ <SelectContent>
107
+ {options.map((option) => {
108
+ if (!option || !option.value) return null;
109
+ return (
110
+ <SelectItem key={option.value} value={option.value}>
111
+ {option.label || option.value}
112
+ </SelectItem>
113
+ );
114
+ })}
115
+ </SelectContent>
116
+ </Select>
117
+ {isInvalid && uniqueErrors.length > 0 && <FieldError errors={uniqueErrors} />}
118
+ </Field>
119
+ );
120
+ }
121
+
122
+ interface FilterRangeFieldProps
123
+ extends Omit<
124
+ React.ComponentProps<typeof Input>,
125
+ "name" | "value" | "onBlur" | "onChange" | "aria-invalid"
126
+ > {
127
+ label?: string;
128
+ description?: React.ReactNode;
129
+ placeholder?: string;
130
+ }
131
+
132
+ function FilterRangeFieldBase({
133
+ label,
134
+ id: providedId,
135
+ description,
136
+ placeholder,
137
+ type = "text",
138
+ ...props
139
+ }: FilterRangeFieldProps) {
140
+ const field = useFieldContext<string>();
141
+ const generatedId = useId();
142
+ const id = providedId ?? generatedId;
143
+ const descriptionId = `${id}-description`;
144
+ const errorId = `${id}-error`;
145
+ const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
146
+
147
+ const uniqueErrors = getUniqueErrors(field.state.meta.errors);
148
+
149
+ return (
150
+ <div>
151
+ {label && <FieldLabel htmlFor={id}>{label}</FieldLabel>}
152
+ {description && <FieldDescription id={descriptionId}>{description}</FieldDescription>}
153
+ <Input
154
+ id={id}
155
+ name={field.name as string}
156
+ type={type}
157
+ value={field.state.value ?? ""}
158
+ onBlur={field.handleBlur}
159
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => field.handleChange(e.target.value)}
160
+ placeholder={placeholder}
161
+ aria-invalid={isInvalid}
162
+ aria-describedby={cn(description && descriptionId, isInvalid && errorId)}
163
+ {...props}
164
+ />
165
+ {isInvalid && uniqueErrors.length > 0 && <FieldError errors={uniqueErrors} />}
166
+ </div>
167
+ );
168
+ }
169
+
170
+ interface FilterRangeMinFieldProps
171
+ extends Omit<
172
+ React.ComponentProps<typeof Input>,
173
+ "name" | "value" | "onBlur" | "onChange" | "aria-invalid"
174
+ > {
175
+ label?: string;
176
+ description?: React.ReactNode;
177
+ placeholder?: string;
178
+ }
179
+
180
+ interface FilterRangeMaxFieldProps
181
+ extends Omit<
182
+ React.ComponentProps<typeof Input>,
183
+ "name" | "value" | "onBlur" | "onChange" | "aria-invalid"
184
+ > {
185
+ label?: string;
186
+ description?: React.ReactNode;
187
+ placeholder?: string;
188
+ }
189
+
190
+ function FilterRangeMinField({ placeholder = "Min", ...props }: FilterRangeMinFieldProps) {
191
+ return <FilterRangeFieldBase placeholder={placeholder} {...props} />;
192
+ }
193
+
194
+ function FilterRangeMaxField({ placeholder = "Max", ...props }: FilterRangeMaxFieldProps) {
195
+ return <FilterRangeFieldBase placeholder={placeholder} {...props} />;
196
+ }
197
+
198
+ export const { useAppForm } = createFormHook({
199
+ fieldContext,
200
+ formContext,
201
+ fieldComponents: {
202
+ FilterTextField,
203
+ FilterSelectField,
204
+ FilterRangeMinField,
205
+ FilterRangeMaxField,
206
+ },
207
+ formComponents: {},
208
+ });
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Object Search Data Hooks
3
+ *
4
+ * Custom hooks for managing object search data including columns, results, and filters.
5
+ * Each hook is responsible for a specific aspect of the search functionality.
6
+ *
7
+ * @remarks
8
+ * All hooks coordinate API calls separately from UI components.
9
+ * This ensures proper separation of concerns and allows for coordinated chained calls per page.
10
+ */
11
+
12
+ import { useState, useEffect, useRef, useMemo } from "react";
13
+ import { objectInfoService, type SearchParams } from "../api/objectInfoService";
14
+ import type {
15
+ Column,
16
+ SearchResultRecord,
17
+ SearchResultRecordData,
18
+ } from "../types/search/searchResults";
19
+ import type { Filter, FilterCriteria } from "../types/filters/filters";
20
+ import type { PicklistValue } from "../types/filters/picklist";
21
+ import { createFiltersKey } from "../utils/cacheUtils";
22
+
23
+ // --- Shared Types ---
24
+ export interface FiltersData {
25
+ filters: Filter[];
26
+ picklistValues: Record<string, PicklistValue[]>;
27
+ loading: boolean;
28
+ error: string | null;
29
+ }
30
+
31
+ /**
32
+ * Hook: useObjectColumns
33
+ *
34
+ * Fetches and caches column definitions for a specific object.
35
+ * Columns are metadata that rarely changes, so they are cached per object.
36
+ *
37
+ * @param objectApiName - The API name of the object to fetch columns for
38
+ * @returns Object containing columns array, loading state, and error state
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * const { columns, columnsLoading, columnsError } = useObjectColumns('Account');
43
+ * ```
44
+ */
45
+ export function useObjectColumns(objectApiName: string | null) {
46
+ const [columnsCache, setColumnsCache] = useState<Record<string, Column[]>>({});
47
+ const [loading, setLoading] = useState<Record<string, boolean>>({});
48
+ const [error, setError] = useState<Record<string, string | null>>({});
49
+
50
+ const columnsCacheRef = useRef(columnsCache);
51
+ useEffect(() => {
52
+ columnsCacheRef.current = columnsCache;
53
+ }, [columnsCache]);
54
+
55
+ useEffect(() => {
56
+ if (!objectApiName) return;
57
+
58
+ if (columnsCacheRef.current[objectApiName]) {
59
+ return;
60
+ }
61
+
62
+ let isCancelled = false;
63
+ const abortController = new AbortController();
64
+
65
+ const fetchColumns = async () => {
66
+ setLoading((prev) => ({ ...prev, [objectApiName]: true }));
67
+ setError((prev) => ({ ...prev, [objectApiName]: null }));
68
+
69
+ try {
70
+ const columns = await objectInfoService.getObjectListInfo(
71
+ objectApiName,
72
+ "__SearchResult",
73
+ abortController.signal,
74
+ );
75
+
76
+ if (isCancelled) return;
77
+
78
+ setColumnsCache((prev) => ({ ...prev, [objectApiName]: columns }));
79
+ } catch (err) {
80
+ if (isCancelled || (err instanceof Error && err.name === "AbortError")) {
81
+ return;
82
+ }
83
+ setError((prev) => ({ ...prev, [objectApiName]: "Failed to load columns" }));
84
+ } finally {
85
+ if (!isCancelled) {
86
+ setLoading((prev) => ({ ...prev, [objectApiName]: false }));
87
+ }
88
+ }
89
+ };
90
+
91
+ fetchColumns();
92
+
93
+ return () => {
94
+ isCancelled = true;
95
+ abortController.abort();
96
+ };
97
+ }, [objectApiName]);
98
+
99
+ return {
100
+ columns: objectApiName ? columnsCache[objectApiName] || [] : [],
101
+ columnsLoading: objectApiName ? loading[objectApiName] || false : false,
102
+ columnsError: objectApiName ? error[objectApiName] || null : null,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Hook: useObjectSearchResults
108
+ *
109
+ * Fetches search results for a specific object based on the provided query parameters.
110
+ * Maintains the *latest* result set for the object in state to prevent redundant
111
+ * network requests when the component re-renders with the same parameters.
112
+ * Includes debouncing for search queries (but not pagination).
113
+ *
114
+ * @param objectApiName - The API name of the object to search
115
+ * @param searchQuery - The search query string
116
+ * @param searchPageSize - Number of results per page (default: 50)
117
+ * @param searchPageToken - Pagination token (default: '0')
118
+ * @param filters - Array of filter criteria to apply (default: [])
119
+ * @param sortBy - Sort field and direction (default: 'relevance')
120
+ * @returns Object containing results array, pagination tokens, loading state, and error state
121
+ *
122
+ * @example
123
+ * ```tsx
124
+ * const { results, nextPageToken, previousPageToken, currentPageToken, resultsLoading, resultsError } = useObjectSearchResults(
125
+ * 'Account',
126
+ * 'test query',
127
+ * 25,
128
+ * '0',
129
+ * [{ objectApiName: 'Account', fieldPath: 'Name', operator: 'contains', values: ['test'] }]
130
+ * );
131
+ * ```
132
+ */
133
+ export function useObjectSearchResults(
134
+ objectApiName: string | null,
135
+ searchQuery: string,
136
+ searchPageSize: number = 50,
137
+ searchPageToken: string = "0",
138
+ filters: FilterCriteria[] = [],
139
+ sortBy: string = "relevance",
140
+ ) {
141
+ const [resultsCache, setResultsCache] = useState<
142
+ Record<
143
+ string,
144
+ {
145
+ results: SearchResultRecord[];
146
+ query: string;
147
+ pageToken: string;
148
+ pageSize: number;
149
+ filtersKey: string;
150
+ sortBy: string;
151
+ nextPageToken: string | null;
152
+ previousPageToken: string | null;
153
+ currentPageToken: string;
154
+ }
155
+ >
156
+ >({});
157
+ const [loading, setLoading] = useState<Record<string, boolean>>({});
158
+ const [error, setError] = useState<Record<string, string | null>>({});
159
+
160
+ const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
161
+ const abortControllerRef = useRef<AbortController | null>(null);
162
+ const resultsCacheRef = useRef(resultsCache);
163
+
164
+ const filtersKey = useMemo(() => {
165
+ const filtersArray = Array.isArray(filters) ? filters : [];
166
+ return createFiltersKey(filtersArray);
167
+ }, [filters]);
168
+
169
+ useEffect(() => {
170
+ resultsCacheRef.current = resultsCache;
171
+ }, [resultsCache]);
172
+
173
+ useEffect(() => {
174
+ if (!objectApiName || !searchQuery.trim()) {
175
+ return;
176
+ }
177
+
178
+ let isCancelled = false;
179
+ const abortController = new AbortController();
180
+
181
+ if (abortControllerRef.current) {
182
+ abortControllerRef.current.abort();
183
+ }
184
+ abortControllerRef.current = abortController;
185
+
186
+ if (debounceTimeout.current) {
187
+ clearTimeout(debounceTimeout.current);
188
+ debounceTimeout.current = null;
189
+ }
190
+
191
+ const cached = resultsCacheRef.current[objectApiName];
192
+ if (
193
+ !abortController.signal.aborted &&
194
+ cached &&
195
+ cached.query === searchQuery &&
196
+ cached.pageToken === searchPageToken &&
197
+ cached.pageSize === searchPageSize &&
198
+ cached.filtersKey === filtersKey &&
199
+ cached.sortBy === sortBy
200
+ ) {
201
+ return;
202
+ }
203
+
204
+ if (abortController.signal.aborted) {
205
+ return;
206
+ }
207
+
208
+ const fetchResults = async () => {
209
+ setLoading((prev) => ({ ...prev, [objectApiName]: true }));
210
+ setError((prev) => ({ ...prev, [objectApiName]: null }));
211
+
212
+ try {
213
+ const searchParams: SearchParams = {
214
+ sortBy: sortBy === "relevance" ? "" : sortBy,
215
+ filters: filters,
216
+ pageSize: searchPageSize,
217
+ pageToken: searchPageToken,
218
+ };
219
+
220
+ const keywordSearchResult = await objectInfoService.searchResults(
221
+ searchQuery,
222
+ objectApiName,
223
+ searchParams,
224
+ abortController.signal,
225
+ );
226
+
227
+ if (isCancelled || abortController.signal.aborted) return;
228
+
229
+ const normalizedRecords = keywordSearchResult.records.map((r) => ({
230
+ record: r.record as SearchResultRecordData,
231
+ highlightInfo: r.highlightInfo,
232
+ searchInfo: r.searchInfo,
233
+ }));
234
+
235
+ const nextPageToken: string | null = keywordSearchResult.nextPageToken ?? null;
236
+ const previousPageToken: string | null = keywordSearchResult.previousPageToken ?? null;
237
+
238
+ setResultsCache((prev): typeof prev => ({
239
+ ...prev,
240
+ [objectApiName]: {
241
+ results: normalizedRecords,
242
+ query: searchQuery,
243
+ pageToken: searchPageToken,
244
+ pageSize: searchPageSize,
245
+ filtersKey: filtersKey,
246
+ sortBy,
247
+ nextPageToken,
248
+ previousPageToken,
249
+ currentPageToken: keywordSearchResult.currentPageToken,
250
+ },
251
+ }));
252
+ } catch (err) {
253
+ if (isCancelled || (err instanceof Error && err.name === "AbortError")) {
254
+ return;
255
+ }
256
+ setError((prev) => ({ ...prev, [objectApiName]: "Unable to load search results" }));
257
+ } finally {
258
+ if (!isCancelled) {
259
+ setLoading((prev) => ({ ...prev, [objectApiName]: false }));
260
+ }
261
+ }
262
+ };
263
+
264
+ if (searchPageToken === "0") {
265
+ debounceTimeout.current = setTimeout(() => {
266
+ fetchResults();
267
+ }, 300);
268
+ } else {
269
+ fetchResults();
270
+ }
271
+
272
+ return () => {
273
+ isCancelled = true;
274
+ abortController.abort();
275
+ if (debounceTimeout.current) {
276
+ clearTimeout(debounceTimeout.current);
277
+ debounceTimeout.current = null;
278
+ }
279
+ if (abortControllerRef.current === abortController) {
280
+ abortControllerRef.current = null;
281
+ }
282
+ };
283
+ }, [objectApiName, searchQuery, searchPageSize, searchPageToken, filtersKey, sortBy]);
284
+
285
+ return {
286
+ results: objectApiName ? resultsCache[objectApiName]?.results || [] : [],
287
+ nextPageToken: objectApiName ? resultsCache[objectApiName]?.nextPageToken || null : null,
288
+ previousPageToken: objectApiName
289
+ ? resultsCache[objectApiName]?.previousPageToken || null
290
+ : null,
291
+ currentPageToken: objectApiName ? resultsCache[objectApiName]?.currentPageToken || "0" : "0",
292
+ resultsLoading: objectApiName ? loading[objectApiName] || false : false,
293
+ resultsError: objectApiName ? error[objectApiName] || null : null,
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Hook: useObjectFilters
299
+ *
300
+ * Fetches and caches filter definitions and picklist values for a specific object.
301
+ * Filters and picklists are fetched in parallel to avoid network waterfalls.
302
+ *
303
+ * @param objectApiName - The API name of the object to fetch filters for
304
+ * @returns Object containing filtersData record keyed by object API name
305
+ *
306
+ * @example
307
+ * ```tsx
308
+ * const { filtersData } = useObjectFilters('Account');
309
+ * const filtersInfo = filtersData['Account'];
310
+ * ```
311
+ */
312
+ export function useObjectFilters(objectApiName: string | null) {
313
+ const [filtersCache, setFiltersCache] = useState<Record<string, FiltersData>>({});
314
+
315
+ const filtersCacheRef = useRef(filtersCache);
316
+ const abortControllerRef = useRef<AbortController | null>(null);
317
+
318
+ useEffect(() => {
319
+ filtersCacheRef.current = filtersCache;
320
+ }, [filtersCache]);
321
+
322
+ useEffect(() => {
323
+ if (!objectApiName) return;
324
+
325
+ const cached = filtersCacheRef.current[objectApiName];
326
+ if (cached && (cached.filters?.length > 0 || cached.loading)) {
327
+ return;
328
+ }
329
+
330
+ if (abortControllerRef.current) {
331
+ abortControllerRef.current.abort();
332
+ }
333
+
334
+ let isCancelled = false;
335
+ const abortController = new AbortController();
336
+ abortControllerRef.current = abortController;
337
+
338
+ const fetchFilters = async () => {
339
+ setFiltersCache((prev) => ({
340
+ ...prev,
341
+ [objectApiName]: {
342
+ filters: prev[objectApiName]?.filters || [],
343
+ picklistValues: prev[objectApiName]?.picklistValues || {},
344
+ loading: true,
345
+ error: null,
346
+ },
347
+ }));
348
+
349
+ try {
350
+ const filters = await objectInfoService.getObjectListFilters(
351
+ objectApiName,
352
+ abortController.signal,
353
+ );
354
+
355
+ if (isCancelled) return;
356
+
357
+ const selectFilters = filters.filter((f) => f.affordance?.toLowerCase() === "select");
358
+
359
+ const picklistPromises = selectFilters.map((f) =>
360
+ objectInfoService
361
+ .getPicklistValues(objectApiName, f.targetFieldPath, undefined, abortController.signal)
362
+ .then((values) => ({ fieldPath: f.targetFieldPath, values }))
363
+ .catch((err) => {
364
+ if (err instanceof Error && err.name === "AbortError") {
365
+ throw err;
366
+ }
367
+ return { fieldPath: f.targetFieldPath, values: [] };
368
+ }),
369
+ );
370
+
371
+ const picklistResults = await Promise.all(picklistPromises);
372
+
373
+ if (isCancelled) return;
374
+
375
+ const picklistValuesRecord: Record<string, PicklistValue[]> = {};
376
+ picklistResults.forEach(({ fieldPath, values }) => {
377
+ picklistValuesRecord[fieldPath] = values;
378
+ });
379
+
380
+ setFiltersCache((prev) => ({
381
+ ...prev,
382
+ [objectApiName]: {
383
+ filters,
384
+ picklistValues: picklistValuesRecord,
385
+ loading: false,
386
+ error: null,
387
+ },
388
+ }));
389
+ } catch (err) {
390
+ if (isCancelled || (err instanceof Error && err.name === "AbortError")) {
391
+ return;
392
+ }
393
+ setFiltersCache((prev) => ({
394
+ ...prev,
395
+ [objectApiName]: {
396
+ filters: [],
397
+ picklistValues: {},
398
+ loading: false,
399
+ error: "Failed to load filters",
400
+ },
401
+ }));
402
+ }
403
+ };
404
+
405
+ fetchFilters();
406
+
407
+ return () => {
408
+ isCancelled = true;
409
+ abortController.abort();
410
+ if (abortControllerRef.current === abortController) {
411
+ abortControllerRef.current = null;
412
+ }
413
+ };
414
+ }, [objectApiName]);
415
+
416
+ return {
417
+ filtersData: filtersCache,
418
+ };
419
+ }