@salesforce/webapp-template-app-react-sample-b2e-experimental 1.73.0 → 1.74.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 (117) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/objects/Maintenance_Request__c/Maintenance_Request__c.object-meta.xml +11 -1
  3. package/dist/force-app/main/default/objects/Maintenance_Worker__c/Maintenance_Worker__c.object-meta.xml +6 -1
  4. package/dist/force-app/main/default/objects/Property__c/Property__c.object-meta.xml +6 -1
  5. package/dist/force-app/main/default/webapplications/appreactsampleb2e/package.json +7 -5
  6. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/api/maintenanceWorkers.ts +60 -0
  7. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/ApplicationsTable.tsx +59 -62
  8. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/FiltersFromApi.tsx +200 -0
  9. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/ListPageFilters.tsx +97 -0
  10. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/MaintenanceTable.tsx +2 -1
  11. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/ObjectSelect.tsx +39 -0
  12. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/VerticalNav.tsx +6 -4
  13. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/dashboard/GlobalSearchBar.tsx +125 -0
  14. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/feedback/FilterErrorAlert.tsx +15 -0
  15. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/feedback/PageErrorState.tsx +19 -0
  16. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/feedback/PageLoadingState.tsx +18 -0
  17. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/FilterFieldRange.tsx +40 -0
  18. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/FilterFieldSelect.tsx +190 -0
  19. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/FilterFieldText.tsx +32 -0
  20. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/ListPageFilterRow.tsx +100 -0
  21. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/layout/PageContainer.tsx +9 -0
  22. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/layout/PageHeader.tsx +21 -0
  23. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/list/ListPageWithFilters.tsx +70 -0
  24. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/constants.ts +39 -0
  25. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/index.ts +19 -0
  26. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/objectDetailService.ts +125 -0
  27. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/objectInfoGraphQLService.ts +194 -0
  28. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/objectInfoService.ts +199 -0
  29. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/recordListGraphQLService.ts +364 -0
  30. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailFields.tsx +55 -0
  31. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailForm.tsx +146 -0
  32. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailHeader.tsx +34 -0
  33. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailLayoutSections.tsx +80 -0
  34. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/Section.tsx +108 -0
  35. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/SectionRow.tsx +20 -0
  36. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/UiApiDetailForm.tsx +140 -0
  37. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FieldValueDisplay.tsx +73 -0
  38. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedAddress.tsx +29 -0
  39. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedEmail.tsx +17 -0
  40. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedPhone.tsx +24 -0
  41. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedText.tsx +11 -0
  42. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedUrl.tsx +29 -0
  43. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/index.ts +6 -0
  44. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FilterField.tsx +54 -0
  45. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FilterInput.tsx +55 -0
  46. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FilterSelect.tsx +72 -0
  47. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FiltersPanel.tsx +380 -0
  48. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/forms/filters-form.tsx +114 -0
  49. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/forms/submit-button.tsx +47 -0
  50. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/GlobalSearchInput.tsx +114 -0
  51. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/ResultCardFields.tsx +71 -0
  52. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchHeader.tsx +31 -0
  53. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchPagination.tsx +144 -0
  54. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchResultCard.tsx +136 -0
  55. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchResultsPanel.tsx +197 -0
  56. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/shared/LoadingFallback.tsx +61 -0
  57. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/filters/FilterInput.tsx +55 -0
  58. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/filters/FilterSelect.tsx +72 -0
  59. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/form.tsx +209 -0
  60. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/index.ts +22 -0
  61. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useObjectInfoBatch.ts +65 -0
  62. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useObjectSearchData.ts +395 -0
  63. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useRecordDetailLayout.ts +156 -0
  64. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useRecordListGraphQL.ts +135 -0
  65. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/pages/DetailPage.tsx +109 -0
  66. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/pages/GlobalSearch.tsx +229 -0
  67. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/filters/filters.ts +121 -0
  68. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/filters/picklist.ts +32 -0
  69. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/index.ts +5 -0
  70. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/objectInfo/objectInfo.ts +166 -0
  71. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/recordDetail/recordDetail.ts +61 -0
  72. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/search/searchResults.ts +229 -0
  73. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/apiUtils.ts +125 -0
  74. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/cacheUtils.ts +76 -0
  75. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/debounce.ts +89 -0
  76. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/fieldUtils.ts +354 -0
  77. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/fieldValueExtractor.ts +67 -0
  78. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/filterUtils.ts +32 -0
  79. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/formDataTransformUtils.ts +260 -0
  80. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/formUtils.ts +142 -0
  81. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/graphQLNodeFieldUtils.ts +186 -0
  82. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/graphQLObjectInfoAdapter.ts +319 -0
  83. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/graphQLRecordAdapter.ts +90 -0
  84. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/index.ts +59 -0
  85. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/layoutTransformUtils.ts +236 -0
  86. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/linkUtils.ts +14 -0
  87. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/paginationUtils.ts +49 -0
  88. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/recordUtils.ts +159 -0
  89. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/sanitizationUtils.ts +49 -0
  90. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/hooks/useAccumulatedListPages.ts +29 -0
  91. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/hooks/useListPage.ts +167 -0
  92. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/index.ts +8 -4
  93. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/applicationAdapter.ts +33 -0
  94. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/applicationColumns.ts +28 -0
  95. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/constants.ts +24 -0
  96. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/fieldMappers.ts +71 -0
  97. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/filterUtils.ts +165 -0
  98. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/globalSearchConstants.ts +40 -0
  99. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/listFilters.ts +152 -0
  100. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/listPageConfig.ts +65 -0
  101. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceAdapter.ts +110 -0
  102. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceColumns.ts +24 -0
  103. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceWorkerAdapter.ts +29 -0
  104. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceWorkerColumns.ts +25 -0
  105. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/objectApiNames.ts +13 -0
  106. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/propertyAdapter.ts +68 -0
  107. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/propertyColumns.ts +17 -0
  108. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/routeConfig.ts +35 -0
  109. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/types.ts +10 -0
  110. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Applications.tsx +47 -62
  111. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Home.tsx +130 -98
  112. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Maintenance.tsx +74 -91
  113. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/MaintenanceWorkers.tsx +138 -0
  114. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Properties.tsx +166 -85
  115. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/routes.tsx +41 -2
  116. package/dist/package.json +1 -1
  117. package/package.json +5 -1
@@ -0,0 +1,71 @@
1
+ /**
2
+ * ResultCardFields Component
3
+ *
4
+ * Displays secondary fields (up to 3) for a search result card.
5
+ * Excludes the primary field and handles nested field values.
6
+ *
7
+ * @param record - The search result record data
8
+ * @param columns - Array of column definitions
9
+ * @param excludeFieldApiName - Field API name to exclude (usually the primary field)
10
+ *
11
+ * @remarks
12
+ * - Displays up to 3 secondary fields
13
+ * - Handles nested field paths (e.g., "Owner.Alias")
14
+ * - Skips fields with null/undefined/empty values
15
+ * - Responsive layout (vertical on mobile, horizontal on desktop)
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <ResultCardFields
20
+ * record={searchResult}
21
+ * columns={columns}
22
+ * excludeFieldApiName="Name"
23
+ * />
24
+ * ```
25
+ */
26
+ import type { Column, SearchResultRecordData } from "../../types/search/searchResults";
27
+ import { getNestedFieldValue } from "../../utils/fieldUtils";
28
+
29
+ interface ResultCardFieldsProps {
30
+ record: SearchResultRecordData;
31
+ columns: Column[];
32
+ excludeFieldApiName?: string;
33
+ }
34
+
35
+ export default function ResultCardFields({
36
+ record,
37
+ columns,
38
+ excludeFieldApiName,
39
+ }: ResultCardFieldsProps) {
40
+ const secondaryFields = columns.filter(
41
+ (col) => col && col.fieldApiName && col.fieldApiName !== excludeFieldApiName,
42
+ );
43
+
44
+ return (
45
+ <dl className="space-y-2" aria-label="Additional record information">
46
+ {secondaryFields.map((column) => {
47
+ if (!column || !column.fieldApiName) {
48
+ return null;
49
+ }
50
+
51
+ const displayValue = getNestedFieldValue(record.fields, column.fieldApiName);
52
+
53
+ if (displayValue === null || displayValue === undefined || displayValue === "") {
54
+ return null;
55
+ }
56
+
57
+ return (
58
+ <div
59
+ key={column.fieldApiName}
60
+ className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2"
61
+ >
62
+ <dt className="text-sm font-medium text-muted-foreground min-w-[100px]">
63
+ {column.label || column.fieldApiName}:
64
+ </dt>
65
+ <dd className="text-sm text-foreground">{displayValue}</dd>
66
+ </div>
67
+ );
68
+ })}
69
+ </dl>
70
+ );
71
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * SearchHeader Component
3
+ *
4
+ * Displays the header for search results or browse-all (same UI).
5
+ * labelPlural comes from object metadata (e.g. useObjectInfoBatch) so it is not hard-coded.
6
+ */
7
+ interface SearchHeaderProps {
8
+ query?: string;
9
+ isBrowseAll?: boolean;
10
+ /** Plural label for the primary object (e.g. "Accounts"). From object metadata. */
11
+ labelPlural?: string;
12
+ }
13
+
14
+ export default function SearchHeader({
15
+ query,
16
+ isBrowseAll,
17
+ labelPlural = "records",
18
+ }: SearchHeaderProps) {
19
+ return (
20
+ <header className="mb-6" aria-label="Search results header">
21
+ <h1 className="text-3xl font-bold mb-2">
22
+ {isBrowseAll ? `Browse All ${labelPlural}` : "Search Results"}
23
+ </h1>
24
+ {!isBrowseAll && query && (
25
+ <p className="text-lg" aria-live="polite">
26
+ Results for: <span className="font-semibold">"{query}"</span>
27
+ </p>
28
+ )}
29
+ </header>
30
+ );
31
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * SearchPagination Component
3
+ *
4
+ * Displays pagination controls for search results.
5
+ * Previous/Next are disabled using hasPreviousPage/hasNextPage from the API so no request is made when there is no page.
6
+ *
7
+ * @remarks
8
+ * - Layout: page size selector on the left, prev/page/next controls on the right (corners).
9
+ * - Previous disabled when !hasPreviousPage (cursor stack enables prev when pageIndex > 0).
10
+ * - Next disabled when !hasNextPage or nextPageToken is null.
11
+ */
12
+ import { Button } from "../../../../components/ui/button";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "../../../../components/ui/select";
20
+ import { Label } from "../../../../components/ui/label";
21
+ import { PAGE_SIZE_OPTIONS, getValidPageSize, isValidPageSize } from "../../utils/paginationUtils";
22
+ import { ChevronLeft, ChevronRight } from "lucide-react";
23
+
24
+ interface SearchPaginationProps {
25
+ currentPageToken: string;
26
+ nextPageToken: string | null;
27
+ previousPageToken: string | null;
28
+ hasNextPage?: boolean;
29
+ hasPreviousPage?: boolean;
30
+ pageSize: number;
31
+ /** direction: 'prev' | 'next' | 'first'. When 'first' (e.g. page size change), parent typically resets pagination; token may be '' for prev when going to page 0. */
32
+ onPageChange: (newPageToken: string, direction?: "next" | "prev" | "first") => void;
33
+ onPageSizeChange: (newPageSize: number) => void;
34
+ }
35
+
36
+ export default function SearchPagination({
37
+ currentPageToken,
38
+ nextPageToken,
39
+ previousPageToken,
40
+ hasNextPage = false,
41
+ hasPreviousPage = false,
42
+ pageSize,
43
+ onPageChange,
44
+ onPageSizeChange,
45
+ }: SearchPaginationProps) {
46
+ const validPageSize = getValidPageSize(pageSize);
47
+
48
+ const currentPageTokenNum = parseInt(currentPageToken, 10) || 0;
49
+ const currentPage = currentPageTokenNum + 1;
50
+
51
+ const canGoPrevious = Boolean(hasPreviousPage);
52
+ const canGoNext = Boolean(hasNextPage && nextPageToken != null);
53
+
54
+ const handlePrevious = () => {
55
+ if (canGoPrevious) {
56
+ onPageChange(previousPageToken ?? "", "prev");
57
+ }
58
+ };
59
+
60
+ const handleNext = () => {
61
+ if (canGoNext && nextPageToken != null) {
62
+ onPageChange(nextPageToken, "next");
63
+ }
64
+ };
65
+
66
+ const handlePageSizeChange = (newPageSize: string) => {
67
+ const newSize = parseInt(newPageSize, 10);
68
+ if (!isNaN(newSize) && isValidPageSize(newSize) && newSize !== validPageSize) {
69
+ onPageSizeChange(newSize);
70
+ onPageChange("0", "first");
71
+ }
72
+ };
73
+
74
+ return (
75
+ <nav
76
+ className="w-full flex flex-row flex-wrap items-center justify-between gap-4 py-2"
77
+ aria-label="Search results pagination"
78
+ >
79
+ <div
80
+ className="flex items-center gap-2 shrink-0"
81
+ role="group"
82
+ aria-label="Page size selector"
83
+ >
84
+ <Label htmlFor="page-size-select" className="text-sm font-normal whitespace-nowrap">
85
+ Results per page:
86
+ </Label>
87
+ <Select value={validPageSize.toString()} onValueChange={handlePageSizeChange}>
88
+ <SelectTrigger
89
+ id="page-size-select"
90
+ className="w-[70px]"
91
+ aria-label="Select number of results per page"
92
+ >
93
+ <SelectValue />
94
+ </SelectTrigger>
95
+ <SelectContent>
96
+ {PAGE_SIZE_OPTIONS.map((option) => (
97
+ <SelectItem key={option.value} value={option.value}>
98
+ {option.label}
99
+ </SelectItem>
100
+ ))}
101
+ </SelectContent>
102
+ </Select>
103
+ </div>
104
+
105
+ <div className="flex items-center gap-1 shrink-0" role="group" aria-label="Page navigation">
106
+ <Button
107
+ type="button"
108
+ variant="outline"
109
+ size="sm"
110
+ disabled={!canGoPrevious}
111
+ onClick={handlePrevious}
112
+ aria-label={
113
+ canGoPrevious
114
+ ? `Go to previous page (Page ${currentPage - 1})`
115
+ : "Previous page (disabled)"
116
+ }
117
+ >
118
+ <ChevronLeft className="size-4" aria-hidden />
119
+ Previous
120
+ </Button>
121
+ <span
122
+ className="min-w-[4rem] text-center text-sm text-muted-foreground px-2"
123
+ aria-label={`Page ${currentPage}, current page`}
124
+ aria-current="page"
125
+ >
126
+ Page {currentPage}
127
+ </span>
128
+ <Button
129
+ type="button"
130
+ variant="outline"
131
+ size="sm"
132
+ disabled={!canGoNext}
133
+ onClick={handleNext}
134
+ aria-label={
135
+ canGoNext ? `Go to next page (Page ${currentPage + 1})` : "Next page (disabled)"
136
+ }
137
+ >
138
+ Next
139
+ <ChevronRight className="size-4" aria-hidden />
140
+ </Button>
141
+ </div>
142
+ </nav>
143
+ );
144
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * SearchResultCard Component
3
+ *
4
+ * Displays a single search result as a card with primary and secondary fields.
5
+ * Clicking the card navigates to the detail page for that record.
6
+ *
7
+ * @param record - The search result record data to display
8
+ * @param columns - Array of column definitions for field display
9
+ * @param objectApiName - API name of the object (path param in detail URL: /object/:objectApiName/:recordId)
10
+ *
11
+ * @remarks
12
+ * - Automatically identifies the primary field (usually "Name")
13
+ * - Displays up to 3 secondary fields
14
+ * - Supports keyboard navigation (Enter/Space to navigate)
15
+ * - Handles nested field values (e.g., "Owner.Alias")
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <SearchResultCard
20
+ * record={searchResult}
21
+ * columns={columns}
22
+ * objectApiName="Account"
23
+ * />
24
+ * ```
25
+ */
26
+ import { useNavigate } from "react-router";
27
+ import { useMemo, useCallback } from "react";
28
+ import {
29
+ Card,
30
+ CardContent,
31
+ CardHeader,
32
+ CardTitle,
33
+ } from "../../../../components/ui/card";
34
+ import type { Column, SearchResultRecordData } from "../../types/search/searchResults";
35
+ import { getNestedFieldValue } from "../../utils/fieldUtils";
36
+ import ResultCardFields from "./ResultCardFields";
37
+ import { OBJECT_API_NAMES } from "../../../../constants";
38
+
39
+ interface SearchResultCardProps {
40
+ record: SearchResultRecordData;
41
+ columns: Column[];
42
+ objectApiName?: string;
43
+ }
44
+
45
+ export default function SearchResultCard({
46
+ record,
47
+ columns,
48
+ objectApiName,
49
+ }: SearchResultCardProps) {
50
+ const navigate = useNavigate();
51
+
52
+ if (!record || !record.id) {
53
+ return null;
54
+ }
55
+
56
+ if (!columns || !Array.isArray(columns) || columns.length === 0) {
57
+ return null;
58
+ }
59
+
60
+ if (!record.fields || typeof record.fields !== "object") {
61
+ return null;
62
+ }
63
+
64
+ const detailPath = useMemo(
65
+ () => `/object/${objectApiName?.trim() || OBJECT_API_NAMES[0]}/${record.id}`,
66
+ [record.id, objectApiName],
67
+ );
68
+
69
+ const handleClick = useCallback(() => {
70
+ if (record.id) navigate(detailPath);
71
+ }, [record.id, detailPath, navigate]);
72
+
73
+ const handleKeyDown = useCallback(
74
+ (e: React.KeyboardEvent) => {
75
+ if (e.key === "Enter" || e.key === " ") {
76
+ e.preventDefault();
77
+ handleClick();
78
+ }
79
+ },
80
+ [handleClick],
81
+ );
82
+
83
+ const primaryField = useMemo(() => {
84
+ return (
85
+ columns.find(
86
+ (col) =>
87
+ col &&
88
+ col.fieldApiName &&
89
+ (col.fieldApiName.toLowerCase() === "name" ||
90
+ col.fieldApiName.toLowerCase().includes("name")),
91
+ ) ||
92
+ columns[0] ||
93
+ null
94
+ );
95
+ }, [columns]);
96
+
97
+ const primaryValue = useMemo(() => {
98
+ return primaryField && primaryField.fieldApiName
99
+ ? getNestedFieldValue(record.fields, primaryField.fieldApiName) || "Untitled"
100
+ : "Untitled";
101
+ }, [primaryField, record.fields]);
102
+
103
+ const secondaryColumns = useMemo(() => {
104
+ return columns.filter(
105
+ (col) => col && col.fieldApiName && col.fieldApiName !== primaryField?.fieldApiName,
106
+ );
107
+ }, [columns, primaryField]);
108
+
109
+ return (
110
+ <Card
111
+ className="cursor-pointer hover:shadow-md transition-shadow focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2"
112
+ onClick={handleClick}
113
+ onKeyDown={handleKeyDown}
114
+ role="button"
115
+ tabIndex={0}
116
+ aria-label={`View details for ${primaryValue}`}
117
+ aria-describedby={`result-${record.id}-description`}
118
+ >
119
+ <CardHeader>
120
+ <CardTitle className="text-lg" id={`result-${record.id}-title`}>
121
+ {primaryValue}
122
+ </CardTitle>
123
+ </CardHeader>
124
+ <CardContent>
125
+ <div id={`result-${record.id}-description`} className="sr-only">
126
+ Search result: {primaryValue}
127
+ </div>
128
+ <ResultCardFields
129
+ record={record}
130
+ columns={secondaryColumns}
131
+ excludeFieldApiName={primaryField?.fieldApiName}
132
+ />
133
+ </CardContent>
134
+ </Card>
135
+ );
136
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * SearchResultsPanel Component
3
+ *
4
+ * Displays the search results panel with loading, error, and empty states.
5
+ * Renders a list of SearchResultCard components and pagination controls.
6
+ *
7
+ * @param columns - Array of column definitions for displaying result data
8
+ * @param results - Array of search result records to display
9
+ * @param columnsLoading - Whether column metadata is currently loading
10
+ * @param resultsLoading - Whether search results are currently loading
11
+ * @param columnsError - Error message if column fetch failed
12
+ * @param resultsError - Error message if results fetch failed
13
+ * @param currentPageToken - Current pagination token
14
+ * @param pageSize - Number of results per page
15
+ * @param onPageChange - Callback when pagination changes; second arg optional: "next" | "prev" | "first" (cursor-stack pagination)
16
+ * @param onPageSizeChange - Callback when page size changes
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * <SearchResultsPanel
21
+ * columns={columns}
22
+ * results={results}
23
+ * columnsLoading={false}
24
+ * resultsLoading={false}
25
+ * columnsError={null}
26
+ * resultsError={null}
27
+ * currentPageToken="0"
28
+ * pageSize={25}
29
+ * onPageChange={(token, direction) => handlePageChange(token, direction)}
30
+ * onPageSizeChange={(size) => setPageSize(size)}
31
+ * />
32
+ * ```
33
+ */
34
+ import { useMemo } from "react";
35
+ import { Alert, AlertDescription, AlertTitle } from "../../../../components/ui/alert";
36
+ import { Skeleton } from "../../../../components/ui/skeleton";
37
+ import { AlertCircle } from "lucide-react";
38
+ import {
39
+ Select,
40
+ SelectContent,
41
+ SelectItem,
42
+ SelectTrigger,
43
+ SelectValue,
44
+ } from "../../../../components/ui/select";
45
+ import { Label } from "../../../../components/ui/label";
46
+ import SearchResultCard from "./SearchResultCard";
47
+ import SearchPagination from "./SearchPagination";
48
+ import type { Column, SearchResultRecord } from "../../types/search/searchResults";
49
+ import { getSafeKey } from "../../utils/recordUtils";
50
+
51
+ interface SearchResultsPanelProps {
52
+ /** API name of the object being searched (e.g. for detail page navigation). */
53
+ objectApiName?: string;
54
+ columns: Column[];
55
+ results: SearchResultRecord[];
56
+ columnsLoading: boolean;
57
+ resultsLoading: boolean;
58
+ columnsError: string | null;
59
+ resultsError: string | null;
60
+ currentPageToken: string;
61
+ nextPageToken: string | null;
62
+ previousPageToken: string | null;
63
+ hasNextPage?: boolean;
64
+ hasPreviousPage?: boolean;
65
+ pageSize: number;
66
+ sortBy: string;
67
+ onPageChange: (newPageToken: string, direction?: "next" | "prev" | "first") => void;
68
+ onPageSizeChange: (newPageSize: number) => void;
69
+ onSortByChange: (newSortBy: string) => void;
70
+ }
71
+
72
+ export default function SearchResultsPanel({
73
+ objectApiName,
74
+ columns,
75
+ results,
76
+ columnsLoading,
77
+ resultsLoading,
78
+ columnsError,
79
+ resultsError,
80
+ currentPageToken,
81
+ nextPageToken,
82
+ previousPageToken,
83
+ hasNextPage = false,
84
+ hasPreviousPage = false,
85
+ pageSize,
86
+ sortBy,
87
+ onPageChange,
88
+ onPageSizeChange,
89
+ onSortByChange,
90
+ }: SearchResultsPanelProps) {
91
+ const sortableColumns = useMemo(() => columns.filter(({ sortable }) => sortable), [columns]);
92
+
93
+ const validResults = useMemo(
94
+ () => results.filter((record) => record && record.record && record.record.id),
95
+ [results],
96
+ );
97
+ if (columnsError || resultsError) {
98
+ return (
99
+ <Alert variant="destructive" role="alert">
100
+ <AlertCircle className="h-4 w-4" aria-hidden="true" />
101
+ <AlertTitle>Error</AlertTitle>
102
+ <AlertDescription>
103
+ {columnsError || resultsError || "Failed to load search results"}
104
+ </AlertDescription>
105
+ </Alert>
106
+ );
107
+ }
108
+
109
+ if (resultsLoading || columnsLoading) {
110
+ return (
111
+ <div
112
+ className="space-y-4"
113
+ role="status"
114
+ aria-live="polite"
115
+ aria-label="Loading search results"
116
+ >
117
+ <span className="sr-only">Loading search results</span>
118
+ {[1, 2, 3].map((i) => (
119
+ <div key={i} className="border rounded-lg p-6" aria-hidden="true">
120
+ <Skeleton className="h-6 w-3/4 mb-4" />
121
+ <Skeleton className="h-4 w-full mb-2" />
122
+ <Skeleton className="h-4 w-2/3" />
123
+ </div>
124
+ ))}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ if (results.length === 0) {
130
+ return (
131
+ <div className="text-center py-12" role="status" aria-live="polite">
132
+ <p className="text-lg mb-2">No results found</p>
133
+ <p className="text-sm">Try adjusting your search query or filters</p>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ return (
139
+ <>
140
+ {sortableColumns.length > 0 && (
141
+ <div
142
+ className="mb-6 flex items-center gap-2 justify-end"
143
+ role="group"
144
+ aria-label="Sort options"
145
+ >
146
+ <Label htmlFor="sort-by-select" className="text-sm font-normal whitespace-nowrap">
147
+ Sort by:
148
+ </Label>
149
+ <Select value={sortBy || ""} onValueChange={onSortByChange}>
150
+ <SelectTrigger
151
+ id="sort-by-select"
152
+ className="w-[200px]"
153
+ aria-label="Sort search results by field"
154
+ >
155
+ <SelectValue placeholder="Select field..." />
156
+ </SelectTrigger>
157
+ <SelectContent>
158
+ <SelectItem value="relevance">Relevance</SelectItem>
159
+ {sortableColumns.map((column) => (
160
+ <SelectItem key={column.fieldApiName} value={column.fieldApiName}>
161
+ {column.label}
162
+ </SelectItem>
163
+ ))}
164
+ </SelectContent>
165
+ </Select>
166
+ </div>
167
+ )}
168
+
169
+ <div className="space-y-4 mb-6" role="list" aria-label="Search results list">
170
+ {validResults.map((record, index) => {
171
+ const recordId = record.record.id;
172
+ const safeKey = getSafeKey(recordId, index);
173
+ return (
174
+ <div key={safeKey} role="listitem">
175
+ <SearchResultCard
176
+ record={record.record}
177
+ columns={columns}
178
+ objectApiName={objectApiName}
179
+ />
180
+ </div>
181
+ );
182
+ })}
183
+ </div>
184
+
185
+ <SearchPagination
186
+ currentPageToken={currentPageToken}
187
+ nextPageToken={nextPageToken}
188
+ previousPageToken={previousPageToken}
189
+ hasNextPage={hasNextPage}
190
+ hasPreviousPage={hasPreviousPage}
191
+ pageSize={pageSize}
192
+ onPageChange={onPageChange}
193
+ onPageSizeChange={onPageSizeChange}
194
+ />
195
+ </>
196
+ );
197
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * LoadingFallback Component
3
+ *
4
+ * Loading fallback component for Suspense boundaries.
5
+ * Displays a centered spinner while lazy-loaded components are being fetched.
6
+ *
7
+ * @remarks
8
+ * - Used with React Suspense for code splitting
9
+ * - Simple centered spinner design
10
+ * - Responsive and accessible
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * <Suspense fallback={<LoadingFallback />}>
15
+ * <LazyComponent />
16
+ * </Suspense>
17
+ * ```
18
+ */
19
+ import { cva, type VariantProps } from "class-variance-authority";
20
+ import { Spinner } from "../../../../components/ui/spinner";
21
+
22
+ /**
23
+ * Spinner size variants based on content width.
24
+ */
25
+ const spinnerVariants = cva("", {
26
+ variants: {
27
+ contentMaxWidth: {
28
+ sm: "size-6",
29
+ md: "size-8",
30
+ lg: "size-10",
31
+ },
32
+ },
33
+ defaultVariants: {
34
+ contentMaxWidth: "sm",
35
+ },
36
+ });
37
+
38
+ interface LoadingFallbackProps extends VariantProps<typeof spinnerVariants> {
39
+ /**
40
+ * Maximum width of the content container. Also scales the spinner size.
41
+ * @default "sm"
42
+ */
43
+ contentMaxWidth?: "sm" | "md" | "lg";
44
+ /**
45
+ * Accessible label for screen readers.
46
+ * @default "Loading…"
47
+ */
48
+ loadingText?: string;
49
+ }
50
+
51
+ export default function LoadingFallback({
52
+ contentMaxWidth = "sm",
53
+ loadingText = "Loading…",
54
+ }: LoadingFallbackProps) {
55
+ return (
56
+ <div className="flex justify-center" role="status" aria-live="polite">
57
+ <Spinner className={spinnerVariants({ contentMaxWidth })} aria-hidden="true" />
58
+ <span className="sr-only">{loadingText}</span>
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * FilterInput Component
3
+ *
4
+ * Renders a text input field for filter values.
5
+ * Used for filters that don't have a picklist (affordance !== 'select').
6
+ *
7
+ * @param filter - Filter definition containing field path, label, and attributes
8
+ * @param value - Current filter input value
9
+ * @param onChange - Callback when input value changes
10
+ *
11
+ * @remarks
12
+ * - Displays filter label or field path as the label
13
+ * - Shows placeholder text from filter attributes or generates default
14
+ * - Displays help message if available
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * <FilterInput
19
+ * filter={textFilter}
20
+ * value={filterValue}
21
+ * onChange={(value) => setFilterValue(value)}
22
+ * />
23
+ * ```
24
+ */
25
+ import { Input } from "../../../components/ui/input";
26
+ import { Field, FieldLabel, FieldDescription } from "../../../components/ui/field";
27
+ import type { Filter } from "../types/filters/filters";
28
+
29
+ interface FilterInputProps {
30
+ filter: Filter;
31
+ value: string;
32
+ onChange: (value: string) => void;
33
+ }
34
+
35
+ export default function FilterInput({ filter, value, onChange }: FilterInputProps) {
36
+ return (
37
+ <Field>
38
+ <FieldLabel htmlFor={filter.targetFieldPath}>
39
+ {filter.label || filter.targetFieldPath}
40
+ </FieldLabel>
41
+ <Input
42
+ id={filter.targetFieldPath}
43
+ type="text"
44
+ value={value}
45
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
46
+ placeholder={
47
+ filter.attributes?.placeholder ||
48
+ `Enter ${(filter.label || filter.targetFieldPath).toLowerCase()}`
49
+ }
50
+ aria-label={filter.label || filter.targetFieldPath}
51
+ />
52
+ {filter.helpMessage && <FieldDescription>{filter.helpMessage}</FieldDescription>}
53
+ </Field>
54
+ );
55
+ }