@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.
- package/dist/CHANGELOG.md +16 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/api/objectDetailService.ts +97 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/FiltersPanel.tsx +3 -1
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/SearchResultCard.tsx +9 -5
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/DetailFields.tsx +29 -30
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/DetailForm.tsx +143 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/DetailHeader.tsx +20 -28
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/DetailLayoutSections.tsx +80 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/Section.tsx +108 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/SectionRow.tsx +20 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/UiApiDetailForm.tsx +129 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FieldValueDisplay.tsx +59 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FormattedAddress.tsx +29 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FormattedEmail.tsx +17 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FormattedPhone.tsx +24 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FormattedText.tsx +11 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/FormattedUrl.tsx +29 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/detail/formatted/index.ts +6 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/components/search/SearchResultsPanel.tsx +8 -1
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/constants.ts +3 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/features/global-search/index.ts +21 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/hooks/useRecordDetail.ts +7 -8
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/hooks/useRecordDetailLayout.ts +133 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/pages/DetailPage.tsx +32 -53
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/pages/GlobalSearch.tsx +4 -2
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/routes.tsx +1 -1
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/types/recordDetail/recordDetail.ts +61 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/fieldUtils.ts +214 -67
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/fieldValueExtractor.ts +1 -1
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/formDataTransformUtils.ts +260 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/formUtils.ts +14 -2
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/layoutTransformUtils.ts +236 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/linkUtils.ts +14 -0
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/paginationUtils.ts +1 -1
- package/dist/force-app/main/default/webapplications/feature-react-global-search/src/utils/recordUtils.ts +105 -62
- package/dist/force-app/main/default/webapplications/feature-react-global-search/vite.config.ts +1 -0
- package/dist/package.json +1 -1
- 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 =
|
|
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 -
|
|
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
|
-
|
|
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
|
-
*
|
|
2
|
+
* Alternative detail rendering: columns + record → label/value list.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 -
|
|
8
|
-
* @param columns -
|
|
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
|
-
|
|
33
|
-
return
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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={
|
|
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 ||
|
|
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
|
-
*
|
|
2
|
+
* Back button and title for the record detail page.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
}
|