@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.
Files changed (73) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/package-lock.json +15 -15
  3. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/index.ts +19 -0
  4. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/objectDetailService.ts +125 -0
  5. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/objectInfoGraphQLService.ts +194 -0
  6. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/objectInfoService.ts +199 -0
  7. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/api/recordListGraphQLService.ts +365 -0
  8. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/FiltersPanel.tsx +375 -0
  9. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/LoadingFallback.tsx +61 -0
  10. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/SearchResultCard.tsx +131 -0
  11. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/alerts/status-alert.tsx +45 -0
  12. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/DetailFields.tsx +55 -0
  13. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/DetailForm.tsx +146 -0
  14. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/DetailHeader.tsx +34 -0
  15. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/DetailLayoutSections.tsx +80 -0
  16. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/Section.tsx +108 -0
  17. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/SectionRow.tsx +20 -0
  18. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/UiApiDetailForm.tsx +140 -0
  19. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FieldValueDisplay.tsx +73 -0
  20. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FormattedAddress.tsx +29 -0
  21. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FormattedEmail.tsx +17 -0
  22. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FormattedPhone.tsx +24 -0
  23. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FormattedText.tsx +11 -0
  24. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/FormattedUrl.tsx +29 -0
  25. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/detail/formatted/index.ts +6 -0
  26. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/filters/FilterField.tsx +54 -0
  27. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/filters/FilterInput.tsx +55 -0
  28. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/filters/FilterSelect.tsx +72 -0
  29. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/forms/filters-form.tsx +114 -0
  30. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/forms/submit-button.tsx +47 -0
  31. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/layout/card-layout.tsx +19 -0
  32. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/search/ResultCardFields.tsx +71 -0
  33. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/search/SearchHeader.tsx +31 -0
  34. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/search/SearchPagination.tsx +144 -0
  35. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/search/SearchResultsPanel.tsx +197 -0
  36. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/components/shared/GlobalSearchInput.tsx +114 -0
  37. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/constants.ts +39 -0
  38. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/features/global-search/index.ts +33 -0
  39. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/form.tsx +208 -0
  40. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/index.ts +22 -0
  41. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/useObjectInfoBatch.ts +65 -0
  42. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/useObjectSearchData.ts +380 -0
  43. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/useRecordDetailLayout.ts +156 -0
  44. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/hooks/useRecordListGraphQL.ts +135 -0
  45. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/pages/DetailPage.tsx +109 -0
  46. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/pages/GlobalSearch.tsx +229 -0
  47. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/pages/Home.tsx +11 -10
  48. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/routes.tsx +23 -1
  49. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/filters/filters.ts +120 -0
  50. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/filters/picklist.ts +32 -0
  51. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/index.ts +4 -0
  52. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/objectInfo/objectInfo.ts +166 -0
  53. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/recordDetail/recordDetail.ts +61 -0
  54. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/types/search/searchResults.ts +229 -0
  55. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/apiUtils.ts +125 -0
  56. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/cacheUtils.ts +76 -0
  57. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/debounce.ts +89 -0
  58. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/fieldUtils.ts +354 -0
  59. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/fieldValueExtractor.ts +67 -0
  60. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/filterUtils.ts +32 -0
  61. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/formDataTransformUtils.ts +260 -0
  62. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/formUtils.ts +142 -0
  63. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/graphQLNodeFieldUtils.ts +186 -0
  64. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/graphQLObjectInfoAdapter.ts +319 -0
  65. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/graphQLRecordAdapter.ts +90 -0
  66. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/index.ts +59 -0
  67. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/layoutTransformUtils.ts +236 -0
  68. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/linkUtils.ts +14 -0
  69. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/paginationUtils.ts +49 -0
  70. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/recordUtils.ts +159 -0
  71. package/dist/force-app/main/default/webapplications/appreacttemplateb2x/src/utils/sanitizationUtils.ts +49 -0
  72. package/dist/package.json +1 -1
  73. 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
+ }