@salesforce/webapp-template-feature-react-global-search-experimental 1.25.0 → 1.25.2

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 (38) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/api/objectDetailService.ts +97 -0
  3. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/FiltersPanel.tsx +3 -1
  4. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/SearchResultCard.tsx +9 -5
  5. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/DetailFields.tsx +29 -30
  6. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/DetailForm.tsx +143 -0
  7. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/DetailHeader.tsx +20 -28
  8. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/DetailLayoutSections.tsx +80 -0
  9. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/Section.tsx +108 -0
  10. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/SectionRow.tsx +20 -0
  11. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/UiApiDetailForm.tsx +129 -0
  12. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FieldValueDisplay.tsx +59 -0
  13. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FormattedAddress.tsx +29 -0
  14. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FormattedEmail.tsx +17 -0
  15. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FormattedPhone.tsx +24 -0
  16. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FormattedText.tsx +11 -0
  17. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FormattedUrl.tsx +29 -0
  18. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/index.ts +6 -0
  19. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/search/SearchResultsPanel.tsx +8 -1
  20. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/constants.ts +3 -0
  21. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/features/global-search/index.ts +21 -0
  22. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/hooks/useRecordDetail.ts +7 -8
  23. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/hooks/useRecordDetailLayout.ts +133 -0
  24. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/pages/DetailPage.tsx +32 -53
  25. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/pages/GlobalSearch.tsx +4 -2
  26. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/routes.tsx +1 -1
  27. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/types/recordDetail/recordDetail.ts +61 -0
  28. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/fieldUtils.ts +214 -67
  29. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/fieldValueExtractor.ts +1 -1
  30. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/formDataTransformUtils.ts +260 -0
  31. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/formUtils.ts +14 -2
  32. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/layoutTransformUtils.ts +236 -0
  33. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/linkUtils.ts +14 -0
  34. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/paginationUtils.ts +1 -1
  35. package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/recordUtils.ts +105 -62
  36. package/dist/force-app/main/default/webapplications/feature-react-global-search/vite.config.ts +1 -0
  37. package/dist/package.json +1 -1
  38. package/package.json +2 -2
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [1.25.2](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.25.1...v1.25.2) (2026-02-11)
7
+
8
+ **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
9
+
10
+
11
+
12
+
13
+
14
+ ## [1.25.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.25.0...v1.25.1) (2026-02-11)
15
+
16
+ **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
17
+
18
+
19
+
20
+
21
+
6
22
  # [1.25.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.24.0...v1.25.0) (2026-02-11)
7
23
 
8
24
  **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
@@ -0,0 +1,97 @@
1
+ import { uiApiClient } from "@salesforce/webapp-experimental/api";
2
+ import type { LayoutResponse } from "../types/recordDetail/recordDetail";
3
+ import { LayoutResponseSchema } from "../types/recordDetail/recordDetail";
4
+ import type { SearchResultRecordData } from "../types/search/searchResults";
5
+ import { SearchResultRecordDataSchema } from "../types/search/searchResults";
6
+ import { fetchAndValidate, safeEncodePath } from "../utils/apiUtils";
7
+ import { calculateFieldsToFetch } from "../utils/recordUtils";
8
+ import { objectInfoService } from "./objectInfoService";
9
+ import type { ObjectInfoResult } from "../types/objectInfo/objectInfo";
10
+
11
+ /** Fallback when record type is unknown. Prefer recordTypeId from the record (e.g. from search or record response) when available. */
12
+ const DEFAULT_RECORD_TYPE_ID = "012000000000000AAA";
13
+
14
+ /** Builds optionalFields list from layout (delegates to recordUtils for LWC-aligned logic). */
15
+ export function extractFieldsFromLayout(
16
+ objectMetadata: ObjectInfoResult,
17
+ layout: LayoutResponse,
18
+ ): string[] {
19
+ const [optionalFields] = calculateFieldsToFetch(objectMetadata, layout, true);
20
+ return optionalFields;
21
+ }
22
+
23
+ export async function getLayout(
24
+ objectApiName: string,
25
+ recordTypeId: string = DEFAULT_RECORD_TYPE_ID,
26
+ signal?: AbortSignal,
27
+ ): Promise<LayoutResponse> {
28
+ const params = new URLSearchParams({
29
+ layoutType: "Full",
30
+ mode: "View",
31
+ recordTypeId,
32
+ });
33
+ return fetchAndValidate(
34
+ (abortSignal) =>
35
+ uiApiClient.get(`/layout/${safeEncodePath(objectApiName)}?${params.toString()}`, {
36
+ signal: abortSignal,
37
+ }),
38
+ {
39
+ schema: LayoutResponseSchema,
40
+ errorContext: `layout for ${objectApiName}`,
41
+ signal,
42
+ },
43
+ );
44
+ }
45
+
46
+ export async function getRecord(
47
+ recordId: string,
48
+ optionalFields: string[],
49
+ signal?: AbortSignal,
50
+ ): Promise<SearchResultRecordData> {
51
+ const optionalFieldsParam = optionalFields.length > 0 ? optionalFields.join(",") : "";
52
+ const query = optionalFieldsParam
53
+ ? `?optionalFields=${encodeURIComponent(optionalFieldsParam)}`
54
+ : "";
55
+ const validated = await fetchAndValidate(
56
+ (abortSignal) =>
57
+ uiApiClient.get(`/records/${safeEncodePath(recordId)}${query}`, {
58
+ signal: abortSignal,
59
+ }),
60
+ {
61
+ schema: SearchResultRecordDataSchema,
62
+ errorContext: `record ${recordId}`,
63
+ signal,
64
+ },
65
+ );
66
+ return validated as SearchResultRecordData;
67
+ }
68
+
69
+ export interface RecordDetailResult {
70
+ layout: LayoutResponse;
71
+ record: SearchResultRecordData;
72
+ objectMetadata: ObjectInfoResult;
73
+ }
74
+
75
+ export async function getRecordDetail(
76
+ objectApiName: string,
77
+ recordId: string,
78
+ recordTypeId: string = DEFAULT_RECORD_TYPE_ID,
79
+ signal?: AbortSignal,
80
+ ): Promise<RecordDetailResult> {
81
+ const layout = await getLayout(objectApiName, recordTypeId, signal);
82
+ const objectMetadata = await objectInfoService.getObjectInfoBatch(objectApiName, signal);
83
+ const firstResult = objectMetadata?.results?.[0]?.result;
84
+ if (!firstResult) {
85
+ throw new Error(`Object metadata not found for ${objectApiName}`);
86
+ }
87
+ const [optionalFields] = calculateFieldsToFetch(firstResult, layout, true);
88
+ const record = await getRecord(recordId, optionalFields, signal);
89
+ return { layout, record, objectMetadata: firstResult };
90
+ }
91
+
92
+ export const objectDetailService = {
93
+ extractFieldsFromLayout,
94
+ getLayout,
95
+ getRecord,
96
+ getRecordDetail,
97
+ };
@@ -36,6 +36,7 @@ import type { Filter, FilterCriteria } from "../types/filters/filters";
36
36
  import type { PicklistValue } from "../types/filters/picklist";
37
37
  import { parseFilterValue } from "../utils/filterUtils";
38
38
  import { sanitizeFilterValue } from "../utils/sanitizationUtils";
39
+ import { getFormValueByPath } from "../utils/formUtils";
39
40
 
40
41
  interface FiltersPanelProps {
41
42
  filters: Filter[];
@@ -144,7 +145,8 @@ export default function FiltersPanel({
144
145
  }
145
146
  }
146
147
  } else {
147
- const fieldValueRaw = value[filter.targetFieldPath] || "";
148
+ const fieldValueRaw =
149
+ getFormValueByPath(value as Record<string, unknown>, filter.targetFieldPath) || "";
148
150
  const fieldValue = sanitizeFilterValue(fieldValueRaw);
149
151
 
150
152
  if (fieldValue) {
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @param record - The search result record data to display
8
8
  * @param columns - Array of column definitions for field display
9
- * @param objectApiName - Optional API name of the object (deprecated, kept for backward compatibility)
9
+ * @param objectApiName - API name of the object (path param in detail URL: /object/:objectApiName/:recordId)
10
10
  *
11
11
  * @remarks
12
12
  * - Automatically identifies the primary field (usually "Name")
@@ -29,6 +29,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
29
29
  import type { Column, SearchResultRecordData } from "../types/search/searchResults";
30
30
  import { getNestedFieldValue } from "../utils/fieldUtils";
31
31
  import ResultCardFields from "./search/ResultCardFields";
32
+ import { OBJECT_API_NAMES } from "../constants";
32
33
 
33
34
  interface SearchResultCardProps {
34
35
  record: SearchResultRecordData;
@@ -55,11 +56,14 @@ export default function SearchResultCard({
55
56
  return null;
56
57
  }
57
58
 
59
+ const detailPath = useMemo(
60
+ () => `/object/${objectApiName?.trim() || OBJECT_API_NAMES[0]}/${record.id}`,
61
+ [record.id, objectApiName],
62
+ );
63
+
58
64
  const handleClick = useCallback(() => {
59
- if (record.id) {
60
- navigate(`/detail/${record.id}`);
61
- }
62
- }, [record.id, navigate]);
65
+ if (record.id) navigate(detailPath);
66
+ }, [record.id, detailPath, navigate]);
63
67
 
64
68
  const handleKeyDown = useCallback(
65
69
  (e: React.KeyboardEvent) => {
@@ -1,25 +1,13 @@
1
1
  /**
2
- * DetailFields Component
2
+ * Alternative detail rendering: columns + record → label/value list.
3
3
  *
4
- * Displays all fields for a record in the detail page.
5
- * Shows field labels and values in a structured layout.
4
+ * Use when you have list columns + record (e.g. from useRecordDetail or getObjectListInfo
5
+ * + searchResults) and do not need the Layout API. The primary detail view (DetailPage)
6
+ * uses DetailForm via UiApiDetailForm; use this component for list-to-detail flows
7
+ * that rely on column metadata instead of layout.
6
8
  *
7
- * @param record - The record data to display
8
- * @param columns - Array of column definitions for field display
9
- *
10
- * @remarks
11
- * - Displays all available fields from the record
12
- * - Handles nested field paths (e.g., "Owner.Alias")
13
- * - Skips fields with null/undefined/empty values
14
- * - Responsive layout (vertical on mobile, horizontal on desktop)
15
- *
16
- * @example
17
- * ```tsx
18
- * <DetailFields
19
- * record={recordData}
20
- * columns={columns}
21
- * />
22
- * ```
9
+ * @param record - Record data to display
10
+ * @param columns - Column definitions (e.g. from getObjectListInfo)
23
11
  */
24
12
  import type { Column, SearchResultRecordData } from "../../types/search/searchResults";
25
13
  import { getNestedFieldValue } from "../../utils/fieldUtils";
@@ -29,23 +17,34 @@ interface DetailFieldsProps {
29
17
  columns: Column[];
30
18
  }
31
19
 
32
- export default function DetailFields({ record, columns }: DetailFieldsProps) {
33
- return (
34
- <dl className="space-y-4" role="list">
35
- {columns.map((column) => {
36
- if (!column || !column.fieldApiName) return null;
20
+ function hasVisibleValue(value: string | number | boolean | null | undefined): boolean {
21
+ return value !== null && value !== undefined && value !== "";
22
+ }
37
23
 
38
- const displayValue = getNestedFieldValue(record.fields, column.fieldApiName);
24
+ export default function DetailFields({ record, columns }: DetailFieldsProps) {
25
+ const rows = columns.filter(
26
+ (col) =>
27
+ col?.fieldApiName && hasVisibleValue(getNestedFieldValue(record.fields, col.fieldApiName)),
28
+ );
39
29
 
40
- if (displayValue === null || displayValue === undefined || displayValue === "") {
41
- return null;
42
- }
30
+ if (columns.length > 0 && rows.length === 0) {
31
+ return (
32
+ <div role="status" className="text-sm text-muted-foreground py-4">
33
+ No field values to display
34
+ </div>
35
+ );
36
+ }
43
37
 
38
+ return (
39
+ <dl className="space-y-4" role="list">
40
+ {rows.map((column) => {
41
+ const fieldApiName = column.fieldApiName as string;
42
+ const displayValue = getNestedFieldValue(record.fields, fieldApiName);
44
43
  return (
45
- <div key={column.fieldApiName} className="border-b pb-4 last:border-0" role="listitem">
44
+ <div key={fieldApiName} className="border-b pb-4 last:border-0" role="listitem">
46
45
  <div className="flex flex-col sm:flex-row sm:items-start gap-2">
47
46
  <dt className="font-semibold text-sm text-muted-foreground min-w-[150px]">
48
- {column.label || column.fieldApiName}:
47
+ {column.label || fieldApiName}:
49
48
  </dt>
50
49
  <dd className="text-sm text-foreground flex-1">{displayValue}</dd>
51
50
  </div>
@@ -0,0 +1,143 @@
1
+ import { useState, useCallback, useMemo, useId } from "react";
2
+ import type { LayoutResponse } from "../../types/recordDetail/recordDetail";
3
+ import type { SearchResultRecordData } from "../../types/search/searchResults";
4
+ import type { FieldValue } from "../../types/search/searchResults";
5
+ import {
6
+ getDisplayValueForDetailField,
7
+ getDisplayValueForLayoutItem,
8
+ } from "../../utils/fieldUtils";
9
+ import { calculateFormData } from "../../utils/formDataTransformUtils";
10
+ import type { ObjectInfoMetadata } from "../../utils/formDataTransformUtils";
11
+ import {
12
+ getTransformedSections,
13
+ type LayoutTransformContext,
14
+ type ObjectInfo,
15
+ type PicklistOption,
16
+ type TransformedLayoutItem,
17
+ } from "../../utils/layoutTransformUtils";
18
+ import { FieldValueDisplay } from "./formatted";
19
+ import { Section } from "./Section";
20
+ import { SectionRow } from "./SectionRow";
21
+
22
+ export interface DetailFormProps {
23
+ layout: LayoutResponse;
24
+ record: SearchResultRecordData;
25
+ metadata?: ObjectInfoMetadata | null;
26
+ objectInfo?: ObjectInfo | null;
27
+ lookupRecords?: Record<string, PicklistOption[] | null> | null;
28
+ showSectionHeaders?: boolean;
29
+ collapsibleSections?: boolean;
30
+ }
31
+
32
+ function FieldCell({
33
+ item,
34
+ fields,
35
+ }: {
36
+ item: TransformedLayoutItem;
37
+ fields: Record<string, FieldValue>;
38
+ }) {
39
+ if (!item.isField || item.apiName == null) return null;
40
+ const label = item.label ?? item.apiName;
41
+ const value =
42
+ item.layoutComponentApiNames && item.layoutComponentApiNames.length > 0
43
+ ? getDisplayValueForLayoutItem(fields, item.layoutComponentApiNames).value
44
+ : getDisplayValueForDetailField(fields[item.apiName]);
45
+ const dataType = item.dataType ?? undefined;
46
+ const labelId = useId();
47
+ const valueId = useId();
48
+ return (
49
+ <div
50
+ className="flex flex-col gap-1"
51
+ role="listitem"
52
+ aria-labelledby={labelId}
53
+ aria-describedby={valueId}
54
+ >
55
+ <dt id={labelId} className="text-sm font-medium text-muted-foreground">
56
+ {label}
57
+ </dt>
58
+ <dd id={valueId} className="text-sm text-foreground">
59
+ <FieldValueDisplay value={value} dataType={dataType} />
60
+ </dd>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Read-only detail form: layout API + record (+ optional object info) drive sections, rows, and
67
+ * field values. Uses layoutComponents to club multi-component items (address, Created By, etc.).
68
+ */
69
+ export function DetailForm({
70
+ layout,
71
+ record,
72
+ metadata = null,
73
+ objectInfo = null,
74
+ lookupRecords = null,
75
+ showSectionHeaders = true,
76
+ collapsibleSections = true,
77
+ }: DetailFormProps) {
78
+ const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
79
+
80
+ const formData = useMemo(
81
+ () => calculateFormData(record, metadata ?? undefined),
82
+ [record, metadata],
83
+ );
84
+
85
+ const displayFields: Record<string, FieldValue> = formData.formValues[record.id] ?? record.fields;
86
+
87
+ const layoutObjectInfo = objectInfo ?? metadata;
88
+
89
+ const transformContext: LayoutTransformContext = useMemo(
90
+ () => ({
91
+ recordId: record.id,
92
+ objectInfo: layoutObjectInfo,
93
+ lookupRecords,
94
+ getSectionCollapsedState: (sectionId: string) => Boolean(collapsedSections[sectionId]),
95
+ }),
96
+ [record.id, layoutObjectInfo, lookupRecords, collapsedSections],
97
+ );
98
+
99
+ const computedSections = useMemo(
100
+ () => getTransformedSections(layout.sections, transformContext),
101
+ [layout.sections, transformContext],
102
+ );
103
+
104
+ const handleSectionToggle = useCallback((sectionId: string, collapsed: boolean) => {
105
+ setCollapsedSections((prev) => ({ ...prev, [sectionId]: collapsed }));
106
+ }, []);
107
+
108
+ return (
109
+ <div
110
+ className="space-y-6"
111
+ role="region"
112
+ aria-label="Record details"
113
+ aria-roledescription="Detail form"
114
+ >
115
+ {computedSections.map((section) => (
116
+ <Section
117
+ key={section.key}
118
+ sectionId={section.id}
119
+ titleLabel={section.heading}
120
+ showHeader={showSectionHeaders && section.useHeading}
121
+ collapsible={collapsibleSections && section.collapsible}
122
+ collapsed={section.collapsed}
123
+ onToggle={handleSectionToggle}
124
+ >
125
+ <div className="space-y-4">
126
+ {section.layoutRows.map((row) => (
127
+ <SectionRow key={row.key}>
128
+ {row.layoutItems.map((item) => {
129
+ const cellKey = `${section.key}-${row.key}-${item.apiName ?? item.key}`;
130
+ return item.isField ? (
131
+ <FieldCell key={cellKey} item={item} fields={displayFields} />
132
+ ) : (
133
+ <div key={cellKey} className="min-h-[2.5rem]" aria-hidden="true" />
134
+ );
135
+ })}
136
+ </SectionRow>
137
+ ))}
138
+ </div>
139
+ </Section>
140
+ ))}
141
+ </div>
142
+ );
143
+ }
@@ -1,23 +1,8 @@
1
1
  /**
2
- * DetailHeader Component
2
+ * Back button and title for the record detail page.
3
3
  *
4
- * Displays a back button for navigating away from the detail page.
5
- *
6
- * @param title - Title text (currently unused but kept for consistency)
7
- * @param onBack - Callback function to navigate back
8
- *
9
- * @remarks
10
- * - Simple component with just a back button
11
- * - Uses ghost variant for subtle styling
12
- * - Includes accessibility label
13
- *
14
- * @example
15
- * ```tsx
16
- * <DetailHeader
17
- * title="Record Details"
18
- * onBack={() => navigate(-1)}
19
- * />
20
- * ```
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.
21
6
  */
22
7
  import { Button } from "../ui/button";
23
8
  import { ArrowLeft } from "lucide-react";
@@ -27,16 +12,23 @@ interface DetailHeaderProps {
27
12
  onBack: () => void;
28
13
  }
29
14
 
30
- export default function DetailHeader({ onBack }: DetailHeaderProps) {
15
+ export default function DetailHeader({ title, onBack }: DetailHeaderProps) {
31
16
  return (
32
- <Button
33
- variant="ghost"
34
- onClick={onBack}
35
- className="mb-6"
36
- aria-label="Go back to search results"
37
- >
38
- <ArrowLeft className="h-4 w-4 mr-2" aria-hidden="true" />
39
- Back
40
- </Button>
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>
41
33
  );
42
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
+ }
@@ -0,0 +1,108 @@
1
+ import { useState, useCallback, useEffect, useRef } from "react";
2
+ import { ChevronDown, ChevronRight } from "lucide-react";
3
+ import type { ReactNode } from "react";
4
+
5
+ export interface SectionProps {
6
+ sectionId: string;
7
+ titleLabel: string;
8
+ showHeader: boolean;
9
+ collapsible: boolean;
10
+ /** When provided, section is controlled (parent owns state). When undefined, section is uncontrolled (internal state). */
11
+ collapsed?: boolean;
12
+ onToggle?: (sectionId: string, collapsed: boolean) => void;
13
+ children: ReactNode;
14
+ }
15
+
16
+ /**
17
+ * Section block with optional heading and collapsible content. Controlled when
18
+ * `collapsed` is passed; uncontrolled otherwise. Accessible: aria-expanded, aria-controls, keyboard (Enter/Space).
19
+ */
20
+ export function Section({
21
+ sectionId,
22
+ titleLabel,
23
+ showHeader,
24
+ collapsible,
25
+ collapsed: controlledCollapsed,
26
+ onToggle,
27
+ children,
28
+ }: SectionProps) {
29
+ const [internalCollapsed, setInternalCollapsed] = useState(false);
30
+ const isControlled = controlledCollapsed !== undefined;
31
+ const collapsed = isControlled ? controlledCollapsed : internalCollapsed;
32
+
33
+ const warnedUncontrolledRef = useRef(false);
34
+ useEffect(() => {
35
+ if (
36
+ process.env.NODE_ENV === "development" &&
37
+ onToggle != null &&
38
+ !isControlled &&
39
+ !warnedUncontrolledRef.current
40
+ ) {
41
+ warnedUncontrolledRef.current = true;
42
+ console.warn(
43
+ "[Section] onToggle is passed but collapsed is undefined; section is uncontrolled. Pass collapsed to control from parent.",
44
+ );
45
+ }
46
+ }, [onToggle, isControlled]);
47
+
48
+ const contentId = `section-content-${sectionId}`;
49
+ const headerId = `section-header-${sectionId}`;
50
+
51
+ const handleToggle = useCallback(() => {
52
+ const next = !collapsed;
53
+ if (!isControlled) setInternalCollapsed(next);
54
+ onToggle?.(sectionId, next);
55
+ }, [collapsed, isControlled, onToggle, sectionId]);
56
+
57
+ const handleKeyDown = useCallback(
58
+ (e: React.KeyboardEvent) => {
59
+ if (!collapsible) return;
60
+ if (e.key === "Enter" || e.key === " ") {
61
+ e.preventDefault();
62
+ handleToggle();
63
+ }
64
+ },
65
+ [collapsible, handleToggle],
66
+ );
67
+
68
+ return (
69
+ <section
70
+ className="border-b border-border last:border-b-0 pb-6 last:pb-0"
71
+ aria-labelledby={showHeader ? headerId : undefined}
72
+ >
73
+ {showHeader && titleLabel && (
74
+ <h3 id={headerId} className="text-base font-semibold text-foreground mb-4">
75
+ {collapsible ? (
76
+ <button
77
+ type="button"
78
+ className="flex items-center gap-2 w-full text-left hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded"
79
+ onClick={handleToggle}
80
+ onKeyDown={handleKeyDown}
81
+ aria-expanded={!collapsed}
82
+ aria-controls={contentId}
83
+ aria-label={`${titleLabel}, ${collapsed ? "expand" : "collapse"} section`}
84
+ aria-roledescription="Section toggle"
85
+ >
86
+ {collapsed ? (
87
+ <ChevronRight className="h-4 w-4 shrink-0" aria-hidden />
88
+ ) : (
89
+ <ChevronDown className="h-4 w-4 shrink-0" aria-hidden />
90
+ )}
91
+ <span>{titleLabel}</span>
92
+ </button>
93
+ ) : (
94
+ <span className="block">{titleLabel}</span>
95
+ )}
96
+ </h3>
97
+ )}
98
+ <div
99
+ id={contentId}
100
+ className={showHeader && collapsible ? "mt-2" : ""}
101
+ aria-hidden={collapsible ? collapsed : undefined}
102
+ hidden={collapsible && collapsed}
103
+ >
104
+ {children}
105
+ </div>
106
+ </section>
107
+ );
108
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface SectionRowProps {
4
+ children: ReactNode;
5
+ }
6
+
7
+ /**
8
+ * One row of the detail form: definition list (dl) with two-column grid. Each child
9
+ * is a layout item (field cell or placeholder) from the layout API row.
10
+ */
11
+ export function SectionRow({ children }: SectionRowProps) {
12
+ return (
13
+ <dl
14
+ className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4 sm:gap-y-2"
15
+ aria-label="Row of fields"
16
+ >
17
+ {children}
18
+ </dl>
19
+ );
20
+ }