@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.
Files changed (30) hide show
  1. package/dist/{BlockNoteEditor-KJZ7FGBA.mjs → BlockNoteEditor-NJMTHPO4.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-A37P3FA7.js → BlockNoteEditor-SLT4VOLL.js} +6 -6
  3. package/dist/{BlockNoteEditor-A37P3FA7.js.map → BlockNoteEditor-SLT4VOLL.js.map} +1 -1
  4. package/dist/billing/index.js +299 -299
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-XUTMY6K5.js → chunk-DTE6RZXF.js} +606 -526
  7. package/dist/chunk-DTE6RZXF.js.map +1 -0
  8. package/dist/{chunk-ZNODEBMI.mjs → chunk-Q7JKB777.mjs} +2380 -2300
  9. package/dist/chunk-Q7JKB777.mjs.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/index.d.mts +29 -3
  13. package/dist/components/index.d.ts +29 -3
  14. package/dist/components/index.js +4 -2
  15. package/dist/components/index.js.map +1 -1
  16. package/dist/components/index.mjs +3 -1
  17. package/dist/contexts/index.js +2 -2
  18. package/dist/contexts/index.mjs +1 -1
  19. package/dist/scripts/generate-web-module/templates/components/multi-selector.template.d.ts.map +1 -1
  20. package/dist/scripts/generate-web-module/templates/components/multi-selector.template.js +14 -120
  21. package/dist/scripts/generate-web-module/templates/components/multi-selector.template.js.map +1 -1
  22. package/package.json +1 -1
  23. package/scripts/generate-web-module/templates/components/multi-selector.template.ts +14 -120
  24. package/src/components/forms/EntityMultiSelector.tsx +325 -0
  25. package/src/components/forms/index.ts +1 -0
  26. package/src/features/how-to/components/forms/HowToMultiSelector.tsx +14 -120
  27. package/src/features/user/components/forms/UserMultiSelect.tsx +34 -181
  28. package/dist/chunk-XUTMY6K5.js.map +0 -1
  29. package/dist/chunk-ZNODEBMI.mjs.map +0 -1
  30. /package/dist/{BlockNoteEditor-KJZ7FGBA.mjs.map → BlockNoteEditor-NJMTHPO4.mjs.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "1.74.0",
3
+ "version": "1.75.0",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -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 { DataListRetriever, useDataListRetriever, useDebounce } from "@carlonicora/nextjs-jsonapi/client";
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
- <div className="flex w-full flex-col">
155
- <FormFieldWrapper form={form} name={id} label={label} isRequired={isRequired}>
156
- {() => (
157
- <MultipleSelector
158
- value={selectedOptions}
159
- onChange={handleChange}
160
- options={${names.camelCase}Options}
161
- placeholder={placeholder}
162
- maxDisplayCount={maxCount}
163
- hideClearAllButton
164
- onSearchSync={handleSearchSync}
165
- delay={0}
166
- emptyIndicator={<span className="text-muted-foreground">{t("ui.search.no_results_generic")}</span>}
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,4 +1,5 @@
1
1
  export * from "./CommonAssociationForm";
2
+ export * from "./EntityMultiSelector";
2
3
  export * from "./CommonDeleter";
3
4
  export * from "./CommonEditorButtons";
4
5
  export * from "./CommonEditorHeader";
@@ -1,20 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
- import { useCallback, useEffect, useMemo, useState } from "react";
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
- <div className="flex w-full flex-col">
135
- <FormFieldWrapper form={form} name={id} label={label} isRequired={isRequired}>
136
- {() => (
137
- <MultipleSelector
138
- value={selectedOptions}
139
- onChange={handleChange}
140
- options={howToOptions}
141
- placeholder={placeholder}
142
- maxDisplayCount={maxCount}
143
- hideClearAllButton
144
- onSearchSync={handleSearchSync}
145
- delay={0}
146
- emptyIndicator={<span className="text-muted-foreground">{t("ui.search.no_results_generic")}</span>}
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
  }