@salesforce/webapp-template-app-react-sample-b2e-experimental 1.72.0 → 1.73.1

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
@@ -4,6 +4,7 @@ import dashboardIcon from "../assets/icons/dashboard.svg";
4
4
  import filesIcon from "../assets/icons/files.svg";
5
5
  import propertiesIcon from "../assets/icons/properties.svg";
6
6
  import maintenanceIcon from "../assets/icons/maintenance.svg";
7
+ import { PATHS } from "../lib/routeConfig.js";
7
8
 
8
9
  interface NavItem {
9
10
  path: string;
@@ -12,10 +13,11 @@ interface NavItem {
12
13
  }
13
14
 
14
15
  const navItems: NavItem[] = [
15
- { path: "/", icon: dashboardIcon, label: "Dashboard" },
16
- { path: "/applications", icon: filesIcon, label: "Applications" },
17
- { path: "/properties", icon: propertiesIcon, label: "Properties" },
18
- { path: "/maintenance", icon: maintenanceIcon, label: "Maintenance" },
16
+ { path: PATHS.HOME, icon: dashboardIcon, label: "Dashboard" },
17
+ { path: PATHS.APPLICATIONS, icon: filesIcon, label: "Applications" },
18
+ { path: PATHS.PROPERTIES, icon: propertiesIcon, label: "Properties" },
19
+ { path: PATHS.MAINTENANCE_REQUESTS, icon: maintenanceIcon, label: "Maintenance Requests" },
20
+ { path: PATHS.MAINTENANCE_WORKERS, icon: maintenanceIcon, label: "Maintenance Workers" },
19
21
  ];
20
22
 
21
23
  export const VerticalNav: React.FC = () => {
@@ -0,0 +1,125 @@
1
+ import type { ObjectInfoResult } from "@salesforce/webapp-template-feature-react-global-search-experimental";
2
+ import type { SearchableObjectConfig } from "../../lib/globalSearchConstants.js";
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from "@/components/ui/select";
10
+
11
+ type SearchableObjectApiName = SearchableObjectConfig["objectApiName"];
12
+
13
+ interface GlobalSearchBarProps {
14
+ objectApiNames: SearchableObjectApiName[];
15
+ objectInfos: (ObjectInfoResult | null)[];
16
+ searchableObjects: readonly SearchableObjectConfig[];
17
+ selectedObjectApiName: SearchableObjectApiName;
18
+ onSelectedObjectChange: (objectApiName: SearchableObjectApiName) => void;
19
+ searchQuery: string;
20
+ onSearchQueryChange: (value: string) => void;
21
+ onSearchSubmit: () => void;
22
+ onBrowseAll: () => void;
23
+ labelPlural: string;
24
+ }
25
+
26
+ /**
27
+ * Home page search: object dropdown + search input in a single combined control.
28
+ * Uses shadcn Select and object metadata for labels when available.
29
+ */
30
+ export function GlobalSearchBar({
31
+ searchableObjects,
32
+ objectApiNames,
33
+ objectInfos,
34
+ selectedObjectApiName,
35
+ onSelectedObjectChange,
36
+ searchQuery,
37
+ onSearchQueryChange,
38
+ onSearchSubmit,
39
+ onBrowseAll,
40
+ labelPlural,
41
+ }: GlobalSearchBarProps) {
42
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
43
+ if (e.key === "Enter") {
44
+ e.preventDefault();
45
+ onSearchSubmit();
46
+ }
47
+ };
48
+
49
+ return (
50
+ <div className="flex flex-wrap justify-end gap-2 items-center mb-4">
51
+ <div
52
+ className="w-full max-w-xl bg-white rounded-full px-4 py-3 shadow-sm border border-gray-200 flex items-center gap-2"
53
+ role="search"
54
+ aria-label={`Search ${labelPlural}`}
55
+ >
56
+ <Select
57
+ value={selectedObjectApiName}
58
+ onValueChange={(v) => onSelectedObjectChange(v as SearchableObjectApiName)}
59
+ >
60
+ <SelectTrigger
61
+ className="border-0 bg-transparent shadow-none focus:ring-0 focus:ring-offset-0 min-w-[120px] py-0 h-auto font-medium text-gray-700"
62
+ aria-label="Search in"
63
+ >
64
+ <SelectValue />
65
+ </SelectTrigger>
66
+ <SelectContent>
67
+ {searchableObjects.map((obj) => {
68
+ const idx = objectApiNames.indexOf(obj.objectApiName);
69
+ const info = idx >= 0 ? objectInfos[idx] : null;
70
+ const label =
71
+ (info?.labelPlural as string | undefined) ?? obj.fallbackLabelPlural ?? "Records";
72
+ return (
73
+ <SelectItem key={obj.objectApiName} value={obj.objectApiName}>
74
+ {label}
75
+ </SelectItem>
76
+ );
77
+ })}
78
+ </SelectContent>
79
+ </Select>
80
+ <span className="text-gray-300 shrink-0" aria-hidden="true">
81
+ |
82
+ </span>
83
+ <input
84
+ type="search"
85
+ placeholder={`Search ${labelPlural}`}
86
+ value={searchQuery}
87
+ onChange={(e) => onSearchQueryChange(e.target.value)}
88
+ onKeyDown={handleKeyDown}
89
+ className="flex-1 outline-none text-gray-600 bg-transparent min-w-0"
90
+ aria-label={`Search ${labelPlural}`}
91
+ />
92
+ <button
93
+ type="button"
94
+ onClick={onSearchSubmit}
95
+ disabled={!searchQuery.trim()}
96
+ className="p-1 rounded-full hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none shrink-0"
97
+ aria-label="Submit search"
98
+ >
99
+ <svg
100
+ className="w-5 h-5 text-gray-500"
101
+ fill="none"
102
+ stroke="currentColor"
103
+ viewBox="0 0 24 24"
104
+ aria-hidden="true"
105
+ >
106
+ <path
107
+ strokeLinecap="round"
108
+ strokeLinejoin="round"
109
+ strokeWidth={2}
110
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
111
+ />
112
+ </svg>
113
+ </button>
114
+ </div>
115
+ <button
116
+ type="button"
117
+ onClick={onBrowseAll}
118
+ className="px-4 py-3 text-sm font-medium text-[#372949] bg-white border border-gray-200 rounded-full shadow-sm hover:bg-gray-50 whitespace-nowrap"
119
+ aria-label={`Browse all ${labelPlural}`}
120
+ >
121
+ Browse all {labelPlural}
122
+ </button>
123
+ </div>
124
+ );
125
+ }
@@ -0,0 +1,15 @@
1
+ interface FilterErrorAlertProps {
2
+ message: string;
3
+ }
4
+
5
+ /** Inline alert for filter validation errors (e.g. range min > max). Announced to screen readers. */
6
+ export function FilterErrorAlert({ message }: FilterErrorAlertProps) {
7
+ return (
8
+ <div
9
+ role="alert"
10
+ className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-4 py-2"
11
+ >
12
+ {message}
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,19 @@
1
+ interface PageErrorStateProps {
2
+ message: string;
3
+ }
4
+
5
+ /**
6
+ * Full-page error state when list or metadata fails to load.
7
+ * Uses role="alert" so screen readers announce the error.
8
+ */
9
+ export function PageErrorState({ message }: PageErrorStateProps) {
10
+ return (
11
+ <div
12
+ className="flex justify-center items-center min-h-[200px] bg-gray-50"
13
+ role="alert"
14
+ aria-live="assertive"
15
+ >
16
+ <p className="text-red-600">{message}</p>
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,18 @@
1
+ interface PageLoadingStateProps {
2
+ message?: string;
3
+ }
4
+
5
+ /**
6
+ * Full-page loading state. Uses aria-live="polite" so screen readers can announce updates.
7
+ */
8
+ export function PageLoadingState({ message = "Loading..." }: PageLoadingStateProps) {
9
+ return (
10
+ <div
11
+ className="flex justify-center items-center min-h-[200px] bg-gray-50"
12
+ aria-live="polite"
13
+ aria-busy="true"
14
+ >
15
+ <p className="text-gray-600">{message}</p>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,40 @@
1
+ import type { Filter } from "@salesforce/webapp-template-feature-react-global-search-experimental";
2
+ import { getRangeMinKey, getRangeMaxKey } from "../../lib/filterUtils.js";
3
+
4
+ const inputClass =
5
+ "h-9 px-3 rounded-md border border-gray-300 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent w-20";
6
+
7
+ interface FilterFieldRangeProps {
8
+ filter: Filter;
9
+ formValues: Record<string, string>;
10
+ onChange: (key: string, value: string) => void;
11
+ }
12
+
13
+ export function FilterFieldRange({ filter, formValues, onChange }: FilterFieldRangeProps) {
14
+ const label = filter.label || filter.targetFieldPath;
15
+ const minKey = getRangeMinKey(filter.targetFieldPath);
16
+ const maxKey = getRangeMaxKey(filter.targetFieldPath);
17
+ return (
18
+ <div className="flex flex-col gap-1.5 min-w-[140px]" role="group" aria-label={label + " range"}>
19
+ <label className="text-sm font-medium text-gray-700 whitespace-nowrap">{label}</label>
20
+ <div className="flex gap-2">
21
+ <input
22
+ type="text"
23
+ value={formValues[minKey] ?? ""}
24
+ onChange={(e) => onChange(minKey, e.target.value)}
25
+ placeholder="Min"
26
+ className={inputClass}
27
+ aria-label={label + " minimum"}
28
+ />
29
+ <input
30
+ type="text"
31
+ value={formValues[maxKey] ?? ""}
32
+ onChange={(e) => onChange(maxKey, e.target.value)}
33
+ placeholder="Max"
34
+ className={inputClass}
35
+ aria-label={label + " maximum"}
36
+ />
37
+ </div>
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,190 @@
1
+ import * as React from "react";
2
+ import type { Filter } from "@salesforce/webapp-template-feature-react-global-search-experimental";
3
+ import { ALL_PLACEHOLDER_VALUE, MULTI_VALUE_SEP } from "../../lib/filterUtils.js";
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from "@/components/ui/select";
11
+
12
+ const TRIGGER_CLASS =
13
+ "h-9 rounded-md border border-gray-300 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent min-w-[140px] data-[size=default]:h-9";
14
+
15
+ interface FilterFieldSelectProps {
16
+ filter: Filter;
17
+ options: { label?: string; value: string }[];
18
+ value: string;
19
+ onChange: (value: string) => void;
20
+ multiSelect?: boolean;
21
+ }
22
+
23
+ function FilterFieldSelectSingle({
24
+ filter,
25
+ options,
26
+ value,
27
+ onChange,
28
+ }: Omit<FilterFieldSelectProps, "multiSelect">) {
29
+ const label = filter.label || filter.targetFieldPath;
30
+ const placeholder = filter.attributes?.placeholder || "All";
31
+ const id = `filter-${filter.targetFieldPath}`;
32
+ return (
33
+ <div className="flex flex-col gap-1.5 min-w-[160px]" role="group" aria-label={label}>
34
+ <label htmlFor={id} className="text-sm font-medium text-gray-700 whitespace-nowrap">
35
+ {label}
36
+ </label>
37
+ <Select
38
+ value={value || ALL_PLACEHOLDER_VALUE}
39
+ onValueChange={(v) => onChange(v === ALL_PLACEHOLDER_VALUE ? "" : (v ?? ""))}
40
+ >
41
+ <SelectTrigger id={id} className={TRIGGER_CLASS} size="default" aria-label={label}>
42
+ <SelectValue placeholder={placeholder} />
43
+ </SelectTrigger>
44
+ <SelectContent>
45
+ <SelectItem value={ALL_PLACEHOLDER_VALUE}>{placeholder}</SelectItem>
46
+ {options.map((opt) => (
47
+ <SelectItem key={opt.value} value={opt.value}>
48
+ {opt.label || opt.value}
49
+ </SelectItem>
50
+ ))}
51
+ </SelectContent>
52
+ </Select>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ function FilterFieldMultiSelect({
58
+ filter,
59
+ options,
60
+ value,
61
+ onChange,
62
+ }: Omit<FilterFieldSelectProps, "multiSelect">) {
63
+ const label = filter.label || filter.targetFieldPath;
64
+ const selected = value ? value.split(MULTI_VALUE_SEP).filter(Boolean) : [];
65
+ const [open, setOpen] = React.useState(false);
66
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
67
+ const panelRef = React.useRef<HTMLDivElement>(null);
68
+
69
+ React.useEffect(() => {
70
+ if (!open) return;
71
+ const close = (e: MouseEvent) => {
72
+ if (
73
+ triggerRef.current?.contains(e.target as Node) ||
74
+ panelRef.current?.contains(e.target as Node)
75
+ )
76
+ return;
77
+ setOpen(false);
78
+ };
79
+ const handleKeyDown = (e: KeyboardEvent) => {
80
+ if (e.key === "Escape") setOpen(false);
81
+ };
82
+ document.addEventListener("mousedown", close);
83
+ document.addEventListener("keydown", handleKeyDown);
84
+ return () => {
85
+ document.removeEventListener("mousedown", close);
86
+ document.removeEventListener("keydown", handleKeyDown);
87
+ };
88
+ }, [open]);
89
+
90
+ const toggle = (optValue: string) => {
91
+ const next = selected.includes(optValue)
92
+ ? selected.filter((v) => v !== optValue)
93
+ : [...selected, optValue];
94
+ onChange(next.join(MULTI_VALUE_SEP));
95
+ };
96
+
97
+ const displayText =
98
+ selected.length === 0
99
+ ? filter.attributes?.placeholder || "All"
100
+ : selected.length === 1
101
+ ? options.find((o) => o.value === selected[0])?.label || selected[0]
102
+ : `${selected.length} selected`;
103
+
104
+ return (
105
+ <div className="flex flex-col gap-1.5 min-w-[160px]" role="group" aria-label={label}>
106
+ <label className="text-sm font-medium text-gray-700 whitespace-nowrap">{label}</label>
107
+ <div className="relative">
108
+ <button
109
+ ref={triggerRef}
110
+ type="button"
111
+ onClick={() => setOpen((o) => !o)}
112
+ className={`${TRIGGER_CLASS} w-full flex items-center justify-between gap-2 px-3 text-left`}
113
+ aria-label={label}
114
+ aria-expanded={open}
115
+ aria-haspopup="listbox"
116
+ >
117
+ <span className="truncate">{displayText}</span>
118
+ <svg
119
+ className={`h-4 w-4 shrink-0 text-gray-500 transition-transform ${open ? "rotate-180" : ""}`}
120
+ fill="none"
121
+ stroke="currentColor"
122
+ viewBox="0 0 24 24"
123
+ aria-hidden
124
+ >
125
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
126
+ </svg>
127
+ </button>
128
+ {open && (
129
+ <div
130
+ ref={panelRef}
131
+ role="listbox"
132
+ className="absolute top-full left-0 z-50 mt-1 min-w-[var(--radix-select-trigger-width)] rounded-lg border border-gray-200 bg-white py-1 shadow-md max-h-60 overflow-auto"
133
+ >
134
+ {options.map((opt) => {
135
+ const isSelected = selected.includes(opt.value);
136
+ return (
137
+ <button
138
+ key={opt.value}
139
+ type="button"
140
+ role="option"
141
+ aria-selected={isSelected}
142
+ onClick={() => toggle(opt.value)}
143
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
144
+ >
145
+ <span
146
+ className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
147
+ isSelected ? "bg-purple-600 border-purple-600" : "border-gray-300"
148
+ }`}
149
+ >
150
+ {isSelected && (
151
+ <svg
152
+ className="h-3 w-3 text-white"
153
+ viewBox="0 0 12 12"
154
+ fill="none"
155
+ stroke="currentColor"
156
+ strokeWidth="2"
157
+ strokeLinecap="round"
158
+ strokeLinejoin="round"
159
+ >
160
+ <polyline points="2,6 5,9 10,3" />
161
+ </svg>
162
+ )}
163
+ </span>
164
+ {opt.label || opt.value}
165
+ </button>
166
+ );
167
+ })}
168
+ </div>
169
+ )}
170
+ </div>
171
+ </div>
172
+ );
173
+ }
174
+
175
+ export function FilterFieldSelect({
176
+ filter,
177
+ options,
178
+ value,
179
+ onChange,
180
+ multiSelect = true,
181
+ }: FilterFieldSelectProps) {
182
+ if (multiSelect) {
183
+ return (
184
+ <FilterFieldMultiSelect filter={filter} options={options} value={value} onChange={onChange} />
185
+ );
186
+ }
187
+ return (
188
+ <FilterFieldSelectSingle filter={filter} options={options} value={value} onChange={onChange} />
189
+ );
190
+ }
@@ -0,0 +1,32 @@
1
+ import type { Filter } from "@salesforce/webapp-template-feature-react-global-search-experimental";
2
+
3
+ const INPUT_CLASS =
4
+ "h-9 px-3 rounded-md border border-gray-300 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent min-w-[140px]";
5
+
6
+ interface FilterFieldTextProps {
7
+ filter: Filter;
8
+ value: string;
9
+ onChange: (value: string) => void;
10
+ }
11
+
12
+ export function FilterFieldText({ filter, value, onChange }: FilterFieldTextProps) {
13
+ const label = filter.label || filter.targetFieldPath;
14
+ const id = `filter-${filter.targetFieldPath}`;
15
+ const placeholder = filter.attributes?.placeholder || `Enter ${label.toLowerCase()}`;
16
+ return (
17
+ <div className="flex flex-col gap-1.5 min-w-[160px]">
18
+ <label htmlFor={id} className="text-sm font-medium text-gray-700 whitespace-nowrap">
19
+ {label}
20
+ </label>
21
+ <input
22
+ id={id}
23
+ type="text"
24
+ value={value}
25
+ onChange={(e) => onChange(e.target.value)}
26
+ placeholder={placeholder}
27
+ className={INPUT_CLASS}
28
+ aria-label={label}
29
+ />
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,100 @@
1
+ import type { Filter } from "@salesforce/webapp-template-feature-react-global-search-experimental";
2
+ import { FilterFieldRange } from "./FilterFieldRange.js";
3
+ import { FilterFieldSelect } from "./FilterFieldSelect.js";
4
+ import { FilterFieldText } from "./FilterFieldText.js";
5
+
6
+ /** Compatible with feature picklist option shape (label/value). */
7
+ interface PicklistOption {
8
+ label?: string;
9
+ value: string;
10
+ }
11
+ interface ListPageFilterRowProps {
12
+ filters: Filter[];
13
+ picklistValues: Record<string, PicklistOption[]>;
14
+ formValues: Record<string, string>;
15
+ onFormValueChange: (key: string, value: string) => void;
16
+ onApply: () => void;
17
+ onReset: () => void;
18
+ ariaLabel?: string;
19
+ }
20
+
21
+ /**
22
+ * Horizontal row of filter controls: range, select (multi-select), and text fields + Apply/Reset.
23
+ */
24
+ export function ListPageFilterRow({
25
+ filters,
26
+ picklistValues,
27
+ formValues,
28
+ onFormValueChange,
29
+ onApply,
30
+ onReset,
31
+ ariaLabel = "Filters",
32
+ }: ListPageFilterRowProps) {
33
+ if (filters.length === 0) return null;
34
+
35
+ return (
36
+ <div
37
+ className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"
38
+ role="region"
39
+ aria-label={ariaLabel}
40
+ >
41
+ <div className="flex flex-wrap items-end gap-4">
42
+ {filters.map((filter) => {
43
+ if (!filter?.targetFieldPath) return null;
44
+ const affordance = (filter.affordance ?? "").toLowerCase();
45
+
46
+ if (affordance === "range") {
47
+ return (
48
+ <FilterFieldRange
49
+ key={filter.targetFieldPath}
50
+ filter={filter}
51
+ formValues={formValues}
52
+ onChange={onFormValueChange}
53
+ />
54
+ );
55
+ }
56
+
57
+ if (affordance === "select") {
58
+ const options = picklistValues[filter.targetFieldPath] ?? [];
59
+ const value = formValues[filter.targetFieldPath] ?? "";
60
+ return (
61
+ <FilterFieldSelect
62
+ key={filter.targetFieldPath}
63
+ filter={filter}
64
+ options={options}
65
+ value={value}
66
+ onChange={(v) => onFormValueChange(filter.targetFieldPath, v)}
67
+ multiSelect={true}
68
+ />
69
+ );
70
+ }
71
+
72
+ return (
73
+ <FilterFieldText
74
+ key={filter.targetFieldPath}
75
+ filter={filter}
76
+ value={formValues[filter.targetFieldPath] ?? ""}
77
+ onChange={(v) => onFormValueChange(filter.targetFieldPath, v)}
78
+ />
79
+ );
80
+ })}
81
+ <div className="flex items-center gap-2 ml-2 shrink-0">
82
+ <button
83
+ onClick={onApply}
84
+ className="h-9 px-4 bg-purple-700 hover:bg-purple-800 text-white text-sm font-medium rounded-md"
85
+ aria-label="Apply filters"
86
+ >
87
+ Apply
88
+ </button>
89
+ <button
90
+ onClick={onReset}
91
+ className="h-9 px-4 text-sm font-medium rounded-md border-gray-300"
92
+ aria-label="Reset filters"
93
+ >
94
+ Reset
95
+ </button>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ interface PageContainerProps {
4
+ children: ReactNode;
5
+ }
6
+
7
+ export function PageContainer({ children }: PageContainerProps) {
8
+ return <div className="min-h-screen bg-gray-50 p-8">{children}</div>;
9
+ }
@@ -0,0 +1,21 @@
1
+ interface PageHeaderProps {
2
+ title: string;
3
+ description?: string;
4
+ }
5
+
6
+ /**
7
+ * Page title and optional description. Uses a consistent wrapper (max-w-7xl mx-auto px-8 pt-8)
8
+ * so the header aligns with list/content on all pages.
9
+ */
10
+ export function PageHeader({ title, description }: PageHeaderProps) {
11
+ return (
12
+ <div className="max-w-7xl mx-auto px-8 pt-8">
13
+ <div className="mb-6">
14
+ <h1 className="text-2xl font-bold text-gray-900">{title}</h1>
15
+ {description != null && description !== "" && (
16
+ <p className="text-gray-600 mt-1">{description}</p>
17
+ )}
18
+ </div>
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,70 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Filter } from "@salesforce/webapp-template-feature-react-global-search-experimental";
3
+ import { PageContainer } from "../layout/PageContainer.js";
4
+ import { PageLoadingState } from "../feedback/PageLoadingState.js";
5
+ import { PageErrorState } from "../feedback/PageErrorState.js";
6
+ import { FilterErrorAlert } from "../feedback/FilterErrorAlert.js";
7
+ import { ListPageFilterRow } from "../filters/ListPageFilterRow.js";
8
+
9
+ /** Compatible with feature picklist option shape. */
10
+ interface PicklistOption {
11
+ label?: string;
12
+ value: string;
13
+ }
14
+
15
+ export interface ListPageWithFiltersProps {
16
+ filterProps: {
17
+ filters: Filter[];
18
+ picklistValues: Record<string, PicklistOption[]>;
19
+ formValues: Record<string, string>;
20
+ onFormValueChange: (key: string, value: string) => void;
21
+ onApply: () => void;
22
+ onReset: () => void;
23
+ ariaLabel: string;
24
+ };
25
+ filterError: string | null;
26
+ loading: boolean;
27
+ error: string | null;
28
+ loadingMessage: string;
29
+ isEmpty: boolean;
30
+ /** When set, a search input is shown that syncs with URL ?q= and drives list search. */
31
+ searchPlaceholder?: string;
32
+ searchAriaLabel?: string;
33
+ children: ReactNode;
34
+ }
35
+
36
+ export function ListPageWithFilters({
37
+ filterProps,
38
+ filterError,
39
+ loading,
40
+ error,
41
+ loadingMessage,
42
+ isEmpty,
43
+ children,
44
+ }: ListPageWithFiltersProps) {
45
+ if (error) {
46
+ return <PageErrorState message={error} />;
47
+ }
48
+
49
+ if (loading && isEmpty) {
50
+ return <PageLoadingState message={loadingMessage} />;
51
+ }
52
+
53
+ return (
54
+ <PageContainer>
55
+ <div className="max-w-7xl mx-auto space-y-6">
56
+ <ListPageFilterRow
57
+ filters={filterProps.filters}
58
+ picklistValues={filterProps.picklistValues}
59
+ formValues={filterProps.formValues}
60
+ onFormValueChange={filterProps.onFormValueChange}
61
+ onApply={filterProps.onApply}
62
+ onReset={filterProps.onReset}
63
+ ariaLabel={filterProps.ariaLabel}
64
+ />
65
+ {filterError && <FilterErrorAlert message={filterError} />}
66
+ {children}
67
+ </div>
68
+ </PageContainer>
69
+ );
70
+ }