@carlonicora/nextjs-jsonapi 1.74.0 → 1.75.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/{BlockNoteEditor-KJZ7FGBA.mjs → BlockNoteEditor-NJMTHPO4.mjs} +2 -2
- package/dist/{BlockNoteEditor-A37P3FA7.js → BlockNoteEditor-SLT4VOLL.js} +6 -6
- package/dist/{BlockNoteEditor-A37P3FA7.js.map → BlockNoteEditor-SLT4VOLL.js.map} +1 -1
- package/dist/billing/index.js +299 -299
- package/dist/billing/index.mjs +1 -1
- package/dist/{chunk-XUTMY6K5.js → chunk-DTE6RZXF.js} +606 -526
- package/dist/chunk-DTE6RZXF.js.map +1 -0
- package/dist/{chunk-ZNODEBMI.mjs → chunk-Q7JKB777.mjs} +2380 -2300
- package/dist/chunk-Q7JKB777.mjs.map +1 -0
- package/dist/client/index.js +2 -2
- package/dist/client/index.mjs +1 -1
- package/dist/components/index.d.mts +29 -3
- package/dist/components/index.d.ts +29 -3
- package/dist/components/index.js +4 -2
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +3 -1
- package/dist/contexts/index.js +2 -2
- package/dist/contexts/index.mjs +1 -1
- package/dist/scripts/generate-web-module/templates/components/multi-selector.template.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/multi-selector.template.js +14 -120
- package/dist/scripts/generate-web-module/templates/components/multi-selector.template.js.map +1 -1
- package/package.json +1 -1
- package/scripts/generate-web-module/templates/components/multi-selector.template.ts +14 -120
- package/src/components/forms/EntityMultiSelector.tsx +325 -0
- package/src/components/forms/index.ts +1 -0
- package/src/features/how-to/components/forms/HowToMultiSelector.tsx +14 -120
- package/src/features/user/components/forms/UserMultiSelect.tsx +34 -181
- package/dist/chunk-XUTMY6K5.js.map +0 -1
- package/dist/chunk-ZNODEBMI.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-KJZ7FGBA.mjs.map → BlockNoteEditor-NJMTHPO4.mjs.map} +0 -0
package/package.json
CHANGED
|
@@ -22,18 +22,9 @@ export function generateMultiSelectorTemplate(data: FrontendTemplateData): strin
|
|
|
22
22
|
|
|
23
23
|
import { ${names.pascalCase}Interface } from "@/features/${data.importTargetDir}/${names.kebabCase}/data/${names.pascalCase}Interface";
|
|
24
24
|
import { ${names.pascalCase}Service } from "@/features/${data.importTargetDir}/${names.kebabCase}/data/${names.pascalCase}Service";
|
|
25
|
-
import {
|
|
26
|
-
import { FormFieldWrapper, MultipleSelector } from "@carlonicora/nextjs-jsonapi/components";
|
|
27
|
-
import { Option } from "@carlonicora/nextjs-jsonapi/components";
|
|
25
|
+
import { EntityMultiSelector } from "@carlonicora/nextjs-jsonapi/components";
|
|
28
26
|
import { Modules } from "@carlonicora/nextjs-jsonapi/core";
|
|
29
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
30
27
|
import { useTranslations } from "next-intl";
|
|
31
|
-
import { useWatch } from "react-hook-form";
|
|
32
|
-
|
|
33
|
-
type ${names.pascalCase}MultiSelectType = {
|
|
34
|
-
id: string;
|
|
35
|
-
${displayProp}: string;
|
|
36
|
-
};
|
|
37
28
|
|
|
38
29
|
type ${names.pascalCase}MultiSelectorProps = {
|
|
39
30
|
id: string;
|
|
@@ -46,10 +37,6 @@ type ${names.pascalCase}MultiSelectorProps = {
|
|
|
46
37
|
isRequired?: boolean;
|
|
47
38
|
};
|
|
48
39
|
|
|
49
|
-
type ${names.pascalCase}Option = Option & {
|
|
50
|
-
${names.camelCase}Data?: ${names.pascalCase}Interface;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
40
|
export default function ${names.pascalCase}MultiSelector({
|
|
54
41
|
id,
|
|
55
42
|
form,
|
|
@@ -57,117 +44,24 @@ export default function ${names.pascalCase}MultiSelector({
|
|
|
57
44
|
label,
|
|
58
45
|
placeholder,
|
|
59
46
|
onChange,
|
|
60
|
-
maxCount = 3,
|
|
61
47
|
isRequired = false,
|
|
62
48
|
}: ${names.pascalCase}MultiSelectorProps) {
|
|
63
49
|
const t = useTranslations();
|
|
64
|
-
const [${names.camelCase}Options, set${names.pascalCase}Options] = useState<${names.pascalCase}Option[]>([]);
|
|
65
|
-
const [searchTerm, setSearchTerm] = useState<string>("");
|
|
66
|
-
|
|
67
|
-
const selected${names.pluralPascal}: ${names.pascalCase}MultiSelectType[] = useWatch({ control: form.control, name: id }) || [];
|
|
68
|
-
|
|
69
|
-
const data: DataListRetriever<${names.pascalCase}Interface> = useDataListRetriever({
|
|
70
|
-
retriever: (params) => ${names.pascalCase}Service.findMany(params),
|
|
71
|
-
retrieverParams: {},
|
|
72
|
-
ready: true,
|
|
73
|
-
module: Modules.${names.pascalCase},
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const updateSearch = useCallback(
|
|
77
|
-
(searchedTerm: string) => {
|
|
78
|
-
if (searchedTerm.trim()) {
|
|
79
|
-
data.addAdditionalParameter("search", searchedTerm.trim());
|
|
80
|
-
} else {
|
|
81
|
-
data.removeAdditionalParameter("search");
|
|
82
|
-
}
|
|
83
|
-
},
|
|
84
|
-
[data]
|
|
85
|
-
);
|
|
86
|
-
|
|
87
|
-
const debouncedUpdateSearch = useDebounce(updateSearch, 500);
|
|
88
|
-
|
|
89
|
-
useEffect(() => {
|
|
90
|
-
debouncedUpdateSearch(searchTerm);
|
|
91
|
-
}, [debouncedUpdateSearch, searchTerm]);
|
|
92
|
-
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
if (data.data && data.data.length > 0) {
|
|
95
|
-
const ${names.pluralCamel} = data.data as ${names.pascalCase}Interface[];
|
|
96
|
-
const filtered${names.pluralPascal} = ${names.pluralCamel}.filter((${names.camelCase}) => ${names.camelCase}.id !== current${names.pascalCase}?.id);
|
|
97
|
-
|
|
98
|
-
const options: ${names.pascalCase}Option[] = filtered${names.pluralPascal}.map((${names.camelCase}) => ({
|
|
99
|
-
label: ${names.camelCase}.${displayProp},
|
|
100
|
-
value: ${names.camelCase}.id,
|
|
101
|
-
${names.camelCase}Data: ${names.camelCase},
|
|
102
|
-
}));
|
|
103
|
-
|
|
104
|
-
// Add options for any already selected that aren't in search results
|
|
105
|
-
const existingOptionIds = new Set(options.map((option) => option.value));
|
|
106
|
-
const missingOptions: ${names.pascalCase}Option[] = selected${names.pluralPascal}
|
|
107
|
-
.filter((${names.camelCase}) => !existingOptionIds.has(${names.camelCase}.id))
|
|
108
|
-
.map((${names.camelCase}) => ({
|
|
109
|
-
label: ${names.camelCase}.${displayProp},
|
|
110
|
-
value: ${names.camelCase}.id,
|
|
111
|
-
${names.camelCase}Data: ${names.camelCase} as unknown as ${names.pascalCase}Interface,
|
|
112
|
-
}));
|
|
113
|
-
|
|
114
|
-
set${names.pascalCase}Options([...options, ...missingOptions]);
|
|
115
|
-
}
|
|
116
|
-
}, [data.data, current${names.pascalCase}, selected${names.pluralPascal}]);
|
|
117
|
-
|
|
118
|
-
// Convert selected to Option[] format
|
|
119
|
-
const selectedOptions = useMemo(() => {
|
|
120
|
-
return selected${names.pluralPascal}.map((${names.camelCase}) => ({
|
|
121
|
-
value: ${names.camelCase}.id,
|
|
122
|
-
label: ${names.camelCase}.${displayProp},
|
|
123
|
-
}));
|
|
124
|
-
}, [selected${names.pluralPascal}]);
|
|
125
|
-
|
|
126
|
-
const handleChange = (options: Option[]) => {
|
|
127
|
-
// Convert to form format
|
|
128
|
-
const formValues = options.map((option) => ({
|
|
129
|
-
id: option.value,
|
|
130
|
-
${displayProp}: option.label,
|
|
131
|
-
}));
|
|
132
|
-
|
|
133
|
-
form.setValue(id, formValues, { shouldDirty: true, shouldTouch: true });
|
|
134
|
-
|
|
135
|
-
if (onChange) {
|
|
136
|
-
// Get full data for onChange callback
|
|
137
|
-
const fullData = options
|
|
138
|
-
.map((option) => {
|
|
139
|
-
const ${names.camelCase}Option = ${names.camelCase}Options.find((opt) => opt.value === option.value);
|
|
140
|
-
return ${names.camelCase}Option?.${names.camelCase}Data;
|
|
141
|
-
})
|
|
142
|
-
.filter(Boolean) as ${names.pascalCase}Interface[];
|
|
143
|
-
onChange(fullData);
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
// Search handler
|
|
148
|
-
const handleSearchSync = (search: string): Option[] => {
|
|
149
|
-
setSearchTerm(search);
|
|
150
|
-
return ${names.camelCase}Options;
|
|
151
|
-
};
|
|
152
50
|
|
|
153
51
|
return (
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
/>
|
|
168
|
-
)}
|
|
169
|
-
</FormFieldWrapper>
|
|
170
|
-
</div>
|
|
52
|
+
<EntityMultiSelector<${names.pascalCase}Interface>
|
|
53
|
+
id={id}
|
|
54
|
+
form={form}
|
|
55
|
+
label={label}
|
|
56
|
+
placeholder={placeholder || t("ui.search.button")}
|
|
57
|
+
emptyText={t("ui.search.no_results_generic")}
|
|
58
|
+
isRequired={isRequired}
|
|
59
|
+
retriever={(params) => ${names.pascalCase}Service.findMany(params)}
|
|
60
|
+
module={Modules.${names.pascalCase}}
|
|
61
|
+
getLabel={(${names.camelCase}) => ${displayProp === "id" ? `${names.camelCase}.id` : `${names.camelCase}.${displayProp}`}}
|
|
62
|
+
excludeId={current${names.pascalCase}?.id}
|
|
63
|
+
onChange={onChange}
|
|
64
|
+
/>
|
|
171
65
|
);
|
|
172
66
|
}
|
|
173
67
|
`;
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { useWatch } from "react-hook-form";
|
|
5
|
+
import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
|
|
6
|
+
import { Badge } from "../../shadcnui/ui/badge";
|
|
7
|
+
import { Input } from "../../shadcnui/ui/input";
|
|
8
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../../shadcnui/ui/popover";
|
|
9
|
+
import { FormFieldWrapper } from "./FormFieldWrapper";
|
|
10
|
+
import { type DataListRetriever, useDataListRetriever } from "../../hooks/useDataListRetriever";
|
|
11
|
+
import { useDebounce } from "../../hooks/useDebounce";
|
|
12
|
+
|
|
13
|
+
type EntityMultiSelectorProps<T extends { id: string }> = {
|
|
14
|
+
id: string;
|
|
15
|
+
form: any;
|
|
16
|
+
label?: string;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
emptyText?: string;
|
|
19
|
+
isRequired?: boolean;
|
|
20
|
+
retriever: (params: any) => Promise<T[]>;
|
|
21
|
+
retrieverParams?: Record<string, any>;
|
|
22
|
+
module: any;
|
|
23
|
+
getLabel: (entity: T) => string;
|
|
24
|
+
toFormValue?: (entity: T) => { id: string; [key: string]: any };
|
|
25
|
+
getFormValueLabel?: (formValue: any) => string;
|
|
26
|
+
excludeId?: string;
|
|
27
|
+
onChange?: (entities?: T[]) => void;
|
|
28
|
+
renderOption?: (entity: T, isSelected: boolean) => ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type OptionData<T> = {
|
|
32
|
+
id: string;
|
|
33
|
+
label: string;
|
|
34
|
+
entityData?: T;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const defaultFormValueLabel = (v: any) => v.name ?? v.id;
|
|
38
|
+
|
|
39
|
+
export function EntityMultiSelector<T extends { id: string }>({
|
|
40
|
+
id,
|
|
41
|
+
form,
|
|
42
|
+
label,
|
|
43
|
+
placeholder = "Search...",
|
|
44
|
+
emptyText = "No results found.",
|
|
45
|
+
isRequired = false,
|
|
46
|
+
retriever,
|
|
47
|
+
retrieverParams = {},
|
|
48
|
+
module,
|
|
49
|
+
getLabel,
|
|
50
|
+
toFormValue,
|
|
51
|
+
getFormValueLabel,
|
|
52
|
+
excludeId,
|
|
53
|
+
onChange,
|
|
54
|
+
renderOption,
|
|
55
|
+
}: EntityMultiSelectorProps<T>) {
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
58
|
+
const [options, setOptions] = useState<OptionData<T>[]>([]);
|
|
59
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
60
|
+
|
|
61
|
+
// Stabilize callback props in refs to prevent infinite re-render loops.
|
|
62
|
+
// These functions are passed inline by consumers (e.g. getLabel={(w) => w.name})
|
|
63
|
+
// which creates new references every render. Using refs keeps effects stable.
|
|
64
|
+
const getLabelRef = useRef(getLabel);
|
|
65
|
+
const toFormValueRef = useRef(toFormValue);
|
|
66
|
+
const getFormValueLabelRef = useRef(getFormValueLabel);
|
|
67
|
+
const onChangeRef = useRef(onChange);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
getLabelRef.current = getLabel;
|
|
70
|
+
}, [getLabel]);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
toFormValueRef.current = toFormValue;
|
|
73
|
+
}, [toFormValue]);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
getFormValueLabelRef.current = getFormValueLabel;
|
|
76
|
+
}, [getFormValueLabel]);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
onChangeRef.current = onChange;
|
|
79
|
+
}, [onChange]);
|
|
80
|
+
|
|
81
|
+
const stableGetFormValueLabel = useCallback((v: any) => {
|
|
82
|
+
const fn = getFormValueLabelRef.current;
|
|
83
|
+
return fn ? fn(v) : defaultFormValueLabel(v);
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const stableToFormValue = useCallback((entity: T) => {
|
|
87
|
+
const fn = toFormValueRef.current;
|
|
88
|
+
return fn ? fn(entity) : { id: entity.id, name: getLabelRef.current(entity) };
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const selectedValues: { id: string; [key: string]: any }[] = useWatch({ control: form.control, name: id }) || [];
|
|
92
|
+
const selectedIds = useMemo(() => new Set(selectedValues.map((v) => v.id)), [selectedValues]);
|
|
93
|
+
|
|
94
|
+
const data: DataListRetriever<T> = useDataListRetriever({
|
|
95
|
+
retriever: (params) => retriever(params),
|
|
96
|
+
retrieverParams,
|
|
97
|
+
ready: true,
|
|
98
|
+
module,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const updateSearch = useCallback(
|
|
102
|
+
(searchedTerm: string) => {
|
|
103
|
+
if (searchedTerm.trim()) {
|
|
104
|
+
data.addAdditionalParameter("search", searchedTerm.trim());
|
|
105
|
+
} else {
|
|
106
|
+
data.removeAdditionalParameter("search");
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
[data],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const debouncedUpdateSearch = useDebounce(updateSearch, 500);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
debouncedUpdateSearch(searchTerm);
|
|
116
|
+
}, [debouncedUpdateSearch, searchTerm]);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (data.data) {
|
|
120
|
+
const entities = data.data as T[];
|
|
121
|
+
const filtered = excludeId ? entities.filter((e) => e.id !== excludeId) : entities;
|
|
122
|
+
|
|
123
|
+
const entityOptions: OptionData<T>[] = filtered.map((entity) => ({
|
|
124
|
+
id: entity.id,
|
|
125
|
+
label: getLabelRef.current(entity),
|
|
126
|
+
entityData: entity,
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
const existingIds = new Set(entityOptions.map((o) => o.id));
|
|
130
|
+
const missingOptions: OptionData<T>[] = selectedValues
|
|
131
|
+
.filter((v) => !existingIds.has(v.id))
|
|
132
|
+
.map((v) => ({
|
|
133
|
+
id: v.id,
|
|
134
|
+
label: stableGetFormValueLabel(v),
|
|
135
|
+
entityData: v as unknown as T,
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
setOptions([...entityOptions, ...missingOptions]);
|
|
139
|
+
}
|
|
140
|
+
}, [data.data, excludeId, selectedValues, stableGetFormValueLabel]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (open) {
|
|
144
|
+
setSearchTerm("");
|
|
145
|
+
requestAnimationFrame(() => {
|
|
146
|
+
searchInputRef.current?.focus();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}, [open]);
|
|
150
|
+
|
|
151
|
+
const toggleEntity = useCallback(
|
|
152
|
+
(option: OptionData<T>) => {
|
|
153
|
+
const current: any[] = form.getValues(id) ?? [];
|
|
154
|
+
let next: any[];
|
|
155
|
+
|
|
156
|
+
if (selectedIds.has(option.id)) {
|
|
157
|
+
next = current.filter((v: any) => v.id !== option.id);
|
|
158
|
+
} else {
|
|
159
|
+
const formValue = option.entityData
|
|
160
|
+
? stableToFormValue(option.entityData)
|
|
161
|
+
: { id: option.id, name: option.label };
|
|
162
|
+
next = [...current, formValue];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
form.setValue(id, next, { shouldDirty: true, shouldTouch: true });
|
|
166
|
+
|
|
167
|
+
const cb = onChangeRef.current;
|
|
168
|
+
if (cb) {
|
|
169
|
+
const fullData = next
|
|
170
|
+
.map((v: any) => options.find((opt) => opt.id === v.id)?.entityData)
|
|
171
|
+
.filter(Boolean) as T[];
|
|
172
|
+
cb(fullData);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
[form, id, selectedIds, options, stableToFormValue],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const removeEntity = useCallback(
|
|
179
|
+
(entityId: string) => {
|
|
180
|
+
const current: any[] = form.getValues(id) ?? [];
|
|
181
|
+
const next = current.filter((v: any) => v.id !== entityId);
|
|
182
|
+
form.setValue(id, next, { shouldDirty: true, shouldTouch: true });
|
|
183
|
+
|
|
184
|
+
const cb = onChangeRef.current;
|
|
185
|
+
if (cb) {
|
|
186
|
+
const fullData = next
|
|
187
|
+
.map((v: any) => options.find((opt) => opt.id === v.id)?.entityData)
|
|
188
|
+
.filter(Boolean) as T[];
|
|
189
|
+
cb(fullData);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
[form, id, options],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const sortedOptions = useMemo(() => {
|
|
196
|
+
const filtered = searchTerm.trim()
|
|
197
|
+
? options.filter((o) => o.label.toLowerCase().includes(searchTerm.trim().toLowerCase()))
|
|
198
|
+
: options;
|
|
199
|
+
|
|
200
|
+
return [...filtered].sort((a, b) => {
|
|
201
|
+
const aSelected = selectedIds.has(a.id) ? 0 : 1;
|
|
202
|
+
const bSelected = selectedIds.has(b.id) ? 0 : 1;
|
|
203
|
+
return aSelected - bSelected;
|
|
204
|
+
});
|
|
205
|
+
}, [options, selectedIds, searchTerm]);
|
|
206
|
+
|
|
207
|
+
const triggerSummary = useMemo(() => {
|
|
208
|
+
if (selectedValues.length === 0) return null;
|
|
209
|
+
return selectedValues.map((v) => stableGetFormValueLabel(v)).join(", ");
|
|
210
|
+
}, [selectedValues, stableGetFormValueLabel]);
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div className="flex w-full flex-col">
|
|
214
|
+
<FormFieldWrapper form={form} name={id} label={label} isRequired={isRequired}>
|
|
215
|
+
{() => (
|
|
216
|
+
<div className="flex flex-col gap-2">
|
|
217
|
+
<Popover open={open} onOpenChange={setOpen} modal>
|
|
218
|
+
<PopoverTrigger className="w-full">
|
|
219
|
+
<div className="bg-input/20 dark:bg-input/30 border-input flex min-h-7 w-full items-center gap-2 rounded-md border px-2 text-sm md:text-xs/relaxed">
|
|
220
|
+
{selectedValues.length > 0 ? (
|
|
221
|
+
<>
|
|
222
|
+
<span className="text-foreground min-w-0 flex-1 truncate text-left">{triggerSummary}</span>
|
|
223
|
+
<span className="bg-primary/10 text-primary shrink-0 rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium">
|
|
224
|
+
{selectedValues.length}
|
|
225
|
+
</span>
|
|
226
|
+
</>
|
|
227
|
+
) : (
|
|
228
|
+
<span className="text-muted-foreground flex-1 text-left">{placeholder}</span>
|
|
229
|
+
)}
|
|
230
|
+
<ChevronDownIcon className="text-muted-foreground size-3.5 shrink-0" />
|
|
231
|
+
</div>
|
|
232
|
+
</PopoverTrigger>
|
|
233
|
+
<PopoverContent className="w-(--anchor-width) flex flex-col gap-0 p-0" align="start">
|
|
234
|
+
<div className="relative p-1.5">
|
|
235
|
+
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-3.5 size-3.5 -translate-y-1/2" />
|
|
236
|
+
<Input
|
|
237
|
+
ref={searchInputRef}
|
|
238
|
+
placeholder={placeholder}
|
|
239
|
+
type="text"
|
|
240
|
+
className="h-8 w-full pr-7 pl-7 text-xs"
|
|
241
|
+
value={searchTerm}
|
|
242
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
243
|
+
/>
|
|
244
|
+
{searchTerm && (
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3.5 -translate-y-1/2"
|
|
248
|
+
onClick={() => setSearchTerm("")}
|
|
249
|
+
>
|
|
250
|
+
<XIcon className="size-3.5" />
|
|
251
|
+
</button>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
<div className="max-h-52 overflow-y-auto p-1">
|
|
255
|
+
{sortedOptions.length === 0 ? (
|
|
256
|
+
<div className="text-muted-foreground py-4 text-center text-xs">{emptyText}</div>
|
|
257
|
+
) : (
|
|
258
|
+
sortedOptions.map((option) => {
|
|
259
|
+
const isSelected = selectedIds.has(option.id);
|
|
260
|
+
return (
|
|
261
|
+
<button
|
|
262
|
+
key={option.id}
|
|
263
|
+
type="button"
|
|
264
|
+
className="hover:bg-muted flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-xs/relaxed"
|
|
265
|
+
onClick={() => toggleEntity(option)}
|
|
266
|
+
>
|
|
267
|
+
{renderOption && option.entityData ? (
|
|
268
|
+
<>
|
|
269
|
+
<div
|
|
270
|
+
className={`border-primary flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors ${
|
|
271
|
+
isSelected ? "bg-primary text-primary-foreground" : "opacity-50"
|
|
272
|
+
}`}
|
|
273
|
+
>
|
|
274
|
+
{isSelected && <CheckIcon className="size-3" />}
|
|
275
|
+
</div>
|
|
276
|
+
{renderOption(option.entityData, isSelected)}
|
|
277
|
+
</>
|
|
278
|
+
) : (
|
|
279
|
+
<>
|
|
280
|
+
<div
|
|
281
|
+
className={`border-primary flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors ${
|
|
282
|
+
isSelected ? "bg-primary text-primary-foreground" : "opacity-50"
|
|
283
|
+
}`}
|
|
284
|
+
>
|
|
285
|
+
{isSelected && <CheckIcon className="size-3" />}
|
|
286
|
+
</div>
|
|
287
|
+
<span className={isSelected ? "text-foreground" : "text-muted-foreground"}>
|
|
288
|
+
{option.label}
|
|
289
|
+
</span>
|
|
290
|
+
</>
|
|
291
|
+
)}
|
|
292
|
+
</button>
|
|
293
|
+
);
|
|
294
|
+
})
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
</PopoverContent>
|
|
298
|
+
</Popover>
|
|
299
|
+
|
|
300
|
+
{selectedValues.length > 0 && (
|
|
301
|
+
<div className="flex flex-wrap gap-1.5">
|
|
302
|
+
{selectedValues.map((value) => (
|
|
303
|
+
<Badge
|
|
304
|
+
key={value.id}
|
|
305
|
+
variant="outline"
|
|
306
|
+
className="h-auto gap-1.5 rounded-md px-2.5 py-1 pr-1.5 text-xs"
|
|
307
|
+
>
|
|
308
|
+
{stableGetFormValueLabel(value)}
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
className="text-muted-foreground hover:text-foreground rounded-sm p-0.5 transition-colors"
|
|
312
|
+
onClick={() => removeEntity(value.id)}
|
|
313
|
+
>
|
|
314
|
+
<XIcon className="size-3" />
|
|
315
|
+
</button>
|
|
316
|
+
</Badge>
|
|
317
|
+
))}
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
</FormFieldWrapper>
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
|
-
import {
|
|
5
|
-
import { useWatch } from "react-hook-form";
|
|
6
|
-
|
|
7
|
-
import { FormFieldWrapper, MultipleSelector, Option } from "../../../../components";
|
|
4
|
+
import { EntityMultiSelector } from "../../../../components/forms/EntityMultiSelector";
|
|
8
5
|
import { Modules } from "../../../../core";
|
|
9
|
-
import { DataListRetriever, useDataListRetriever, useDebounce } from "../../../../hooks";
|
|
10
6
|
import { HowToInterface } from "../../data/HowToInterface";
|
|
11
7
|
import { HowToService } from "../../data/HowToService";
|
|
12
8
|
|
|
13
|
-
type HowToMultiSelectType = {
|
|
14
|
-
id: string;
|
|
15
|
-
name: string;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
9
|
type HowToMultiSelectorProps = {
|
|
19
10
|
id: string;
|
|
20
11
|
form: any;
|
|
@@ -26,10 +17,6 @@ type HowToMultiSelectorProps = {
|
|
|
26
17
|
isRequired?: boolean;
|
|
27
18
|
};
|
|
28
19
|
|
|
29
|
-
type HowToOption = Option & {
|
|
30
|
-
howToData?: HowToInterface;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
20
|
export default function HowToMultiSelector({
|
|
34
21
|
id,
|
|
35
22
|
form,
|
|
@@ -37,116 +24,23 @@ export default function HowToMultiSelector({
|
|
|
37
24
|
label,
|
|
38
25
|
placeholder,
|
|
39
26
|
onChange,
|
|
40
|
-
maxCount = 3,
|
|
41
27
|
isRequired = false,
|
|
42
28
|
}: HowToMultiSelectorProps) {
|
|
43
29
|
const t = useTranslations();
|
|
44
|
-
const [howToOptions, setHowToOptions] = useState<HowToOption[]>([]);
|
|
45
|
-
const [searchTerm, setSearchTerm] = useState<string>("");
|
|
46
|
-
|
|
47
|
-
const selectedHowTos: HowToMultiSelectType[] = useWatch({ control: form.control, name: id }) || [];
|
|
48
|
-
|
|
49
|
-
const data: DataListRetriever<HowToInterface> = useDataListRetriever({
|
|
50
|
-
retriever: (params) => HowToService.findMany(params),
|
|
51
|
-
retrieverParams: {},
|
|
52
|
-
ready: true,
|
|
53
|
-
module: Modules.HowTo,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const updateSearch = useCallback(
|
|
57
|
-
(searchedTerm: string) => {
|
|
58
|
-
if (searchedTerm.trim()) {
|
|
59
|
-
data.addAdditionalParameter("search", searchedTerm.trim());
|
|
60
|
-
} else {
|
|
61
|
-
data.removeAdditionalParameter("search");
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
[data],
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
const debouncedUpdateSearch = useDebounce(updateSearch, 500);
|
|
68
|
-
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
debouncedUpdateSearch(searchTerm);
|
|
71
|
-
}, [debouncedUpdateSearch, searchTerm]);
|
|
72
|
-
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
if (data.data && data.data.length > 0) {
|
|
75
|
-
const howTos = data.data as HowToInterface[];
|
|
76
|
-
const filteredHowTos = howTos.filter((howTo) => howTo.id !== currentHowTo?.id);
|
|
77
|
-
|
|
78
|
-
const options: HowToOption[] = filteredHowTos.map((howTo) => ({
|
|
79
|
-
label: howTo.name,
|
|
80
|
-
value: howTo.id,
|
|
81
|
-
howToData: howTo,
|
|
82
|
-
}));
|
|
83
|
-
|
|
84
|
-
// Add options for any already selected that aren't in search results
|
|
85
|
-
const existingOptionIds = new Set(options.map((option) => option.value));
|
|
86
|
-
const missingOptions: HowToOption[] = selectedHowTos
|
|
87
|
-
.filter((howTo) => !existingOptionIds.has(howTo.id))
|
|
88
|
-
.map((howTo) => ({
|
|
89
|
-
label: howTo.name,
|
|
90
|
-
value: howTo.id,
|
|
91
|
-
howToData: howTo as unknown as HowToInterface,
|
|
92
|
-
}));
|
|
93
|
-
|
|
94
|
-
setHowToOptions([...options, ...missingOptions]);
|
|
95
|
-
}
|
|
96
|
-
}, [data.data, currentHowTo, selectedHowTos]);
|
|
97
|
-
|
|
98
|
-
// Convert selected to Option[] format
|
|
99
|
-
const selectedOptions = useMemo(() => {
|
|
100
|
-
return selectedHowTos.map((howTo) => ({
|
|
101
|
-
value: howTo.id,
|
|
102
|
-
label: howTo.name,
|
|
103
|
-
}));
|
|
104
|
-
}, [selectedHowTos]);
|
|
105
|
-
|
|
106
|
-
const handleChange = (options: Option[]) => {
|
|
107
|
-
// Convert to form format
|
|
108
|
-
const formValues = options.map((option) => ({
|
|
109
|
-
id: option.value,
|
|
110
|
-
name: option.label,
|
|
111
|
-
}));
|
|
112
|
-
|
|
113
|
-
form.setValue(id, formValues, { shouldDirty: true, shouldTouch: true });
|
|
114
|
-
|
|
115
|
-
if (onChange) {
|
|
116
|
-
// Get full data for onChange callback
|
|
117
|
-
const fullData = options
|
|
118
|
-
.map((option) => {
|
|
119
|
-
const howToOption = howToOptions.find((opt) => opt.value === option.value);
|
|
120
|
-
return howToOption?.howToData;
|
|
121
|
-
})
|
|
122
|
-
.filter(Boolean) as HowToInterface[];
|
|
123
|
-
onChange(fullData);
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// Search handler
|
|
128
|
-
const handleSearchSync = (search: string): Option[] => {
|
|
129
|
-
setSearchTerm(search);
|
|
130
|
-
return howToOptions;
|
|
131
|
-
};
|
|
132
30
|
|
|
133
31
|
return (
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
/>
|
|
148
|
-
)}
|
|
149
|
-
</FormFieldWrapper>
|
|
150
|
-
</div>
|
|
32
|
+
<EntityMultiSelector<HowToInterface>
|
|
33
|
+
id={id}
|
|
34
|
+
form={form}
|
|
35
|
+
label={label}
|
|
36
|
+
placeholder={placeholder || t("ui.search.button")}
|
|
37
|
+
emptyText={t("ui.search.no_results_generic")}
|
|
38
|
+
isRequired={isRequired}
|
|
39
|
+
retriever={(params) => HowToService.findMany(params)}
|
|
40
|
+
module={Modules.HowTo}
|
|
41
|
+
getLabel={(howTo) => howTo.name}
|
|
42
|
+
excludeId={currentHowTo?.id}
|
|
43
|
+
onChange={onChange}
|
|
44
|
+
/>
|
|
151
45
|
);
|
|
152
46
|
}
|