@12-apps/discounts 1.3.0 → 1.5.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.
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import type { CategorySelectCopy } from "@12-apps/ui/copy";
4
+ import { useState, type JSX } from "react";
5
+
6
+ import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
7
+ import { CategorySelect } from "@12-apps/ui/form/CategorySelect";
8
+ import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
9
+
10
+ import type { WireTarget, WireTargetGroup } from "./api";
11
+
12
+ /**
13
+ * One collection's picker, in the two shapes a collection can take.
14
+ *
15
+ * Its own module because BOTH callers need it and they are not each other's
16
+ * parent: the scope-driven block renders one per discount, and the combo
17
+ * builder renders one per collection per GROUP. Importing it from either would
18
+ * make the two files a cycle, and copying it is how a nesting collection ends
19
+ * up a flat combobox in one of the two places — the exact bug this seam exists
20
+ * to prevent.
21
+ *
22
+ * There is no shared multi-select in `@12-apps/ui`, so the flat case composes
23
+ * `Autocomplete` in multiple mode — chips plus type-to-filter, the same control
24
+ * `AddressAutocomplete` is built on. A collection that NESTS gets
25
+ * `CategorySelect` instead: a flat combobox would put a subcategory beside its
26
+ * parent with nothing to tell them apart.
27
+ */
28
+
29
+ /** One labelled multi-select of targets, with its own input-text state. */
30
+ function TargetField({
31
+ label,
32
+ placeholder,
33
+ requiredMessage,
34
+ options,
35
+ ids,
36
+ onChange,
37
+ dataTestId,
38
+ }: {
39
+ label: string;
40
+ placeholder: string;
41
+ requiredMessage: string | undefined;
42
+ options: readonly WireTarget[];
43
+ ids: readonly string[];
44
+ onChange: (next: string[]) => void;
45
+ dataTestId: string;
46
+ }): JSX.Element {
47
+ const [query, setQuery] = useState("");
48
+ const byId = new Map(options.map((option) => [option.id, option]));
49
+ // An id whose option is missing — a target archived since the rule was saved
50
+ // — still renders as a chip, so saving cannot silently drop it.
51
+ const selected = ids.map((id) => byId.get(id) ?? { id, name: id });
52
+
53
+ const missing = requiredMessage !== undefined && ids.length === 0;
54
+
55
+ return (
56
+ <FormControl fullWidth>
57
+ <FormLabel error={missing}>{label}</FormLabel>
58
+ <div data-testid={dataTestId}>
59
+ <Autocomplete<WireTarget>
60
+ multiple
61
+ value={query}
62
+ onChange={setQuery}
63
+ suggestions={[...options]}
64
+ selectedItems={selected}
65
+ onSelectedItemsChange={(items) => onChange(items.map((item) => item.id))}
66
+ getKey={(option) => option.id}
67
+ getLabel={(option) => option.name}
68
+ placeholder={placeholder}
69
+ inputAriaLabel={label}
70
+ />
71
+ </div>
72
+ {missing && (
73
+ <FormMessage error dataTestId={`${dataTestId}-message`}>
74
+ {requiredMessage}
75
+ </FormMessage>
76
+ )}
77
+ </FormControl>
78
+ );
79
+ }
80
+
81
+ /**
82
+ * One collection's picker, for whoever is asking.
83
+ *
84
+ * Extracted because the COMBO builder needs the same control per GROUP, and a
85
+ * second copy is how a nesting collection ends up rendered as a flat combobox
86
+ * in one of the two places — the bug this seam exists to prevent. The caller
87
+ * supplies the label and the test id, because "Categorias com desconto" and
88
+ * "Categorias deste grupo" are different sentences about the same control.
89
+ *
90
+ * `requiredMessage` is OPTIONAL, and that is the difference between the two
91
+ * callers rather than an oversight. At CATEGORY/ITEM scope an empty picker IS
92
+ * the error, so the message hangs on it. Inside a combo group it is not: a
93
+ * group naming two categories and no items is complete, so the group as a whole
94
+ * carries the refusal and neither picker does.
95
+ */
96
+ export function TargetPickerField({
97
+ group,
98
+ label,
99
+ placeholder,
100
+ requiredMessage,
101
+ ids,
102
+ onChange,
103
+ dataTestId,
104
+ copy,
105
+ }: {
106
+ group: WireTargetGroup;
107
+ label: string;
108
+ placeholder: string;
109
+ requiredMessage: string | undefined;
110
+ ids: readonly string[];
111
+ onChange: (next: string[]) => void;
112
+ dataTestId: string;
113
+ /** `CategorySelect`'s own words — required since FUT-760, never defaulted. */
114
+ copy: CategorySelectCopy;
115
+ }): JSX.Element {
116
+ if (group.nests) {
117
+ return (
118
+ <CategorySelect
119
+ label={label}
120
+ fullWidth
121
+ allowParentSelection
122
+ options={[...group.targets]}
123
+ value={[...ids]}
124
+ onChange={onChange}
125
+ error={requiredMessage !== undefined && ids.length === 0 ? requiredMessage : undefined}
126
+ copy={copy}
127
+ dataTestId={dataTestId}
128
+ />
129
+ );
130
+ }
131
+ return (
132
+ <TargetField
133
+ label={label}
134
+ placeholder={placeholder}
135
+ requiredMessage={requiredMessage}
136
+ options={group.targets}
137
+ ids={ids}
138
+ onChange={onChange}
139
+ dataTestId={dataTestId}
140
+ />
141
+ );
142
+ }
143
+