@salesforce/webapp-template-app-react-template-b2x-experimental 1.109.5 → 1.109.7
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/appreacttemplateb2x/package.json +4 -3
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/graphql-operations-types.ts +11260 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/ui/sonner.tsx +20 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/authentication/sessionTimeout/SessionTimeoutValidator.tsx +8 -16
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/authentication/sessionTimeout/sessionTimeService.ts +30 -42
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/__examples__/api/accountSearchService.ts +46 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/__examples__/api/query/distinctAccountIndustries.graphql +19 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/__examples__/api/query/distinctAccountTypes.graphql +19 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/__examples__/api/query/getAccountDetail.graphql +121 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/__examples__/api/query/searchAccounts.graphql +51 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/__examples__/pages/AccountObjectDetailPage.tsx +357 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/__examples__/pages/AccountSearch.tsx +275 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/__examples__/pages/Home.tsx +34 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/api/objectSearchService.ts +84 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/ActiveFilters.tsx +89 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/FilterPanel.tsx +127 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/ObjectBreadcrumb.tsx +66 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/PaginationControls.tsx +151 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/SearchBar.tsx +41 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/SortControl.tsx +143 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/filters/BooleanFilter.tsx +94 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/filters/DateFilter.tsx +138 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/filters/DateRangeFilter.tsx +78 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/filters/MultiSelectFilter.tsx +106 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/filters/NumericRangeFilter.tsx +102 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/filters/SearchFilter.tsx +40 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/filters/SelectFilter.tsx +97 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/components/filters/TextFilter.tsx +77 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/hooks/useAsyncData.ts +53 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/hooks/useCachedAsyncData.ts +183 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/hooks/useObjectSearchParams.ts +225 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/utils/debounce.ts +22 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/utils/fieldUtils.ts +29 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/utils/filterUtils.ts +372 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/object-search/utils/sortUtils.ts +38 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/pages/Home.tsx +10 -11
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/routes.tsx +8 -20
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +1 -1
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/api/objectDetailService.ts +0 -102
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/api/objectInfoGraphQLService.ts +0 -137
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/api/objectInfoService.ts +0 -95
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/api/recordListGraphQLService.ts +0 -364
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/DetailFields.tsx +0 -55
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/DetailForm.tsx +0 -146
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/DetailHeader.tsx +0 -34
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/DetailLayoutSections.tsx +0 -80
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/Section.tsx +0 -108
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/SectionRow.tsx +0 -20
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/UiApiDetailForm.tsx +0 -140
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/formatted/FieldValueDisplay.tsx +0 -73
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/formatted/FormattedAddress.tsx +0 -29
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/formatted/FormattedEmail.tsx +0 -17
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/formatted/FormattedPhone.tsx +0 -24
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/formatted/FormattedText.tsx +0 -11
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/detail/formatted/FormattedUrl.tsx +0 -29
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/filters/FilterField.tsx +0 -54
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/filters/FilterInput.tsx +0 -55
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/filters/FilterSelect.tsx +0 -72
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/filters/FiltersPanel.tsx +0 -380
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/forms/filters-form.tsx +0 -114
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/forms/submit-button.tsx +0 -47
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/search/GlobalSearchInput.tsx +0 -114
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/search/ResultCardFields.tsx +0 -71
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/search/SearchHeader.tsx +0 -31
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/search/SearchPagination.tsx +0 -144
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/search/SearchResultCard.tsx +0 -138
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/search/SearchResultsPanel.tsx +0 -197
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/components/shared/LoadingFallback.tsx +0 -61
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/constants.ts +0 -39
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/filters/FilterInput.tsx +0 -55
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/filters/FilterSelect.tsx +0 -72
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/hooks/form.tsx +0 -209
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/hooks/useObjectInfoBatch.ts +0 -72
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/hooks/useObjectSearchData.ts +0 -174
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/hooks/useRecordDetailLayout.ts +0 -137
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/hooks/useRecordListGraphQL.ts +0 -135
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/pages/DetailPage.tsx +0 -109
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/pages/GlobalSearch.tsx +0 -235
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/types/filters/filters.ts +0 -121
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/types/filters/picklist.ts +0 -6
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/types/objectInfo/objectInfo.ts +0 -49
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/types/recordDetail/recordDetail.ts +0 -61
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/types/schema.d.ts +0 -200
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/types/search/searchResults.ts +0 -229
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/apiUtils.ts +0 -59
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/cacheUtils.ts +0 -76
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/debounce.ts +0 -90
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/fieldUtils.ts +0 -354
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/fieldValueExtractor.ts +0 -67
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/filterUtils.ts +0 -32
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/formDataTransformUtils.ts +0 -260
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/formUtils.ts +0 -142
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/graphQLNodeFieldUtils.ts +0 -186
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/graphQLObjectInfoAdapter.ts +0 -77
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/graphQLRecordAdapter.ts +0 -90
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/layoutTransformUtils.ts +0 -236
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/linkUtils.ts +0 -14
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/paginationUtils.ts +0 -49
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/recordUtils.ts +0 -159
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/utils/sanitizationUtils.ts +0 -50
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/index.ts +0 -120
|
@@ -1,146 +0,0 @@
|
|
|
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/FieldValueDisplay";
|
|
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
|
-
const labelId = useId();
|
|
40
|
-
const valueId = useId();
|
|
41
|
-
if (!item.isField || item.apiName == null) return null;
|
|
42
|
-
const label = item.label ?? item.apiName;
|
|
43
|
-
const hasComponents = item.layoutComponentApiNames && item.layoutComponentApiNames.length > 0;
|
|
44
|
-
const layoutResult = hasComponents
|
|
45
|
-
? getDisplayValueForLayoutItemFromNode(
|
|
46
|
-
record,
|
|
47
|
-
item.layoutComponentApiNames as string[],
|
|
48
|
-
metadata,
|
|
49
|
-
)
|
|
50
|
-
: null;
|
|
51
|
-
const value = hasComponents
|
|
52
|
-
? (layoutResult?.value ?? null)
|
|
53
|
-
: getDisplayValueForDetailFieldFromNode(record, item.apiName, metadata);
|
|
54
|
-
const dataType =
|
|
55
|
-
(hasComponents ? layoutResult?.dataType : undefined) ?? item.dataType ?? undefined;
|
|
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
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
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 "../../../../components/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
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,108 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { useEffect, useMemo } from "react";
|
|
2
|
-
import type { LayoutResponse } from "../../types/recordDetail/recordDetail";
|
|
3
|
-
import { useRecordDetailLayout } from "../../hooks/useRecordDetailLayout";
|
|
4
|
-
import { toRecordDisplayNameMetadata } from "../../utils/fieldUtils";
|
|
5
|
-
import { DetailForm } from "./DetailForm";
|
|
6
|
-
import type { ObjectInfoResult } from "../../types/objectInfo/objectInfo";
|
|
7
|
-
import type { ObjectInfoMetadata } from "../../utils/formDataTransformUtils";
|
|
8
|
-
import type { ObjectInfo } from "../../utils/layoutTransformUtils";
|
|
9
|
-
import type { GraphQLRecordNode } from "../../api/recordListGraphQLService";
|
|
10
|
-
import { getGraphQLRecordDisplayName } from "../../utils/graphQLNodeFieldUtils";
|
|
11
|
-
|
|
12
|
-
export interface UiApiDetailFormProps {
|
|
13
|
-
objectApiName: string;
|
|
14
|
-
recordId: string;
|
|
15
|
-
recordTypeId?: string | null;
|
|
16
|
-
/** When provided, skips fetching and uses this layout (controlled mode). */
|
|
17
|
-
layout?: LayoutResponse | null;
|
|
18
|
-
/** When provided with layout, skips fetching and uses this record (controlled mode). */
|
|
19
|
-
record?: GraphQLRecordNode | null;
|
|
20
|
-
/** When provided, skips fetching and uses this object metadata (controlled mode). */
|
|
21
|
-
objectMetadata?: ObjectInfoResult | null;
|
|
22
|
-
/** When true, shows a loading spinner until layout and record are ready. */
|
|
23
|
-
loadsWithSpinner?: boolean;
|
|
24
|
-
/** Reserved for future edit mode; no-op in read-only. */
|
|
25
|
-
hideFooter?: boolean;
|
|
26
|
-
/** Callback when layout and record are ready (e.g. for parent to show record title). */
|
|
27
|
-
onRecordDataUpdate?: (payload: { recordName: string; record: unknown }) => void;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Entry component for the record detail view. When layout/record are not provided,
|
|
32
|
-
* fetches them via useRecordDetailLayout. Shows optional loading spinner and renders
|
|
33
|
-
* a read-only DetailForm when ready. Mirrors LWC uiApiDetailForm (read-only).
|
|
34
|
-
*
|
|
35
|
-
* Passes objectInfo (mapped from object metadata) to DetailForm for layout transform.
|
|
36
|
-
* lookupRecords (picklist/lookup options) are not fetched in this flow; DetailForm
|
|
37
|
-
* accepts them when provided (e.g. from a future picklist API). Omit for read-only
|
|
38
|
-
* display without API-driven picklist labels.
|
|
39
|
-
*/
|
|
40
|
-
export function UiApiDetailForm({
|
|
41
|
-
objectApiName,
|
|
42
|
-
recordId,
|
|
43
|
-
recordTypeId = null,
|
|
44
|
-
layout: layoutProp,
|
|
45
|
-
record: recordProp,
|
|
46
|
-
objectMetadata: objectMetadataProp,
|
|
47
|
-
loadsWithSpinner = false,
|
|
48
|
-
onRecordDataUpdate,
|
|
49
|
-
}: UiApiDetailFormProps) {
|
|
50
|
-
// Memoize so hook dependency doesn't change every render (avoids duplicate fetches)
|
|
51
|
-
const initialData = useMemo(
|
|
52
|
-
() =>
|
|
53
|
-
layoutProp && recordProp && objectMetadataProp
|
|
54
|
-
? {
|
|
55
|
-
layout: layoutProp,
|
|
56
|
-
record: recordProp,
|
|
57
|
-
objectMetadata: objectMetadataProp,
|
|
58
|
-
}
|
|
59
|
-
: null,
|
|
60
|
-
[layoutProp, recordProp, objectMetadataProp],
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
const fetched = useRecordDetailLayout({
|
|
64
|
-
objectApiName,
|
|
65
|
-
recordId,
|
|
66
|
-
recordTypeId,
|
|
67
|
-
initialData,
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const layout = layoutProp ?? fetched.layout;
|
|
71
|
-
const record = recordProp ?? fetched.record;
|
|
72
|
-
const metadata = objectMetadataProp ?? fetched.objectMetadata;
|
|
73
|
-
const loading = layoutProp == null || recordProp == null ? fetched.loading : false;
|
|
74
|
-
const error = layoutProp == null || recordProp == null ? fetched.error : null;
|
|
75
|
-
|
|
76
|
-
const objectInfo: ObjectInfo | null = useMemo(() => {
|
|
77
|
-
if (!metadata?.fields) return null;
|
|
78
|
-
const apiName = metadata.ApiName;
|
|
79
|
-
return {
|
|
80
|
-
apiName,
|
|
81
|
-
fields: Object.fromEntries(
|
|
82
|
-
Object.entries(metadata.fields).map(([name, f]) => [
|
|
83
|
-
name,
|
|
84
|
-
{
|
|
85
|
-
compoundFieldName: f.compoundFieldName ?? undefined,
|
|
86
|
-
dataType: f.dataType ?? "",
|
|
87
|
-
},
|
|
88
|
-
]),
|
|
89
|
-
),
|
|
90
|
-
};
|
|
91
|
-
}, [metadata]);
|
|
92
|
-
|
|
93
|
-
const isReadyToRender = Boolean(layout && record && layout.sections?.length);
|
|
94
|
-
|
|
95
|
-
const showSpinner = !isReadyToRender && loadsWithSpinner && loading;
|
|
96
|
-
|
|
97
|
-
useEffect(() => {
|
|
98
|
-
if (!record || !onRecordDataUpdate || !isReadyToRender) return;
|
|
99
|
-
onRecordDataUpdate({
|
|
100
|
-
recordName: getGraphQLRecordDisplayName(record, toRecordDisplayNameMetadata(metadata)),
|
|
101
|
-
record,
|
|
102
|
-
});
|
|
103
|
-
}, [record, metadata, onRecordDataUpdate, isReadyToRender]);
|
|
104
|
-
|
|
105
|
-
if (showSpinner) {
|
|
106
|
-
return (
|
|
107
|
-
<div
|
|
108
|
-
className="min-h-[80px] flex items-center justify-center"
|
|
109
|
-
role="status"
|
|
110
|
-
aria-live="polite"
|
|
111
|
-
aria-label="Loading record details"
|
|
112
|
-
>
|
|
113
|
-
<span className="sr-only">Loading record details</span>
|
|
114
|
-
<div
|
|
115
|
-
className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"
|
|
116
|
-
aria-hidden
|
|
117
|
-
/>
|
|
118
|
-
</div>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (error || !layout || !record) {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (!isReadyToRender) {
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return (
|
|
131
|
-
<DetailForm
|
|
132
|
-
layout={layout}
|
|
133
|
-
record={record}
|
|
134
|
-
metadata={metadata as ObjectInfoMetadata}
|
|
135
|
-
objectInfo={objectInfo}
|
|
136
|
-
showSectionHeaders
|
|
137
|
-
collapsibleSections
|
|
138
|
-
/>
|
|
139
|
-
);
|
|
140
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Picks formatter by dataType (Address, Phone, Url, Email) or plain text. Empty values show "—" with a11y label.
|
|
3
|
-
*/
|
|
4
|
-
import { FormattedAddress } from "./FormattedAddress";
|
|
5
|
-
import { FormattedEmail } from "./FormattedEmail";
|
|
6
|
-
import { FormattedPhone } from "./FormattedPhone";
|
|
7
|
-
import { FormattedText } from "./FormattedText";
|
|
8
|
-
import { FormattedUrl } from "./FormattedUrl";
|
|
9
|
-
|
|
10
|
-
/** Salesforce UI API dataType values that have dedicated formatters. */
|
|
11
|
-
const DATA_TYPES = {
|
|
12
|
-
Address: "Address",
|
|
13
|
-
Email: "Email",
|
|
14
|
-
Phone: "Phone",
|
|
15
|
-
Url: "Url",
|
|
16
|
-
} as const;
|
|
17
|
-
|
|
18
|
-
/** Normalize dataType to canonical casing so "PHONE" / "phone" match Phone, etc. */
|
|
19
|
-
function normalizeDataType(dataType: string | undefined): string | undefined {
|
|
20
|
-
if (dataType == null || dataType === "") return dataType;
|
|
21
|
-
const lower = dataType.toLowerCase();
|
|
22
|
-
const canonical: Record<string, string> = {
|
|
23
|
-
phone: DATA_TYPES.Phone,
|
|
24
|
-
email: DATA_TYPES.Email,
|
|
25
|
-
url: DATA_TYPES.Url,
|
|
26
|
-
address: DATA_TYPES.Address,
|
|
27
|
-
};
|
|
28
|
-
return canonical[lower] ?? dataType;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface FieldValueDisplayProps {
|
|
32
|
-
/** Resolved display value (string, number, boolean, or null). */
|
|
33
|
-
value: string | number | boolean | null | undefined;
|
|
34
|
-
/** Field dataType from object info (e.g. Phone, Email, Url, Address). */
|
|
35
|
-
dataType?: string;
|
|
36
|
-
className?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const DEFAULT_CLASS = "text-sm text-foreground";
|
|
40
|
-
const LINK_CLASS =
|
|
41
|
-
"text-sm text-foreground text-primary underline underline-offset-2 hover:opacity-80";
|
|
42
|
-
|
|
43
|
-
export function FieldValueDisplay({
|
|
44
|
-
value,
|
|
45
|
-
dataType,
|
|
46
|
-
className = DEFAULT_CLASS,
|
|
47
|
-
}: FieldValueDisplayProps) {
|
|
48
|
-
const str = value || null;
|
|
49
|
-
|
|
50
|
-
if (str === null) {
|
|
51
|
-
return (
|
|
52
|
-
<span className={className} aria-label="No value">
|
|
53
|
-
—
|
|
54
|
-
</span>
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const linkClassName = className === DEFAULT_CLASS ? LINK_CLASS : className;
|
|
59
|
-
const normalizedType = normalizeDataType(dataType);
|
|
60
|
-
|
|
61
|
-
switch (normalizedType) {
|
|
62
|
-
case DATA_TYPES.Address:
|
|
63
|
-
return <FormattedAddress value={str as string} className={linkClassName} />;
|
|
64
|
-
case DATA_TYPES.Phone:
|
|
65
|
-
return <FormattedPhone value={str as string} className={linkClassName} />;
|
|
66
|
-
case DATA_TYPES.Url:
|
|
67
|
-
return <FormattedUrl value={str as string} className={linkClassName} />;
|
|
68
|
-
case DATA_TYPES.Email:
|
|
69
|
-
return <FormattedEmail value={str as string} className={linkClassName} />;
|
|
70
|
-
default:
|
|
71
|
-
return <FormattedText value={value} className={className} />;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/** Address as link to Google Maps search. External link: target _blank, rel noopener noreferrer. */
|
|
2
|
-
|
|
3
|
-
const GOOGLE_MAPS_SEARCH_BASE = "https://www.google.com/maps/search/?api=1&query=";
|
|
4
|
-
|
|
5
|
-
export interface FormattedAddressProps {
|
|
6
|
-
/** Full address string (e.g. "10 Main Rd.\nNew York, NY 31349\nUSA"). */
|
|
7
|
-
value: string;
|
|
8
|
-
className?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function FormattedAddress({ value, className }: FormattedAddressProps) {
|
|
12
|
-
if (!value || !value.trim()) return null;
|
|
13
|
-
const url = GOOGLE_MAPS_SEARCH_BASE + encodeURIComponent(value.trim());
|
|
14
|
-
return (
|
|
15
|
-
<a
|
|
16
|
-
href={url}
|
|
17
|
-
target="_blank"
|
|
18
|
-
rel="noopener noreferrer"
|
|
19
|
-
className={className}
|
|
20
|
-
aria-label="Open address in Google Maps"
|
|
21
|
-
>
|
|
22
|
-
{value.split("\n").map((line, i) => (
|
|
23
|
-
<span key={i} className="block">
|
|
24
|
-
{line}
|
|
25
|
-
</span>
|
|
26
|
-
))}
|
|
27
|
-
</a>
|
|
28
|
-
);
|
|
29
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/** Email as mailto: link. */
|
|
2
|
-
|
|
3
|
-
export interface FormattedEmailProps {
|
|
4
|
-
value: string;
|
|
5
|
-
className?: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function FormattedEmail({ value, className }: FormattedEmailProps) {
|
|
9
|
-
const str = (value || "").trim();
|
|
10
|
-
if (!str) return null;
|
|
11
|
-
const href = `mailto:${encodeURIComponent(str)}`;
|
|
12
|
-
return (
|
|
13
|
-
<a href={href} className={className} aria-label={`Email ${str}`}>
|
|
14
|
-
{str}
|
|
15
|
-
</a>
|
|
16
|
-
);
|
|
17
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** Phone value as tel: link for dialer/VoIP. */
|
|
2
|
-
|
|
3
|
-
export interface FormattedPhoneProps {
|
|
4
|
-
value: string;
|
|
5
|
-
className?: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/** Normalizes value for tel: (digits and + only). */
|
|
9
|
-
function telHref(value: string): string {
|
|
10
|
-
const cleaned = value.replace(/[^\d+]/g, "");
|
|
11
|
-
return cleaned ? `tel:${cleaned}` : "#";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function FormattedPhone({ value, className }: FormattedPhoneProps) {
|
|
15
|
-
if (!value) return null;
|
|
16
|
-
const trimmed = (value || "").trim();
|
|
17
|
-
const href = telHref(trimmed);
|
|
18
|
-
if (href === "#") return <span className={className}>{trimmed}</span>;
|
|
19
|
-
return (
|
|
20
|
-
<a href={href} className={className} aria-label={`Call ${trimmed}`}>
|
|
21
|
-
{trimmed}
|
|
22
|
-
</a>
|
|
23
|
-
);
|
|
24
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/** Plain-text field value when dataType has no dedicated formatter. */
|
|
2
|
-
|
|
3
|
-
export interface FormattedTextProps {
|
|
4
|
-
value: string | number | boolean | null | undefined;
|
|
5
|
-
className?: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function FormattedText({ value, className }: FormattedTextProps) {
|
|
9
|
-
if (!value) return null;
|
|
10
|
-
return <span className={className}>{value as string}</span>;
|
|
11
|
-
}
|