@salesforce/webapp-template-app-react-template-b2x-experimental 1.59.2 → 1.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.md +8 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/package-lock.json +15 -15
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/index.ts +19 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/objectDetailService.ts +125 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/objectInfoGraphQLService.ts +194 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/objectInfoService.ts +199 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/recordListGraphQLService.ts +365 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/FiltersPanel.tsx +375 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/LoadingFallback.tsx +61 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/SearchResultCard.tsx +131 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/alerts/status-alert.tsx +45 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/DetailFields.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/DetailForm.tsx +146 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/DetailHeader.tsx +34 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/DetailLayoutSections.tsx +80 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/Section.tsx +108 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/SectionRow.tsx +20 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/UiApiDetailForm.tsx +140 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FieldValueDisplay.tsx +73 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FormattedAddress.tsx +29 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FormattedEmail.tsx +17 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FormattedPhone.tsx +24 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FormattedText.tsx +11 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FormattedUrl.tsx +29 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/index.ts +6 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/filters/FilterField.tsx +54 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/filters/FilterInput.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/filters/FilterSelect.tsx +72 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/forms/filters-form.tsx +114 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/forms/submit-button.tsx +47 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/layout/card-layout.tsx +19 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/search/ResultCardFields.tsx +71 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/search/SearchHeader.tsx +31 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/search/SearchPagination.tsx +144 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/search/SearchResultsPanel.tsx +197 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/shared/GlobalSearchInput.tsx +114 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/constants.ts +39 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/index.ts +33 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/form.tsx +208 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/index.ts +22 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/useObjectInfoBatch.ts +65 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/useObjectSearchData.ts +380 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/useRecordDetailLayout.ts +156 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/useRecordListGraphQL.ts +135 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/pages/DetailPage.tsx +109 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/pages/GlobalSearch.tsx +229 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/pages/Home.tsx +11 -10
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/routes.tsx +23 -1
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/filters/filters.ts +120 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/filters/picklist.ts +32 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/index.ts +4 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/objectInfo/objectInfo.ts +166 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/recordDetail/recordDetail.ts +61 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/search/searchResults.ts +229 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/apiUtils.ts +125 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/cacheUtils.ts +76 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/debounce.ts +89 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/fieldUtils.ts +354 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/fieldValueExtractor.ts +67 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/filterUtils.ts +32 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/formDataTransformUtils.ts +260 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/formUtils.ts +142 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/graphQLNodeFieldUtils.ts +186 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/graphQLObjectInfoAdapter.ts +319 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/graphQLRecordAdapter.ts +90 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/index.ts +59 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/layoutTransformUtils.ts +236 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/linkUtils.ts +14 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/paginationUtils.ts +49 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/recordUtils.ts +159 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/sanitizationUtils.ts +49 -0
- package/dist/package.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** URL as external link (new tab, noopener noreferrer). Falls back to plain text if not http(s). */
|
|
2
|
+
|
|
3
|
+
import { isAllowedLinkUrl } from "../../../utils/linkUtils";
|
|
4
|
+
|
|
5
|
+
export interface FormattedUrlProps {
|
|
6
|
+
value: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
/** Optional display text; defaults to the URL. */
|
|
9
|
+
displayText?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function FormattedUrl({ value, className, displayText }: FormattedUrlProps) {
|
|
13
|
+
const str = (value || "").trim();
|
|
14
|
+
if (!str) return null;
|
|
15
|
+
const href = str.startsWith("http://") || str.startsWith("https://") ? str : `https://${str}`;
|
|
16
|
+
if (!isAllowedLinkUrl(href)) return <span className={className}>{str}</span>;
|
|
17
|
+
const label = displayText ?? str;
|
|
18
|
+
return (
|
|
19
|
+
<a
|
|
20
|
+
href={href}
|
|
21
|
+
target="_blank"
|
|
22
|
+
rel="noopener noreferrer"
|
|
23
|
+
className={className}
|
|
24
|
+
aria-label={`Open link in new tab: ${label}`}
|
|
25
|
+
>
|
|
26
|
+
{label}
|
|
27
|
+
</a>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { FieldValueDisplay } from "./FieldValueDisplay";
|
|
2
|
+
export { FormattedAddress } from "./FormattedAddress";
|
|
3
|
+
export { FormattedEmail } from "./FormattedEmail";
|
|
4
|
+
export { FormattedPhone } from "./FormattedPhone";
|
|
5
|
+
export { FormattedText } from "./FormattedText";
|
|
6
|
+
export { FormattedUrl } from "./FormattedUrl";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterField Component
|
|
3
|
+
*
|
|
4
|
+
* Wrapper component that renders the appropriate filter input type based on filter affordance.
|
|
5
|
+
* Routes to FilterInput for text fields or FilterSelect for picklist fields.
|
|
6
|
+
*
|
|
7
|
+
* @param filter - Filter definition containing field path, label, and affordance
|
|
8
|
+
* @param value - Current filter value
|
|
9
|
+
* @param picklistValues - Array of picklist options (for select fields)
|
|
10
|
+
* @param onChange - Callback when filter value changes
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* - Automatically determines input type from filter.affordance
|
|
14
|
+
* - Returns null if filter is invalid
|
|
15
|
+
* - Defaults to text input if affordance is not 'select'
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <FilterField
|
|
20
|
+
* filter={filter}
|
|
21
|
+
* value={filterValue}
|
|
22
|
+
* picklistValues={picklistOptions}
|
|
23
|
+
* onChange={(value) => setFilterValue(value)}
|
|
24
|
+
* />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import FilterInput from "./FilterInput";
|
|
28
|
+
import FilterSelect from "./FilterSelect";
|
|
29
|
+
import type { Filter } from "../../types/filters/filters";
|
|
30
|
+
import type { PicklistValue } from "../../types/filters/picklist";
|
|
31
|
+
|
|
32
|
+
interface FilterFieldProps {
|
|
33
|
+
filter: Filter;
|
|
34
|
+
value: string;
|
|
35
|
+
picklistValues: PicklistValue[];
|
|
36
|
+
onChange: (value: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function FilterField({ filter, value, picklistValues, onChange }: FilterFieldProps) {
|
|
40
|
+
// Guard against invalid filter objects
|
|
41
|
+
if (!filter || !filter.targetFieldPath) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const affordance = filter.affordance?.toLowerCase() || "";
|
|
46
|
+
|
|
47
|
+
if (affordance === "select") {
|
|
48
|
+
const options = picklistValues || [];
|
|
49
|
+
return <FilterSelect filter={filter} value={value} options={options} onChange={onChange} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Default to text input
|
|
53
|
+
return <FilterInput filter={filter} value={value} onChange={onChange} />;
|
|
54
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterInput Component
|
|
3
|
+
*
|
|
4
|
+
* Renders a text input field for filter values.
|
|
5
|
+
* Used for filters that don't have a picklist (affordance !== 'select').
|
|
6
|
+
*
|
|
7
|
+
* @param filter - Filter definition containing field path, label, and attributes
|
|
8
|
+
* @param value - Current filter input value
|
|
9
|
+
* @param onChange - Callback when input value changes
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* - Displays filter label or field path as the label
|
|
13
|
+
* - Shows placeholder text from filter attributes or generates default
|
|
14
|
+
* - Displays help message if available
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <FilterInput
|
|
19
|
+
* filter={textFilter}
|
|
20
|
+
* value={filterValue}
|
|
21
|
+
* onChange={(value) => setFilterValue(value)}
|
|
22
|
+
* />
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
import { Input } from "../ui/input";
|
|
26
|
+
import { Field, FieldLabel, FieldDescription } from "../ui/field";
|
|
27
|
+
import type { Filter } from "../../types/filters/filters";
|
|
28
|
+
|
|
29
|
+
interface FilterInputProps {
|
|
30
|
+
filter: Filter;
|
|
31
|
+
value: string;
|
|
32
|
+
onChange: (value: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function FilterInput({ filter, value, onChange }: FilterInputProps) {
|
|
36
|
+
return (
|
|
37
|
+
<Field>
|
|
38
|
+
<FieldLabel htmlFor={filter.targetFieldPath}>
|
|
39
|
+
{filter.label || filter.targetFieldPath}
|
|
40
|
+
</FieldLabel>
|
|
41
|
+
<Input
|
|
42
|
+
id={filter.targetFieldPath}
|
|
43
|
+
type="text"
|
|
44
|
+
value={value}
|
|
45
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
|
46
|
+
placeholder={
|
|
47
|
+
filter.attributes?.placeholder ||
|
|
48
|
+
`Enter ${(filter.label || filter.targetFieldPath).toLowerCase()}`
|
|
49
|
+
}
|
|
50
|
+
aria-label={filter.label || filter.targetFieldPath}
|
|
51
|
+
/>
|
|
52
|
+
{filter.helpMessage && <FieldDescription>{filter.helpMessage}</FieldDescription>}
|
|
53
|
+
</Field>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterSelect Component
|
|
3
|
+
*
|
|
4
|
+
* Renders a dropdown select field for filter values with picklist options.
|
|
5
|
+
* Used for filters with affordance === 'select'.
|
|
6
|
+
*
|
|
7
|
+
* @param filter - Filter definition containing field path, label, and attributes
|
|
8
|
+
* @param value - Currently selected filter value
|
|
9
|
+
* @param options - Array of picklist values to display as options
|
|
10
|
+
* @param onChange - Callback when selection changes
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* - Filters out invalid options (null/undefined values)
|
|
14
|
+
* - Displays option label if available, otherwise uses value
|
|
15
|
+
* - Shows placeholder from filter attributes or default "Select..."
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <FilterSelect
|
|
20
|
+
* filter={selectFilter}
|
|
21
|
+
* value={selectedValue}
|
|
22
|
+
* options={picklistOptions}
|
|
23
|
+
* onChange={(value) => setSelectedValue(value)}
|
|
24
|
+
* />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
Select,
|
|
29
|
+
SelectContent,
|
|
30
|
+
SelectItem,
|
|
31
|
+
SelectTrigger,
|
|
32
|
+
SelectValue,
|
|
33
|
+
} from "../ui/select";
|
|
34
|
+
import { Field, FieldLabel, FieldDescription } from "../ui/field";
|
|
35
|
+
import type { Filter } from "../../types/filters/filters";
|
|
36
|
+
import type { PicklistValue } from "../../types/filters/picklist";
|
|
37
|
+
|
|
38
|
+
interface FilterSelectProps {
|
|
39
|
+
filter: Filter;
|
|
40
|
+
value: string;
|
|
41
|
+
options: PicklistValue[];
|
|
42
|
+
onChange: (value: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function FilterSelect({ filter, value, options, onChange }: FilterSelectProps) {
|
|
46
|
+
return (
|
|
47
|
+
<Field>
|
|
48
|
+
<FieldLabel htmlFor={filter.targetFieldPath}>
|
|
49
|
+
{filter.label || filter.targetFieldPath}
|
|
50
|
+
</FieldLabel>
|
|
51
|
+
<Select value={value} onValueChange={onChange}>
|
|
52
|
+
<SelectTrigger
|
|
53
|
+
id={filter.targetFieldPath}
|
|
54
|
+
aria-label={filter.label || filter.targetFieldPath}
|
|
55
|
+
>
|
|
56
|
+
<SelectValue placeholder={filter.attributes?.placeholder || "Select..."} />
|
|
57
|
+
</SelectTrigger>
|
|
58
|
+
<SelectContent>
|
|
59
|
+
{options.map((option) => {
|
|
60
|
+
if (!option || !option.value) return null;
|
|
61
|
+
return (
|
|
62
|
+
<SelectItem key={option.value} value={option.value}>
|
|
63
|
+
{option.label || option.value}
|
|
64
|
+
</SelectItem>
|
|
65
|
+
);
|
|
66
|
+
})}
|
|
67
|
+
</SelectContent>
|
|
68
|
+
</Select>
|
|
69
|
+
{filter.helpMessage && <FieldDescription>{filter.helpMessage}</FieldDescription>}
|
|
70
|
+
</Field>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { FieldGroup } from "../ui/field";
|
|
2
|
+
import { StatusAlert } from "../alerts/status-alert";
|
|
3
|
+
import { CardLayout } from "../layout/card-layout";
|
|
4
|
+
import { SubmitButton } from "./submit-button";
|
|
5
|
+
import { Button } from "../ui/button";
|
|
6
|
+
import { useFormContext } from "../../hooks/form";
|
|
7
|
+
import { useId, useEffect, useRef } from "react";
|
|
8
|
+
|
|
9
|
+
const SUCCESS_AUTO_DISMISS_DELAY = 3000;
|
|
10
|
+
|
|
11
|
+
interface FiltersFormProps extends Omit<React.ComponentProps<"form">, "onSubmit"> {
|
|
12
|
+
title: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
error?: React.ReactNode;
|
|
15
|
+
success?: React.ReactNode;
|
|
16
|
+
submit: {
|
|
17
|
+
text: string;
|
|
18
|
+
loadingText?: string;
|
|
19
|
+
};
|
|
20
|
+
reset?: {
|
|
21
|
+
text: string;
|
|
22
|
+
onReset: () => void;
|
|
23
|
+
};
|
|
24
|
+
onSuccessDismiss?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wrapper component that provides consistent layout and error/success alert positioning
|
|
29
|
+
* for all filter forms.
|
|
30
|
+
*/
|
|
31
|
+
export function FiltersForm({
|
|
32
|
+
id: providedId,
|
|
33
|
+
title,
|
|
34
|
+
description,
|
|
35
|
+
error,
|
|
36
|
+
success,
|
|
37
|
+
children,
|
|
38
|
+
submit,
|
|
39
|
+
reset,
|
|
40
|
+
onSuccessDismiss,
|
|
41
|
+
...props
|
|
42
|
+
}: FiltersFormProps) {
|
|
43
|
+
const form = useFormContext();
|
|
44
|
+
const generatedId = useId();
|
|
45
|
+
const id = providedId ?? generatedId;
|
|
46
|
+
|
|
47
|
+
const isSubmittingSelector = (state: { isSubmitting: boolean }) => state.isSubmitting;
|
|
48
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (timeoutRef.current) {
|
|
52
|
+
clearTimeout(timeoutRef.current);
|
|
53
|
+
timeoutRef.current = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (success && onSuccessDismiss) {
|
|
57
|
+
timeoutRef.current = setTimeout(() => {
|
|
58
|
+
onSuccessDismiss();
|
|
59
|
+
timeoutRef.current = null;
|
|
60
|
+
}, SUCCESS_AUTO_DISMISS_DELAY);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
if (timeoutRef.current) {
|
|
65
|
+
clearTimeout(timeoutRef.current);
|
|
66
|
+
timeoutRef.current = null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}, [success, onSuccessDismiss]);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<CardLayout title={title} description={description}>
|
|
73
|
+
<div className="space-y-6">
|
|
74
|
+
{error && <StatusAlert variant="error">{error}</StatusAlert>}
|
|
75
|
+
{success && <StatusAlert variant="success">{success}</StatusAlert>}
|
|
76
|
+
|
|
77
|
+
<form
|
|
78
|
+
id={id}
|
|
79
|
+
onSubmit={(e) => {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
e.stopPropagation();
|
|
82
|
+
form.handleSubmit();
|
|
83
|
+
}}
|
|
84
|
+
{...props}
|
|
85
|
+
>
|
|
86
|
+
<FieldGroup>{children}</FieldGroup>
|
|
87
|
+
<div className="flex flex-col sm:flex-row gap-2 pt-4">
|
|
88
|
+
<SubmitButton
|
|
89
|
+
form={id}
|
|
90
|
+
label={submit.text}
|
|
91
|
+
loadingLabel={submit.loadingText}
|
|
92
|
+
className="flex-1"
|
|
93
|
+
/>
|
|
94
|
+
{reset && (
|
|
95
|
+
<form.Subscribe selector={isSubmittingSelector}>
|
|
96
|
+
{(isSubmitting: boolean) => (
|
|
97
|
+
<Button
|
|
98
|
+
type="button"
|
|
99
|
+
variant="outline"
|
|
100
|
+
onClick={reset.onReset}
|
|
101
|
+
className="flex-1"
|
|
102
|
+
disabled={isSubmitting}
|
|
103
|
+
>
|
|
104
|
+
{reset.text}
|
|
105
|
+
</Button>
|
|
106
|
+
)}
|
|
107
|
+
</form.Subscribe>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</form>
|
|
111
|
+
</div>
|
|
112
|
+
</CardLayout>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Button } from "../ui/button";
|
|
2
|
+
import { Spinner } from "../ui/spinner";
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
import { useFormContext } from "../../hooks/form";
|
|
5
|
+
|
|
6
|
+
interface SubmitButtonProps extends Omit<React.ComponentProps<typeof Button>, "type" | "disabled"> {
|
|
7
|
+
/** Button text when not submitting */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Button text while submitting */
|
|
10
|
+
loadingLabel?: string;
|
|
11
|
+
/** Form id to associate with (for buttons outside form element) */
|
|
12
|
+
form?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const isSubmittingSelector = (state: { isSubmitting: boolean }) => state.isSubmitting;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Submit button that subscribes to form submission state.
|
|
19
|
+
* Disables interaction during submission and provides visual feedback.
|
|
20
|
+
*/
|
|
21
|
+
export function SubmitButton({
|
|
22
|
+
label,
|
|
23
|
+
loadingLabel = "Applying…",
|
|
24
|
+
className,
|
|
25
|
+
form: formId,
|
|
26
|
+
...props
|
|
27
|
+
}: SubmitButtonProps) {
|
|
28
|
+
const form = useFormContext();
|
|
29
|
+
return (
|
|
30
|
+
<form.Subscribe selector={isSubmittingSelector}>
|
|
31
|
+
{(isSubmitting: boolean) => (
|
|
32
|
+
<Button
|
|
33
|
+
type="submit"
|
|
34
|
+
form={formId}
|
|
35
|
+
className={cn("w-full", className)}
|
|
36
|
+
disabled={isSubmitting}
|
|
37
|
+
aria-label={isSubmitting ? loadingLabel : label}
|
|
38
|
+
aria-busy={isSubmitting}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
{isSubmitting && <Spinner className="mr-2" aria-hidden="true" />}
|
|
42
|
+
<span aria-live="polite">{isSubmitting ? loadingLabel : label}</span>
|
|
43
|
+
</Button>
|
|
44
|
+
)}
|
|
45
|
+
</form.Subscribe>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
|
2
|
+
|
|
3
|
+
interface CardLayoutProps {
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function CardLayout({ title, description, children }: CardLayoutProps) {
|
|
10
|
+
return (
|
|
11
|
+
<Card className="w-full">
|
|
12
|
+
<CardHeader>
|
|
13
|
+
<CardTitle className="text-2xl">{title}</CardTitle>
|
|
14
|
+
{description && <CardDescription>{description}</CardDescription>}
|
|
15
|
+
</CardHeader>
|
|
16
|
+
<CardContent>{children}</CardContent>
|
|
17
|
+
</Card>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResultCardFields Component
|
|
3
|
+
*
|
|
4
|
+
* Displays secondary fields (up to 3) for a search result card.
|
|
5
|
+
* Excludes the primary field and handles nested field values.
|
|
6
|
+
*
|
|
7
|
+
* @param record - The search result record data
|
|
8
|
+
* @param columns - Array of column definitions
|
|
9
|
+
* @param excludeFieldApiName - Field API name to exclude (usually the primary field)
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* - Displays up to 3 secondary fields
|
|
13
|
+
* - Handles nested field paths (e.g., "Owner.Alias")
|
|
14
|
+
* - Skips fields with null/undefined/empty values
|
|
15
|
+
* - Responsive layout (vertical on mobile, horizontal on desktop)
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <ResultCardFields
|
|
20
|
+
* record={searchResult}
|
|
21
|
+
* columns={columns}
|
|
22
|
+
* excludeFieldApiName="Name"
|
|
23
|
+
* />
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
import type { Column, SearchResultRecordData } from "../../types/search/searchResults";
|
|
27
|
+
import { getNestedFieldValue } from "../../utils/fieldUtils";
|
|
28
|
+
|
|
29
|
+
interface ResultCardFieldsProps {
|
|
30
|
+
record: SearchResultRecordData;
|
|
31
|
+
columns: Column[];
|
|
32
|
+
excludeFieldApiName?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function ResultCardFields({
|
|
36
|
+
record,
|
|
37
|
+
columns,
|
|
38
|
+
excludeFieldApiName,
|
|
39
|
+
}: ResultCardFieldsProps) {
|
|
40
|
+
const secondaryFields = columns.filter(
|
|
41
|
+
(col) => col && col.fieldApiName && col.fieldApiName !== excludeFieldApiName,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<dl className="space-y-2" aria-label="Additional record information">
|
|
46
|
+
{secondaryFields.map((column) => {
|
|
47
|
+
if (!column || !column.fieldApiName) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const displayValue = getNestedFieldValue(record.fields, column.fieldApiName);
|
|
52
|
+
|
|
53
|
+
if (displayValue === null || displayValue === undefined || displayValue === "") {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
key={column.fieldApiName}
|
|
60
|
+
className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2"
|
|
61
|
+
>
|
|
62
|
+
<dt className="text-sm font-medium text-muted-foreground min-w-[100px]">
|
|
63
|
+
{column.label || column.fieldApiName}:
|
|
64
|
+
</dt>
|
|
65
|
+
<dd className="text-sm text-foreground">{displayValue}</dd>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</dl>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearchHeader Component
|
|
3
|
+
*
|
|
4
|
+
* Displays the header for search results or browse-all (same UI).
|
|
5
|
+
* labelPlural comes from object metadata (e.g. useObjectInfoBatch) so it is not hard-coded.
|
|
6
|
+
*/
|
|
7
|
+
interface SearchHeaderProps {
|
|
8
|
+
query?: string;
|
|
9
|
+
isBrowseAll?: boolean;
|
|
10
|
+
/** Plural label for the primary object (e.g. "Accounts"). From object metadata. */
|
|
11
|
+
labelPlural?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function SearchHeader({
|
|
15
|
+
query,
|
|
16
|
+
isBrowseAll,
|
|
17
|
+
labelPlural = "records",
|
|
18
|
+
}: SearchHeaderProps) {
|
|
19
|
+
return (
|
|
20
|
+
<header className="mb-6" aria-label="Search results header">
|
|
21
|
+
<h1 className="text-3xl font-bold mb-2">
|
|
22
|
+
{isBrowseAll ? `Browse All ${labelPlural}` : "Search Results"}
|
|
23
|
+
</h1>
|
|
24
|
+
{!isBrowseAll && query && (
|
|
25
|
+
<p className="text-lg" aria-live="polite">
|
|
26
|
+
Results for: <span className="font-semibold">"{query}"</span>
|
|
27
|
+
</p>
|
|
28
|
+
)}
|
|
29
|
+
</header>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearchPagination Component
|
|
3
|
+
*
|
|
4
|
+
* Displays pagination controls for search results.
|
|
5
|
+
* Previous/Next are disabled using hasPreviousPage/hasNextPage from the API so no request is made when there is no page.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* - Layout: page size selector on the left, prev/page/next controls on the right (corners).
|
|
9
|
+
* - Previous disabled when !hasPreviousPage (cursor stack enables prev when pageIndex > 0).
|
|
10
|
+
* - Next disabled when !hasNextPage or nextPageToken is null.
|
|
11
|
+
*/
|
|
12
|
+
import { Button } from "../ui/button";
|
|
13
|
+
import {
|
|
14
|
+
Select,
|
|
15
|
+
SelectContent,
|
|
16
|
+
SelectItem,
|
|
17
|
+
SelectTrigger,
|
|
18
|
+
SelectValue,
|
|
19
|
+
} from "../ui/select";
|
|
20
|
+
import { Label } from "../ui/label";
|
|
21
|
+
import { PAGE_SIZE_OPTIONS, getValidPageSize, isValidPageSize } from "../../utils/paginationUtils";
|
|
22
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
23
|
+
|
|
24
|
+
interface SearchPaginationProps {
|
|
25
|
+
currentPageToken: string;
|
|
26
|
+
nextPageToken: string | null;
|
|
27
|
+
previousPageToken: string | null;
|
|
28
|
+
hasNextPage?: boolean;
|
|
29
|
+
hasPreviousPage?: boolean;
|
|
30
|
+
pageSize: number;
|
|
31
|
+
/** direction: 'prev' | 'next' | 'first'. When 'first' (e.g. page size change), parent typically resets pagination; token may be '' for prev when going to page 0. */
|
|
32
|
+
onPageChange: (newPageToken: string, direction?: "next" | "prev" | "first") => void;
|
|
33
|
+
onPageSizeChange: (newPageSize: number) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function SearchPagination({
|
|
37
|
+
currentPageToken,
|
|
38
|
+
nextPageToken,
|
|
39
|
+
previousPageToken,
|
|
40
|
+
hasNextPage = false,
|
|
41
|
+
hasPreviousPage = false,
|
|
42
|
+
pageSize,
|
|
43
|
+
onPageChange,
|
|
44
|
+
onPageSizeChange,
|
|
45
|
+
}: SearchPaginationProps) {
|
|
46
|
+
const validPageSize = getValidPageSize(pageSize);
|
|
47
|
+
|
|
48
|
+
const currentPageTokenNum = parseInt(currentPageToken, 10) || 0;
|
|
49
|
+
const currentPage = currentPageTokenNum + 1;
|
|
50
|
+
|
|
51
|
+
const canGoPrevious = Boolean(hasPreviousPage);
|
|
52
|
+
const canGoNext = Boolean(hasNextPage && nextPageToken != null);
|
|
53
|
+
|
|
54
|
+
const handlePrevious = () => {
|
|
55
|
+
if (canGoPrevious) {
|
|
56
|
+
onPageChange(previousPageToken ?? "", "prev");
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleNext = () => {
|
|
61
|
+
if (canGoNext && nextPageToken != null) {
|
|
62
|
+
onPageChange(nextPageToken, "next");
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handlePageSizeChange = (newPageSize: string) => {
|
|
67
|
+
const newSize = parseInt(newPageSize, 10);
|
|
68
|
+
if (!isNaN(newSize) && isValidPageSize(newSize) && newSize !== validPageSize) {
|
|
69
|
+
onPageSizeChange(newSize);
|
|
70
|
+
onPageChange("0", "first");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<nav
|
|
76
|
+
className="w-full flex flex-row flex-wrap items-center justify-between gap-4 py-2"
|
|
77
|
+
aria-label="Search results pagination"
|
|
78
|
+
>
|
|
79
|
+
<div
|
|
80
|
+
className="flex items-center gap-2 shrink-0"
|
|
81
|
+
role="group"
|
|
82
|
+
aria-label="Page size selector"
|
|
83
|
+
>
|
|
84
|
+
<Label htmlFor="page-size-select" className="text-sm font-normal whitespace-nowrap">
|
|
85
|
+
Results per page:
|
|
86
|
+
</Label>
|
|
87
|
+
<Select value={validPageSize.toString()} onValueChange={handlePageSizeChange}>
|
|
88
|
+
<SelectTrigger
|
|
89
|
+
id="page-size-select"
|
|
90
|
+
className="w-[70px]"
|
|
91
|
+
aria-label="Select number of results per page"
|
|
92
|
+
>
|
|
93
|
+
<SelectValue />
|
|
94
|
+
</SelectTrigger>
|
|
95
|
+
<SelectContent>
|
|
96
|
+
{PAGE_SIZE_OPTIONS.map((option) => (
|
|
97
|
+
<SelectItem key={option.value} value={option.value}>
|
|
98
|
+
{option.label}
|
|
99
|
+
</SelectItem>
|
|
100
|
+
))}
|
|
101
|
+
</SelectContent>
|
|
102
|
+
</Select>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className="flex items-center gap-1 shrink-0" role="group" aria-label="Page navigation">
|
|
106
|
+
<Button
|
|
107
|
+
type="button"
|
|
108
|
+
variant="outline"
|
|
109
|
+
size="sm"
|
|
110
|
+
disabled={!canGoPrevious}
|
|
111
|
+
onClick={handlePrevious}
|
|
112
|
+
aria-label={
|
|
113
|
+
canGoPrevious
|
|
114
|
+
? `Go to previous page (Page ${currentPage - 1})`
|
|
115
|
+
: "Previous page (disabled)"
|
|
116
|
+
}
|
|
117
|
+
>
|
|
118
|
+
<ChevronLeft className="size-4" aria-hidden />
|
|
119
|
+
Previous
|
|
120
|
+
</Button>
|
|
121
|
+
<span
|
|
122
|
+
className="min-w-[4rem] text-center text-sm text-muted-foreground px-2"
|
|
123
|
+
aria-label={`Page ${currentPage}, current page`}
|
|
124
|
+
aria-current="page"
|
|
125
|
+
>
|
|
126
|
+
Page {currentPage}
|
|
127
|
+
</span>
|
|
128
|
+
<Button
|
|
129
|
+
type="button"
|
|
130
|
+
variant="outline"
|
|
131
|
+
size="sm"
|
|
132
|
+
disabled={!canGoNext}
|
|
133
|
+
onClick={handleNext}
|
|
134
|
+
aria-label={
|
|
135
|
+
canGoNext ? `Go to next page (Page ${currentPage + 1})` : "Next page (disabled)"
|
|
136
|
+
}
|
|
137
|
+
>
|
|
138
|
+
Next
|
|
139
|
+
<ChevronRight className="size-4" aria-hidden />
|
|
140
|
+
</Button>
|
|
141
|
+
</div>
|
|
142
|
+
</nav>
|
|
143
|
+
);
|
|
144
|
+
}
|