@salesforce/ui-bundle-template-feature-react-search 11.10.1 → 11.11.1

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 (35) hide show
  1. package/README.md +435 -93
  2. package/dist/CHANGELOG.md +16 -0
  3. package/dist/force-app/main/default/uiBundles/feature-react-search/package.json +4 -4
  4. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
  5. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
  6. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
  7. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
  8. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
  9. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
  10. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
  11. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
  12. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
  13. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
  14. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
  15. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
  16. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
  17. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
  18. package/dist/package-lock.json +2 -2
  19. package/dist/package.json +1 -1
  20. package/package.json +2 -2
  21. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__popover.tsx +40 -0
  22. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
  23. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
  24. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
  25. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
  26. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
  27. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
  28. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
  29. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
  30. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
  31. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
  32. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
  33. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
  34. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
  35. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
@@ -1,7 +1,19 @@
1
+ import { useState } from "react";
1
2
  import { Input } from "../../../../../components/ui/input";
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from "../../../../../components/ui/select";
2
10
  import { useFilterField } from "../FilterContext";
3
11
  import { FilterFieldWrapper } from "./FilterFieldWrapper";
4
- import type { FilterFieldType } from "../../../utils/filterUtils";
12
+ import type {
13
+ ActiveFilterValue,
14
+ DateFilterMode,
15
+ FilterFieldType,
16
+ } from "../../../utils/filterUtils";
5
17
 
6
18
  interface DateRangeFilterProps {
7
19
  field: string;
@@ -9,41 +21,139 @@ interface DateRangeFilterProps {
9
21
  helpText?: string;
10
22
  /** "daterange" (Date scalar) or "datetimerange" (DateTime scalar). */
11
23
  filterType?: Extract<FilterFieldType, "daterange" | "datetimerange">;
24
+ /**
25
+ * Which comparison chrome to show. See {@link DateFilterMode}. Defaults to
26
+ * `"comparison"` (an After / Before selector with one date input).
27
+ */
28
+ mode?: DateFilterMode;
12
29
  }
13
30
 
14
31
  /**
15
- * Two `<input type="date">` controls bound to `min` / `max` of the active
16
- * filter. Uses the platform-native picker no popover/calendar dependency.
32
+ * The three ways to constrain a date field. They map directly onto the
33
+ * `min`/`max` bounds the query builder already understands:
34
+ * - after → only `min` set → `gte`
35
+ * - before → only `max` set → `lte`
36
+ * - between → both set → `gte` AND `lte`
37
+ */
38
+ type DateOperator = "between" | "after" | "before";
39
+
40
+ const OPERATOR_LABELS: Record<DateOperator, string> = {
41
+ between: "Between",
42
+ after: "After",
43
+ before: "Before",
44
+ };
45
+
46
+ /** The operators offered for each {@link DateFilterMode}. */
47
+ const MODE_OPERATORS: Record<DateFilterMode, DateOperator[]> = {
48
+ range: ["between"],
49
+ comparison: ["after", "before"],
50
+ both: ["between", "after", "before"],
51
+ };
52
+
53
+ /** Pick the operator implied by an already-active filter (e.g. restored from the URL). */
54
+ function operatorFor(value: ActiveFilterValue | undefined): DateOperator {
55
+ const hasMin = !!value?.min;
56
+ const hasMax = !!value?.max;
57
+ if (hasMax && !hasMin) return "before";
58
+ if (hasMin && !hasMax) return "after";
59
+ return "between";
60
+ }
61
+
62
+ /**
63
+ * A date constraint whose chrome is driven by `mode`:
64
+ * - `"range"`: a single Between control (two date inputs).
65
+ * - `"comparison"` (default): an After / Before selector with one input.
66
+ * - `"both"`: a Between / After / Before selector spanning the two.
67
+ *
68
+ * "Between" shows two `<input type="date">` controls; "After" / "Before"
69
+ * collapse to one bound to the relevant end. Uses the platform-native picker —
70
+ * no popover/calendar dependency.
17
71
  */
18
72
  export function DateRangeFilter({
19
73
  field,
20
74
  label,
21
75
  helpText,
22
76
  filterType = "daterange",
77
+ mode = "comparison",
23
78
  }: DateRangeFilterProps) {
24
79
  const { value, onChange } = useFilterField(field);
80
+ const operators = MODE_OPERATORS[mode];
81
+
82
+ // Operator is local UI state: it must survive a moment where no date is yet
83
+ // entered (which clears the active filter), so it can't be derived from
84
+ // `value` on every render. Seed it from any filter restored on mount, clamped
85
+ // to an operator this mode actually offers.
86
+ const [operator, setOperator] = useState<DateOperator>(() => {
87
+ const restored = operatorFor(value);
88
+ return operators.includes(restored) ? restored : operators[0];
89
+ });
25
90
 
26
- const update = (nextMin: string | undefined, nextMax: string | undefined) => {
91
+ const emit = (nextMin: string | undefined, nextMax: string | undefined) => {
27
92
  if (!nextMin && !nextMax) onChange(undefined);
28
93
  else onChange({ field, label, type: filterType, min: nextMin, max: nextMax });
29
94
  };
30
95
 
96
+ const changeOperator = (next: DateOperator) => {
97
+ setOperator(next);
98
+ // Re-emit, keeping only the bound(s) the new operator uses.
99
+ if (next === "after") emit(value?.min, undefined);
100
+ else if (next === "before") emit(undefined, value?.max);
101
+ else emit(value?.min, value?.max);
102
+ };
103
+
104
+ const operatorId = `filter-${field}-op`;
105
+ // A lone "between" operator (mode="range") needs no selector — it can't change.
106
+ const showSelector = operators.length > 1;
107
+
31
108
  return (
32
109
  <FilterFieldWrapper label={label} helpText={helpText}>
33
- <div className="flex items-center gap-2">
34
- <Input
35
- type="date"
36
- value={value?.min ?? ""}
37
- onChange={(e) => update(e.target.value || undefined, value?.max)}
38
- aria-label={`${label} from`}
39
- />
40
- <span className="text-sm text-muted-foreground">–</span>
41
- <Input
42
- type="date"
43
- value={value?.max ?? ""}
44
- onChange={(e) => update(value?.min, e.target.value || undefined)}
45
- aria-label={`${label} to`}
46
- />
110
+ <div className="flex flex-col gap-2">
111
+ {showSelector && (
112
+ <Select value={operator} onValueChange={(v) => changeOperator(v as DateOperator)}>
113
+ <SelectTrigger id={operatorId} className="w-full" aria-label={`${label} comparison`}>
114
+ <SelectValue />
115
+ </SelectTrigger>
116
+ <SelectContent>
117
+ {operators.map((op) => (
118
+ <SelectItem key={op} value={op}>
119
+ {OPERATOR_LABELS[op]}
120
+ </SelectItem>
121
+ ))}
122
+ </SelectContent>
123
+ </Select>
124
+ )}
125
+
126
+ {operator === "between" ? (
127
+ <div className="flex items-center gap-2">
128
+ <Input
129
+ type="date"
130
+ value={value?.min ?? ""}
131
+ onChange={(e) => emit(e.target.value || undefined, value?.max)}
132
+ aria-label={`${label} from`}
133
+ />
134
+ <span className="text-sm text-muted-foreground">–</span>
135
+ <Input
136
+ type="date"
137
+ value={value?.max ?? ""}
138
+ onChange={(e) => emit(value?.min, e.target.value || undefined)}
139
+ aria-label={`${label} to`}
140
+ />
141
+ </div>
142
+ ) : operator === "after" ? (
143
+ <Input
144
+ type="date"
145
+ value={value?.min ?? ""}
146
+ onChange={(e) => emit(e.target.value || undefined, undefined)}
147
+ aria-label={`${label} after`}
148
+ />
149
+ ) : (
150
+ <Input
151
+ type="date"
152
+ value={value?.max ?? ""}
153
+ onChange={(e) => emit(undefined, e.target.value || undefined)}
154
+ aria-label={`${label} before`}
155
+ />
156
+ )}
47
157
  </div>
48
158
  </FilterFieldWrapper>
49
159
  );
@@ -1,5 +1,12 @@
1
+ import { ChevronDown } from "lucide-react";
2
+ import { Button } from "../../../../../components/ui/button";
1
3
  import { Checkbox } from "../../../../../components/ui/checkbox";
2
4
  import { Label } from "../../../../../components/ui/label";
5
+ import {
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverTrigger,
9
+ } from "../../../../../components/ui/popover";
3
10
  import { useFilterField } from "../FilterContext";
4
11
  import { FilterFieldWrapper } from "./FilterFieldWrapper";
5
12
 
@@ -23,25 +30,59 @@ export function MultiSelectFilter({ field, label, options, helpText }: MultiSele
23
30
  else onChange({ field, label, type: "multipicklist", value: joined });
24
31
  };
25
32
 
33
+ // Summarize the selection on the collapsed trigger: the sole label when one is
34
+ // picked, a count when several are, and a placeholder when none.
35
+ const summary =
36
+ selected.size === 0
37
+ ? `Select ${label.toLowerCase()}`
38
+ : selected.size === 1
39
+ ? (options.find((o) => selected.has(o.value))?.label ?? `${selected.size} selected`)
40
+ : `${selected.size} selected`;
41
+ const triggerId = `filter-${field}`;
42
+
26
43
  return (
27
- <FilterFieldWrapper label={label} helpText={helpText}>
28
- <div className="flex flex-col gap-1">
29
- {options.map((opt) => {
30
- const id = `filter-${field}-${opt.value}`;
31
- return (
32
- <div key={opt.value} className="flex items-center gap-2">
33
- <Checkbox
34
- id={id}
35
- checked={selected.has(opt.value)}
36
- onCheckedChange={(checked) => toggle(opt.value, checked === true)}
37
- />
38
- <Label htmlFor={id} className="text-sm font-normal cursor-pointer">
39
- {opt.label}
40
- </Label>
41
- </div>
42
- );
43
- })}
44
- </div>
44
+ <FilterFieldWrapper label={label} htmlFor={triggerId} helpText={helpText}>
45
+ <Popover>
46
+ <PopoverTrigger asChild>
47
+ <Button
48
+ id={triggerId}
49
+ variant="outline"
50
+ className="w-full justify-between font-normal"
51
+ aria-label={`${label}: ${summary}`}
52
+ >
53
+ <span className={`truncate ${selected.size === 0 ? "text-muted-foreground" : ""}`}>
54
+ {summary}
55
+ </span>
56
+ <ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden="true" />
57
+ </Button>
58
+ </PopoverTrigger>
59
+ <PopoverContent
60
+ align="start"
61
+ className="w-(--radix-popover-trigger-width) max-h-72 overflow-y-auto"
62
+ >
63
+ <div role="group" aria-label={label} className="flex flex-col gap-1">
64
+ {options.length === 0 ? (
65
+ <p className="px-1 py-2 text-sm text-muted-foreground">No options available.</p>
66
+ ) : (
67
+ options.map((opt) => {
68
+ const id = `filter-${field}-${opt.value}`;
69
+ return (
70
+ <div key={opt.value} className="flex items-center gap-2">
71
+ <Checkbox
72
+ id={id}
73
+ checked={selected.has(opt.value)}
74
+ onCheckedChange={(checked) => toggle(opt.value, checked === true)}
75
+ />
76
+ <Label htmlFor={id} className="text-sm font-normal cursor-pointer">
77
+ {opt.label}
78
+ </Label>
79
+ </div>
80
+ );
81
+ })
82
+ )}
83
+ </div>
84
+ </PopoverContent>
85
+ </Popover>
45
86
  </FilterFieldWrapper>
46
87
  );
47
88
  }
@@ -1,10 +1,15 @@
1
1
  {
2
+ "pagination": {
3
+ "pageSize": 10,
4
+ "pageSizeOptions": [5, 10, 20]
5
+ },
2
6
  "sources": [
3
7
  {
4
8
  "kind": "sobject",
5
9
  "key": "accounts",
6
10
  "objectName": "Account",
7
11
  "label": "Accounts",
12
+ "labelSingular": "Account",
8
13
  "routePattern": "/accounts/:id",
9
14
  "searchableFields": ["Name", "Phone", "Industry"],
10
15
  "displayFields": [
@@ -15,25 +20,24 @@
15
20
  "AnnualRevenue",
16
21
  { "name": "Owner", "subfields": ["Name"] }
17
22
  ],
18
- "filterFields": [
23
+ "filterBy": [
19
24
  { "field": "Industry", "label": "Industry", "type": "picklist" },
20
25
  { "field": "Type", "label": "Type", "type": "picklist" },
21
26
  { "field": "AnnualRevenue", "label": "Annual Revenue", "type": "numeric" }
22
27
  ],
23
- "sortFields": [
28
+ "sortBy": [
24
29
  { "field": "Name", "label": "Name" },
25
30
  { "field": "Industry", "label": "Industry" },
26
31
  { "field": "AnnualRevenue", "label": "Annual Revenue" },
27
32
  { "field": "CreatedDate", "label": "Created Date" }
28
- ],
29
- "pageSize": 10,
30
- "validPageSizes": [5, 10, 20]
33
+ ]
31
34
  },
32
35
  {
33
36
  "kind": "sobject",
34
37
  "key": "contacts",
35
38
  "objectName": "Contact",
36
39
  "label": "Contacts",
40
+ "labelSingular": "Contact",
37
41
  "routePattern": "/contacts/:id",
38
42
  "searchableFields": ["Name", "Email", "Phone"],
39
43
  "displayFields": [
@@ -43,18 +47,17 @@
43
47
  "Phone",
44
48
  { "name": "Account", "subfields": ["Name"] }
45
49
  ],
46
- "sortFields": [
50
+ "sortBy": [
47
51
  { "field": "Name", "label": "Name" },
48
52
  { "field": "CreatedDate", "label": "Created Date" }
49
- ],
50
- "pageSize": 10,
51
- "validPageSizes": [5, 10, 20]
53
+ ]
52
54
  },
53
55
  {
54
56
  "kind": "sobject",
55
57
  "key": "opportunities",
56
58
  "objectName": "Opportunity",
57
59
  "label": "Opportunities",
60
+ "labelSingular": "Opportunity",
58
61
  "routePattern": "/opportunities/:id",
59
62
  "searchableFields": ["Name"],
60
63
  "displayFields": [
@@ -64,19 +67,17 @@
64
67
  "CloseDate",
65
68
  { "name": "Account", "subfields": ["Name"] }
66
69
  ],
67
- "filterFields": [
70
+ "filterBy": [
68
71
  { "field": "StageName", "label": "Stage", "type": "picklist" },
69
72
  { "field": "Amount", "label": "Amount", "type": "numeric" },
70
73
  { "field": "CloseDate", "label": "Close Date", "type": "daterange" }
71
74
  ],
72
- "sortFields": [
75
+ "sortBy": [
73
76
  { "field": "CloseDate", "label": "Close Date" },
74
77
  { "field": "Amount", "label": "Amount" },
75
78
  { "field": "Name", "label": "Name" }
76
79
  ],
77
- "defaultSort": { "field": "CloseDate", "direction": "DESC" },
78
- "pageSize": 10,
79
- "validPageSizes": [5, 10, 20]
80
+ "defaultSort": { "field": "CloseDate", "direction": "DESC" }
80
81
  }
81
82
  ]
82
83
  }