@salesforce/webapp-template-app-react-sample-b2x-experimental 1.68.0 → 1.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.md +16 -0
- package/dist/force-app/main/default/data/Lease__c.json +13 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +13 -8
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/applicationApi.ts +78 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphqlClient.ts +17 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/index.ts +19 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/leadApi.ts +69 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/maintenanceRequestApi.ts +177 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/objectDetailService.ts +125 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/objectInfoGraphQLService.ts +194 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/objectInfoService.ts +199 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyDetailGraphQL.ts +497 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyListingGraphQL.ts +190 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/recordListGraphQLService.ts +365 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +20 -30
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/FiltersPanel.tsx +375 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/LoadingFallback.tsx +61 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyListingCard.tsx +164 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyMap.tsx +113 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/SearchResultCard.tsx +131 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/alerts/status-alert.tsx +45 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailFields.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailForm.tsx +146 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailHeader.tsx +34 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailLayoutSections.tsx +80 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/Section.tsx +108 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/SectionRow.tsx +20 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/UiApiDetailForm.tsx +140 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FieldValueDisplay.tsx +73 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedAddress.tsx +29 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedEmail.tsx +17 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedPhone.tsx +24 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedText.tsx +11 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedUrl.tsx +29 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/index.ts +6 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/filters/FilterField.tsx +54 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/filters/FilterInput.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/filters/FilterSelect.tsx +72 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/forms/filters-form.tsx +114 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/forms/submit-button.tsx +47 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/layout/card-layout.tsx +19 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/ResultCardFields.tsx +71 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/SearchHeader.tsx +31 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/SearchPagination.tsx +144 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/SearchResultsPanel.tsx +197 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/shared/GlobalSearchInput.tsx +114 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/constants.ts +39 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/global-search/index.ts +33 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/form.tsx +204 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/index.ts +22 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useGeocode.ts +35 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useMaintenanceRequests.ts +39 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useObjectInfoBatch.ts +65 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useObjectSearchData.ts +395 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyAddresses.ts +36 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyDetail.ts +99 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingSearch.ts +75 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyMapMarkers.ts +100 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyPrimaryImages.ts +51 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useRecordDetailLayout.ts +156 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useRecordListGraphQL.ts +135 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useWeather.ts +173 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Application.tsx +263 -76
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +158 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Dashboard.tsx +137 -65
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/DetailPage.tsx +109 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/GlobalSearch.tsx +229 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Home.tsx +469 -21
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Maintenance.tsx +244 -95
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyDetails.tsx +211 -39
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyListings.tsx +26 -10
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearch.tsx +165 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearchPlaceholder.tsx +49 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-01.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-02.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-03.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-04.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-05.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-06.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-07.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-08.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-09.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-10.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-11.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-12.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-13.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-14.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-15.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-16.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-17.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-18.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-19.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-20.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-21.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-22.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-23.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-24.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-25.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +32 -6
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/styles/global.css +23 -63
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/filters/filters.ts +120 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/filters/picklist.ts +32 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/index.ts +4 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/leaflet.d.ts +17 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/objectInfo/objectInfo.ts +166 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/recordDetail/recordDetail.ts +61 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/search/searchResults.ts +229 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/apiUtils.ts +125 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/cacheUtils.ts +76 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/debounce.ts +89 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/fieldUtils.ts +354 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/fieldValueExtractor.ts +67 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/filterUtils.ts +32 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/formDataTransformUtils.ts +260 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/formUtils.ts +142 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/geocode.ts +65 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/graphQLNodeFieldUtils.ts +186 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/graphQLObjectInfoAdapter.ts +319 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/graphQLRecordAdapter.ts +90 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/index.ts +59 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/layoutTransformUtils.ts +236 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/linkUtils.ts +14 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/paginationUtils.ts +49 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/recordUtils.ts +159 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/sanitizationUtils.ts +49 -0
- package/dist/package.json +1 -1
- package/package.json +2 -2
- package/dist/force-app/main/default/classes/MaintenanceRequestListAction.cls +0 -111
- package/dist/force-app/main/default/classes/MaintenanceRequestListAction.cls-meta.xml +0 -6
- package/dist/force-app/main/default/classes/MaintenanceRequestUpdatePriorityAction.cls +0 -93
- package/dist/force-app/main/default/classes/MaintenanceRequestUpdatePriorityAction.cls-meta.xml +0 -6
package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyMap.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leaflet map for property search and detail. Uses OpenStreetMap tiles (no API key).
|
|
3
|
+
* Renders one pin per property (each marker in the markers array).
|
|
4
|
+
*/
|
|
5
|
+
import { useMemo, useState, useEffect } from "react";
|
|
6
|
+
import { MapContainer, TileLayer, Marker, Popup, useMap, AttributionControl } from "react-leaflet";
|
|
7
|
+
import L from "leaflet";
|
|
8
|
+
import "leaflet/dist/leaflet.css";
|
|
9
|
+
|
|
10
|
+
// Type for leaflet L when using declaration-only (no @types/leaflet)
|
|
11
|
+
const Leaflet = L as {
|
|
12
|
+
divIcon: (opts: {
|
|
13
|
+
className?: string;
|
|
14
|
+
html?: string;
|
|
15
|
+
iconSize?: [number, number];
|
|
16
|
+
iconAnchor?: [number, number];
|
|
17
|
+
popupAnchor?: [number, number];
|
|
18
|
+
}) => unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Custom pin icon so each property shows a visible pin (no dependency on external marker images)
|
|
22
|
+
const pinIcon = Leaflet.divIcon({
|
|
23
|
+
className: "property-map-pin",
|
|
24
|
+
html: `<span class="property-pin-shape" aria-hidden="true"></span>`,
|
|
25
|
+
iconSize: [28, 40],
|
|
26
|
+
iconAnchor: [14, 40],
|
|
27
|
+
popupAnchor: [0, -40],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export interface MapMarker {
|
|
31
|
+
lat: number;
|
|
32
|
+
lng: number;
|
|
33
|
+
label?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PropertyMapProps {
|
|
37
|
+
/** Center [lat, lng] */
|
|
38
|
+
center: [number, number];
|
|
39
|
+
/** Initial zoom 1–18 */
|
|
40
|
+
zoom?: number;
|
|
41
|
+
/** Optional markers */
|
|
42
|
+
markers?: MapMarker[];
|
|
43
|
+
/** CSS class for the container (e.g. height) */
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function MapCenterUpdater({ center, zoom = 13 }: { center: [number, number]; zoom?: number }) {
|
|
48
|
+
const map = useMap() as { setView: (center: [number, number], zoom: number) => void };
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
map.setView(center, zoom);
|
|
51
|
+
}, [map, center[0], center[1], zoom]);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function PropertyMap({
|
|
56
|
+
center,
|
|
57
|
+
zoom = 13,
|
|
58
|
+
markers = [],
|
|
59
|
+
className = "h-[400px] w-full rounded-xl overflow-hidden",
|
|
60
|
+
}: PropertyMapProps) {
|
|
61
|
+
const [mounted, setMounted] = useState(false);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setMounted(true);
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const hasMarkers = markers.length > 0;
|
|
67
|
+
const effectiveCenter = useMemo((): [number, number] => {
|
|
68
|
+
if (hasMarkers) {
|
|
69
|
+
const sum = markers.reduce((acc, m) => [acc[0] + m.lat, acc[1] + m.lng], [0, 0]);
|
|
70
|
+
return [sum[0] / markers.length, sum[1] / markers.length];
|
|
71
|
+
}
|
|
72
|
+
return center;
|
|
73
|
+
}, [center, hasMarkers, markers]);
|
|
74
|
+
|
|
75
|
+
if (!mounted || typeof window === "undefined") {
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
className={
|
|
79
|
+
className + " bg-muted flex items-center justify-center text-muted-foreground text-sm"
|
|
80
|
+
}
|
|
81
|
+
aria-hidden
|
|
82
|
+
>
|
|
83
|
+
Loading map…
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className={className} aria-label="Property map">
|
|
90
|
+
<MapContainer
|
|
91
|
+
center={effectiveCenter}
|
|
92
|
+
zoom={zoom}
|
|
93
|
+
scrollWheelZoom
|
|
94
|
+
attributionControl={false}
|
|
95
|
+
className="h-full w-full"
|
|
96
|
+
style={{ minHeight: 200 }}
|
|
97
|
+
>
|
|
98
|
+
<AttributionControl prefix='<a href="https://leafletjs.com/">Leaflet</a>' />
|
|
99
|
+
<MapCenterUpdater center={effectiveCenter} zoom={zoom} />
|
|
100
|
+
<TileLayer
|
|
101
|
+
attribution='Data by © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
102
|
+
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
103
|
+
maxZoom={19}
|
|
104
|
+
/>
|
|
105
|
+
{markers.map((m, i) => (
|
|
106
|
+
<Marker key={`${m.lat}-${m.lng}-${i}`} position={[m.lat, m.lng]} icon={pinIcon}>
|
|
107
|
+
<Popup>{m.label ?? "Property"}</Popup>
|
|
108
|
+
</Marker>
|
|
109
|
+
))}
|
|
110
|
+
</MapContainer>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
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 { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
|
29
|
+
import type { Column, SearchResultRecordData } from "../types/search/searchResults";
|
|
30
|
+
import { getNestedFieldValue } from "../utils/fieldUtils";
|
|
31
|
+
import ResultCardFields from "./search/ResultCardFields";
|
|
32
|
+
import { OBJECT_API_NAMES } from "../constants";
|
|
33
|
+
|
|
34
|
+
interface SearchResultCardProps {
|
|
35
|
+
record: SearchResultRecordData;
|
|
36
|
+
columns: Column[];
|
|
37
|
+
objectApiName?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default function SearchResultCard({
|
|
41
|
+
record,
|
|
42
|
+
columns,
|
|
43
|
+
objectApiName,
|
|
44
|
+
}: SearchResultCardProps) {
|
|
45
|
+
const navigate = useNavigate();
|
|
46
|
+
|
|
47
|
+
if (!record || !record.id) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!columns || !Array.isArray(columns) || columns.length === 0) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!record.fields || typeof record.fields !== "object") {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const detailPath = useMemo(
|
|
60
|
+
() => `/object/${objectApiName?.trim() || OBJECT_API_NAMES[0]}/${record.id}`,
|
|
61
|
+
[record.id, objectApiName],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const handleClick = useCallback(() => {
|
|
65
|
+
if (record.id) navigate(detailPath);
|
|
66
|
+
}, [record.id, detailPath, navigate]);
|
|
67
|
+
|
|
68
|
+
const handleKeyDown = useCallback(
|
|
69
|
+
(e: React.KeyboardEvent) => {
|
|
70
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
handleClick();
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[handleClick],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const primaryField = useMemo(() => {
|
|
79
|
+
return (
|
|
80
|
+
columns.find(
|
|
81
|
+
(col) =>
|
|
82
|
+
col &&
|
|
83
|
+
col.fieldApiName &&
|
|
84
|
+
(col.fieldApiName.toLowerCase() === "name" ||
|
|
85
|
+
col.fieldApiName.toLowerCase().includes("name")),
|
|
86
|
+
) ||
|
|
87
|
+
columns[0] ||
|
|
88
|
+
null
|
|
89
|
+
);
|
|
90
|
+
}, [columns]);
|
|
91
|
+
|
|
92
|
+
const primaryValue = useMemo(() => {
|
|
93
|
+
return primaryField && primaryField.fieldApiName
|
|
94
|
+
? getNestedFieldValue(record.fields, primaryField.fieldApiName) || "Untitled"
|
|
95
|
+
: "Untitled";
|
|
96
|
+
}, [primaryField, record.fields]);
|
|
97
|
+
|
|
98
|
+
const secondaryColumns = useMemo(() => {
|
|
99
|
+
return columns.filter(
|
|
100
|
+
(col) => col && col.fieldApiName && col.fieldApiName !== primaryField?.fieldApiName,
|
|
101
|
+
);
|
|
102
|
+
}, [columns, primaryField]);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Card
|
|
106
|
+
className="cursor-pointer hover:shadow-md transition-shadow focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2"
|
|
107
|
+
onClick={handleClick}
|
|
108
|
+
onKeyDown={handleKeyDown}
|
|
109
|
+
role="button"
|
|
110
|
+
tabIndex={0}
|
|
111
|
+
aria-label={`View details for ${primaryValue}`}
|
|
112
|
+
aria-describedby={`result-${record.id}-description`}
|
|
113
|
+
>
|
|
114
|
+
<CardHeader>
|
|
115
|
+
<CardTitle className="text-lg" id={`result-${record.id}-title`}>
|
|
116
|
+
{primaryValue}
|
|
117
|
+
</CardTitle>
|
|
118
|
+
</CardHeader>
|
|
119
|
+
<CardContent>
|
|
120
|
+
<div id={`result-${record.id}-description`} className="sr-only">
|
|
121
|
+
Search result: {primaryValue}
|
|
122
|
+
</div>
|
|
123
|
+
<ResultCardFields
|
|
124
|
+
record={record}
|
|
125
|
+
columns={secondaryColumns}
|
|
126
|
+
excludeFieldApiName={primaryField?.fieldApiName}
|
|
127
|
+
/>
|
|
128
|
+
</CardContent>
|
|
129
|
+
</Card>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
3
|
+
import { useId } from "react";
|
|
4
|
+
import { AlertCircle, CheckCircle } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
const statusAlertVariants = cva("", {
|
|
7
|
+
variants: {
|
|
8
|
+
variant: {
|
|
9
|
+
error: "",
|
|
10
|
+
success: "",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: {
|
|
14
|
+
variant: "error",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
interface StatusAlertProps extends VariantProps<typeof statusAlertVariants> {
|
|
19
|
+
children?: React.ReactNode;
|
|
20
|
+
/** Alert variant type. @default "error" */
|
|
21
|
+
variant?: "error" | "success";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Status alert component for displaying error or success messages.
|
|
26
|
+
* Returns null if no children are provided.
|
|
27
|
+
*/
|
|
28
|
+
export function StatusAlert({ children, variant = "error" }: StatusAlertProps) {
|
|
29
|
+
if (!children) return null;
|
|
30
|
+
|
|
31
|
+
const isError = variant === "error";
|
|
32
|
+
const descriptionId = useId();
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Alert
|
|
36
|
+
variant={isError ? "destructive" : "default"}
|
|
37
|
+
className={statusAlertVariants({ variant })}
|
|
38
|
+
aria-describedby={descriptionId}
|
|
39
|
+
role={isError ? "alert" : "status"}
|
|
40
|
+
>
|
|
41
|
+
{isError ? <AlertCircle aria-hidden="true" /> : <CheckCircle aria-hidden="true" />}
|
|
42
|
+
<AlertDescription id={descriptionId}>{children}</AlertDescription>
|
|
43
|
+
</Alert>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alternative detail rendering: columns + record → label/value list.
|
|
3
|
+
*
|
|
4
|
+
* Use when you have list columns + record (e.g. from filters-derived columns + searchResults)
|
|
5
|
+
* and do not need the Layout API. The primary detail view (DetailPage) uses DetailForm
|
|
6
|
+
* via UiApiDetailForm (layout + GraphQL record).
|
|
7
|
+
*
|
|
8
|
+
* @param record - Record data to display
|
|
9
|
+
* @param columns - Column definitions (e.g. derived from getObjectListFilters)
|
|
10
|
+
*/
|
|
11
|
+
import type { Column, SearchResultRecordData } from "../../types/search/searchResults";
|
|
12
|
+
import { getNestedFieldValue } from "../../utils/fieldUtils";
|
|
13
|
+
|
|
14
|
+
interface DetailFieldsProps {
|
|
15
|
+
record: SearchResultRecordData;
|
|
16
|
+
columns: Column[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasVisibleValue(value: string | number | boolean | null | undefined): boolean {
|
|
20
|
+
return value !== null && value !== undefined && value !== "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function DetailFields({ record, columns }: DetailFieldsProps) {
|
|
24
|
+
const rows = columns.filter(
|
|
25
|
+
(col) =>
|
|
26
|
+
col?.fieldApiName && hasVisibleValue(getNestedFieldValue(record.fields, col.fieldApiName)),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (columns.length > 0 && rows.length === 0) {
|
|
30
|
+
return (
|
|
31
|
+
<div role="status" className="text-sm text-muted-foreground py-4">
|
|
32
|
+
No field values to display
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<dl className="space-y-4" role="list">
|
|
39
|
+
{rows.map((column) => {
|
|
40
|
+
const fieldApiName = column.fieldApiName as string;
|
|
41
|
+
const displayValue = getNestedFieldValue(record.fields, fieldApiName);
|
|
42
|
+
return (
|
|
43
|
+
<div key={fieldApiName} className="border-b pb-4 last:border-0" role="listitem">
|
|
44
|
+
<div className="flex flex-col sm:flex-row sm:items-start gap-2">
|
|
45
|
+
<dt className="font-semibold text-sm text-muted-foreground min-w-[150px]">
|
|
46
|
+
{column.label || fieldApiName}:
|
|
47
|
+
</dt>
|
|
48
|
+
<dd className="text-sm text-foreground flex-1">{displayValue}</dd>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
</dl>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, useId } from "react";
|
|
2
|
+
import type { LayoutResponse } from "../../types/recordDetail/recordDetail";
|
|
3
|
+
import type { GraphQLRecordNode } from "../../api/recordListGraphQLService";
|
|
4
|
+
import {
|
|
5
|
+
getDisplayValueForLayoutItemFromNode,
|
|
6
|
+
getDisplayValueForDetailFieldFromNode,
|
|
7
|
+
} from "../../utils/graphQLNodeFieldUtils";
|
|
8
|
+
import type { ObjectInfoMetadata } from "../../utils/formDataTransformUtils";
|
|
9
|
+
import {
|
|
10
|
+
getTransformedSections,
|
|
11
|
+
type LayoutTransformContext,
|
|
12
|
+
type ObjectInfo,
|
|
13
|
+
type PicklistOption,
|
|
14
|
+
type TransformedLayoutItem,
|
|
15
|
+
} from "../../utils/layoutTransformUtils";
|
|
16
|
+
import { FieldValueDisplay } from "./formatted";
|
|
17
|
+
import { Section } from "./Section";
|
|
18
|
+
import { SectionRow } from "./SectionRow";
|
|
19
|
+
|
|
20
|
+
export interface DetailFormProps {
|
|
21
|
+
layout: LayoutResponse;
|
|
22
|
+
record: GraphQLRecordNode;
|
|
23
|
+
metadata?: ObjectInfoMetadata | null;
|
|
24
|
+
objectInfo?: ObjectInfo | null;
|
|
25
|
+
lookupRecords?: Record<string, PicklistOption[] | null> | null;
|
|
26
|
+
showSectionHeaders?: boolean;
|
|
27
|
+
collapsibleSections?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function FieldCell({
|
|
31
|
+
item,
|
|
32
|
+
record,
|
|
33
|
+
metadata,
|
|
34
|
+
}: {
|
|
35
|
+
item: TransformedLayoutItem;
|
|
36
|
+
record: GraphQLRecordNode;
|
|
37
|
+
metadata?: ObjectInfoMetadata | null;
|
|
38
|
+
}) {
|
|
39
|
+
if (!item.isField || item.apiName == null) return null;
|
|
40
|
+
const label = item.label ?? item.apiName;
|
|
41
|
+
const hasComponents = item.layoutComponentApiNames && item.layoutComponentApiNames.length > 0;
|
|
42
|
+
const layoutResult = hasComponents
|
|
43
|
+
? getDisplayValueForLayoutItemFromNode(
|
|
44
|
+
record,
|
|
45
|
+
item.layoutComponentApiNames as string[],
|
|
46
|
+
metadata,
|
|
47
|
+
)
|
|
48
|
+
: null;
|
|
49
|
+
const value = hasComponents
|
|
50
|
+
? (layoutResult?.value ?? null)
|
|
51
|
+
: getDisplayValueForDetailFieldFromNode(record, item.apiName, metadata);
|
|
52
|
+
const dataType =
|
|
53
|
+
(hasComponents ? layoutResult?.dataType : undefined) ?? item.dataType ?? undefined;
|
|
54
|
+
const labelId = useId();
|
|
55
|
+
const valueId = useId();
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className="flex flex-col gap-1"
|
|
59
|
+
role="group"
|
|
60
|
+
aria-labelledby={labelId}
|
|
61
|
+
aria-describedby={valueId}
|
|
62
|
+
>
|
|
63
|
+
<dt id={labelId} className="text-sm font-medium text-muted-foreground">
|
|
64
|
+
{label}
|
|
65
|
+
</dt>
|
|
66
|
+
<dd id={valueId} className="text-sm text-foreground">
|
|
67
|
+
<FieldValueDisplay value={value} dataType={dataType} />
|
|
68
|
+
</dd>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read-only detail form: layout API + record (+ optional object info) drive sections, rows, and
|
|
75
|
+
* field values. Uses layoutComponents to club multi-component items (address, Created By, etc.).
|
|
76
|
+
*/
|
|
77
|
+
export function DetailForm({
|
|
78
|
+
layout,
|
|
79
|
+
record,
|
|
80
|
+
metadata = null,
|
|
81
|
+
objectInfo = null,
|
|
82
|
+
lookupRecords = null,
|
|
83
|
+
showSectionHeaders = true,
|
|
84
|
+
collapsibleSections = true,
|
|
85
|
+
}: DetailFormProps) {
|
|
86
|
+
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
|
87
|
+
|
|
88
|
+
const recordId = (record.Id as string) ?? "";
|
|
89
|
+
|
|
90
|
+
const layoutObjectInfo = objectInfo ?? metadata;
|
|
91
|
+
|
|
92
|
+
const transformContext: LayoutTransformContext = useMemo(
|
|
93
|
+
() => ({
|
|
94
|
+
recordId,
|
|
95
|
+
objectInfo: layoutObjectInfo,
|
|
96
|
+
lookupRecords,
|
|
97
|
+
getSectionCollapsedState: (sectionId: string) => Boolean(collapsedSections[sectionId]),
|
|
98
|
+
}),
|
|
99
|
+
[recordId, layoutObjectInfo, lookupRecords, collapsedSections],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const computedSections = useMemo(
|
|
103
|
+
() => getTransformedSections(layout.sections, transformContext),
|
|
104
|
+
[layout.sections, transformContext],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const handleSectionToggle = useCallback((sectionId: string, collapsed: boolean) => {
|
|
108
|
+
setCollapsedSections((prev) => ({ ...prev, [sectionId]: collapsed }));
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
className="space-y-6"
|
|
114
|
+
role="region"
|
|
115
|
+
aria-label="Record details"
|
|
116
|
+
aria-roledescription="Detail form"
|
|
117
|
+
>
|
|
118
|
+
{computedSections.map((section) => (
|
|
119
|
+
<Section
|
|
120
|
+
key={section.key}
|
|
121
|
+
sectionId={section.id}
|
|
122
|
+
titleLabel={section.heading}
|
|
123
|
+
showHeader={showSectionHeaders && section.useHeading}
|
|
124
|
+
collapsible={collapsibleSections && section.collapsible}
|
|
125
|
+
collapsed={section.collapsed}
|
|
126
|
+
onToggle={handleSectionToggle}
|
|
127
|
+
>
|
|
128
|
+
<div className="space-y-4">
|
|
129
|
+
{section.layoutRows.map((row) => (
|
|
130
|
+
<SectionRow key={row.key}>
|
|
131
|
+
{row.layoutItems.map((item) => {
|
|
132
|
+
const cellKey = `${section.key}-${row.key}-${item.apiName ?? item.key}`;
|
|
133
|
+
return item.isField ? (
|
|
134
|
+
<FieldCell key={cellKey} item={item} record={record} metadata={metadata} />
|
|
135
|
+
) : (
|
|
136
|
+
<div key={cellKey} className="min-h-[2.5rem]" aria-hidden="true" />
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</SectionRow>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
</Section>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Back button and title for the record detail page.
|
|
3
|
+
*
|
|
4
|
+
* @param title - Record title (e.g. record name) shown next to the back control.
|
|
5
|
+
* @param onBack - Called when the user activates the back control.
|
|
6
|
+
*/
|
|
7
|
+
import { Button } from "../ui/button";
|
|
8
|
+
import { ArrowLeft } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
interface DetailHeaderProps {
|
|
11
|
+
title: string;
|
|
12
|
+
onBack: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function DetailHeader({ title, onBack }: DetailHeaderProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
|
18
|
+
<Button
|
|
19
|
+
variant="ghost"
|
|
20
|
+
onClick={onBack}
|
|
21
|
+
className="w-fit"
|
|
22
|
+
aria-label="Go back to search results"
|
|
23
|
+
>
|
|
24
|
+
<ArrowLeft className="h-4 w-4 mr-2" aria-hidden="true" />
|
|
25
|
+
Back
|
|
26
|
+
</Button>
|
|
27
|
+
{title ? (
|
|
28
|
+
<h1 className="text-xl font-semibold text-foreground truncate" id="detail-page-title">
|
|
29
|
+
{title}
|
|
30
|
+
</h1>
|
|
31
|
+
) : null}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alternative detail rendering: layout sections → rows → items → label/value grid.
|
|
3
|
+
*
|
|
4
|
+
* Use when you have raw Layout API response + record and do not need the full
|
|
5
|
+
* layoutTransformUtils + formDataTransformUtils pipeline. The primary detail view
|
|
6
|
+
* (DetailPage) uses DetailForm via UiApiDetailForm; use this component for other
|
|
7
|
+
* entry points that already have layout + record in hand.
|
|
8
|
+
*/
|
|
9
|
+
import type { LayoutResponse } from "../../types/recordDetail/recordDetail";
|
|
10
|
+
import type { SearchResultRecordData } from "../../types/search/searchResults";
|
|
11
|
+
import { getNestedFieldValue } from "../../utils/fieldUtils";
|
|
12
|
+
|
|
13
|
+
interface DetailLayoutSectionsProps {
|
|
14
|
+
layout: LayoutResponse;
|
|
15
|
+
record: SearchResultRecordData;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface FieldEntry {
|
|
19
|
+
key: string;
|
|
20
|
+
label: string;
|
|
21
|
+
value: string | number | boolean | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getSectionFieldEntries(
|
|
25
|
+
section: LayoutResponse["sections"][number],
|
|
26
|
+
record: SearchResultRecordData,
|
|
27
|
+
): FieldEntry[] {
|
|
28
|
+
const entries: FieldEntry[] = [];
|
|
29
|
+
section.layoutRows.forEach((row, rowIdx) => {
|
|
30
|
+
row.layoutItems.forEach((item, itemIdx) => {
|
|
31
|
+
item.layoutComponents.forEach((comp, compIdx) => {
|
|
32
|
+
if (comp.componentType !== "Field" || !comp.apiName) return;
|
|
33
|
+
const value = getNestedFieldValue(record.fields, comp.apiName);
|
|
34
|
+
const label = comp.label ?? item.label;
|
|
35
|
+
entries.push({
|
|
36
|
+
key: `${section.id}-${rowIdx}-${itemIdx}-${comp.apiName ?? compIdx}`,
|
|
37
|
+
label: label || comp.apiName,
|
|
38
|
+
value: value ?? null,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
return entries;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function DetailLayoutSections({ layout, record }: DetailLayoutSectionsProps) {
|
|
47
|
+
return (
|
|
48
|
+
<div className="space-y-8" role="region" aria-label="Record details">
|
|
49
|
+
{layout.sections.map((section) => {
|
|
50
|
+
const entries = getSectionFieldEntries(section, record);
|
|
51
|
+
if (entries.length === 0) return null;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<section
|
|
55
|
+
key={section.id}
|
|
56
|
+
className="space-y-4"
|
|
57
|
+
aria-labelledby={section.useHeading ? `section-${section.id}` : undefined}
|
|
58
|
+
>
|
|
59
|
+
{section.useHeading && section.heading ? (
|
|
60
|
+
<h3
|
|
61
|
+
id={`section-${section.id}`}
|
|
62
|
+
className="text-base font-semibold text-foreground border-b pb-2"
|
|
63
|
+
>
|
|
64
|
+
{section.heading}
|
|
65
|
+
</h3>
|
|
66
|
+
) : null}
|
|
67
|
+
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
|
68
|
+
{entries.map(({ key, label, value }) => (
|
|
69
|
+
<div key={key} className="flex flex-col gap-1">
|
|
70
|
+
<dt className="text-sm font-medium text-muted-foreground">{label}</dt>
|
|
71
|
+
<dd className="text-sm text-foreground">{value || "—"}</dd>
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</dl>
|
|
75
|
+
</section>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|