@carlonicora/nextjs-jsonapi 1.68.0 → 1.70.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 (103) hide show
  1. package/dist/{AuthComponent-NwQ_ZXsv.d.mts → AuthComponent-DXe3kPzb.d.mts} +1 -1
  2. package/dist/{AuthComponent-DL1D3y7f.d.ts → AuthComponent-Di8DsZ2I.d.ts} +1 -1
  3. package/dist/{BlockNoteEditor-6FDECIS2.mjs → BlockNoteEditor-6XV2IXLY.mjs} +15 -9
  4. package/dist/BlockNoteEditor-6XV2IXLY.mjs.map +1 -0
  5. package/dist/{BlockNoteEditor-DXHROT4C.js → BlockNoteEditor-NVPUPZXB.js} +25 -19
  6. package/dist/BlockNoteEditor-NVPUPZXB.js.map +1 -0
  7. package/dist/HowToInterface-DtVWAE1s.d.mts +17 -0
  8. package/dist/HowToInterface-NaqSG9sE.d.ts +17 -0
  9. package/dist/{auth.interface-BX_1qZZJ.d.ts → auth.interface-BTco8PWs.d.ts} +1 -1
  10. package/dist/{auth.interface-yeLelxdI.d.mts → auth.interface-C4uJzBec.d.mts} +1 -1
  11. package/dist/billing/index.js +346 -346
  12. package/dist/billing/index.mjs +3 -3
  13. package/dist/{chunk-37KYO2UD.js → chunk-56VU7A4I.js} +172 -18
  14. package/dist/chunk-56VU7A4I.js.map +1 -0
  15. package/dist/{chunk-WOJIRXIP.js → chunk-6ROMPIIP.js} +11 -11
  16. package/dist/{chunk-WOJIRXIP.js.map → chunk-6ROMPIIP.js.map} +1 -1
  17. package/dist/{chunk-IOMDNRX5.mjs → chunk-GZNHBAZF.mjs} +155 -1
  18. package/dist/chunk-GZNHBAZF.mjs.map +1 -0
  19. package/dist/{chunk-H4ZS3R76.mjs → chunk-LQEKQYUJ.mjs} +2569 -1603
  20. package/dist/chunk-LQEKQYUJ.mjs.map +1 -0
  21. package/dist/{chunk-WVTBEVAL.mjs → chunk-WJYWWOTG.mjs} +2 -2
  22. package/dist/{chunk-ELTHSXBI.js → chunk-ZKOLKFAS.js} +1664 -698
  23. package/dist/chunk-ZKOLKFAS.js.map +1 -0
  24. package/dist/client/index.d.mts +5 -6
  25. package/dist/client/index.d.ts +5 -6
  26. package/dist/client/index.js +4 -4
  27. package/dist/client/index.mjs +3 -3
  28. package/dist/components/index.d.mts +83 -10
  29. package/dist/components/index.d.ts +83 -10
  30. package/dist/components/index.js +26 -4
  31. package/dist/components/index.js.map +1 -1
  32. package/dist/components/index.mjs +25 -3
  33. package/dist/{config-D-mqttuF.d.mts → config-Bmr_0qTn.d.mts} +1 -1
  34. package/dist/{config-CyCAWW-d.d.ts → config-n0lfSf27.d.ts} +1 -1
  35. package/dist/contexts/index.d.mts +16 -4
  36. package/dist/contexts/index.d.ts +16 -4
  37. package/dist/contexts/index.js +8 -4
  38. package/dist/contexts/index.js.map +1 -1
  39. package/dist/contexts/index.mjs +7 -3
  40. package/dist/core/index.d.mts +61 -11
  41. package/dist/core/index.d.ts +61 -11
  42. package/dist/core/index.js +10 -2
  43. package/dist/core/index.js.map +1 -1
  44. package/dist/core/index.mjs +9 -1
  45. package/dist/index.d.mts +9 -10
  46. package/dist/index.d.ts +9 -10
  47. package/dist/index.js +11 -3
  48. package/dist/index.js.map +1 -1
  49. package/dist/index.mjs +10 -2
  50. package/dist/{notification.interface-ItBxq2au.d.ts → notification.interface-DYDZENx2.d.ts} +18 -1
  51. package/dist/{notification.interface-C6UcmJqu.d.mts → notification.interface-DrHu_1MM.d.mts} +18 -1
  52. package/dist/{s3.service-N1g0piXD.d.ts → s3.service-DK2KKXbR.d.ts} +2 -3
  53. package/dist/{s3.service-CHOTwfWA.d.mts → s3.service-TsN2unZr.d.mts} +2 -3
  54. package/dist/server/index.d.mts +3 -4
  55. package/dist/server/index.d.ts +3 -4
  56. package/dist/server/index.js +3 -3
  57. package/dist/server/index.mjs +1 -1
  58. package/dist/{useRbacState-CUj0hp8t.d.ts → useRbacState-BYaSdA78.d.ts} +1 -1
  59. package/dist/{useRbacState-Btk1gkQg.d.mts → useRbacState-CQEJ_ysV.d.mts} +1 -1
  60. package/dist/{useSocket-BSUN9s3p.d.ts → useSocket-Cjt_qvkI.d.ts} +1 -1
  61. package/dist/{useSocket-DKI92Fbg.d.mts → useSocket-VAGetcT3.d.mts} +1 -1
  62. package/package.json +1 -1
  63. package/src/components/editors/BlockNoteEditor.tsx +7 -1
  64. package/src/components/forms/FormBlockNote.tsx +6 -0
  65. package/src/components/forms/FormSelect.tsx +3 -0
  66. package/src/components/index.ts +1 -0
  67. package/src/contexts/index.ts +1 -0
  68. package/src/core/index.ts +2 -0
  69. package/src/core/registry/ModuleRegistry.ts +19 -0
  70. package/src/features/how-to/HowToModule.ts +18 -0
  71. package/src/features/how-to/components/containers/HowToCommand.tsx +230 -0
  72. package/src/features/how-to/components/containers/HowToCommandViewer.tsx +76 -0
  73. package/src/features/how-to/components/containers/HowToContainer.tsx +27 -0
  74. package/src/features/how-to/components/containers/HowToListContainer.tsx +17 -0
  75. package/src/features/how-to/components/details/HowToContent.tsx +16 -0
  76. package/src/features/how-to/components/details/HowToDetails.tsx +52 -0
  77. package/src/features/how-to/components/forms/HowToDeleter.tsx +31 -0
  78. package/src/features/how-to/components/forms/HowToEditor.tsx +270 -0
  79. package/src/features/how-to/components/forms/HowToMultiSelector.tsx +152 -0
  80. package/src/features/how-to/components/forms/HowToSelector.tsx +164 -0
  81. package/src/features/how-to/components/index.ts +11 -0
  82. package/src/features/how-to/components/lists/HowToList.tsx +39 -0
  83. package/src/features/how-to/contexts/HowToContext.tsx +101 -0
  84. package/src/features/how-to/data/HowTo.ts +69 -0
  85. package/src/features/how-to/data/HowToFields.ts +10 -0
  86. package/src/features/how-to/data/HowToInterface.ts +11 -0
  87. package/src/features/how-to/data/HowToService.ts +61 -0
  88. package/src/features/how-to/data/index.ts +4 -0
  89. package/src/features/how-to/hooks/useHowToTableStructure.tsx +86 -0
  90. package/src/features/how-to/index.ts +2 -0
  91. package/src/features/how-to/utils/blocknote.ts +108 -0
  92. package/src/features/how-to/utils/index.ts +1 -0
  93. package/dist/BlockNoteEditor-6FDECIS2.mjs.map +0 -1
  94. package/dist/BlockNoteEditor-DXHROT4C.js.map +0 -1
  95. package/dist/breadcrumb.item.data.interface-CgB4_1EE.d.mts +0 -6
  96. package/dist/breadcrumb.item.data.interface-CgB4_1EE.d.ts +0 -6
  97. package/dist/chunk-37KYO2UD.js.map +0 -1
  98. package/dist/chunk-ELTHSXBI.js.map +0 -1
  99. package/dist/chunk-H4ZS3R76.mjs.map +0 -1
  100. package/dist/chunk-IOMDNRX5.mjs.map +0 -1
  101. package/dist/content.interface-8T5-G84c.d.mts +0 -21
  102. package/dist/content.interface-D-xdYxjt.d.ts +0 -21
  103. /package/dist/{chunk-WVTBEVAL.mjs.map → chunk-WJYWWOTG.mjs.map} +0 -0
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { AttributeElement, ContentTitle } from "../../../../components";
5
+ import { Modules } from "../../../../core";
6
+ import { useSharedContext } from "../../../../contexts/SharedContext";
7
+ import { Link } from "../../../../shadcnui";
8
+ import { HowTo } from "../../data/HowTo";
9
+ import { HowToInterface } from "../../data/HowToInterface";
10
+ import { useHowToContext } from "../../contexts/HowToContext";
11
+
12
+ type HowToDetailsProps = {
13
+ howTo: HowToInterface;
14
+ };
15
+
16
+ function HowToDetailsInternal({ howTo }: HowToDetailsProps) {
17
+ const t = useTranslations();
18
+ const { title } = useSharedContext();
19
+ const pagesList = HowTo.parsePagesFromString(howTo.pages);
20
+
21
+ return (
22
+ <div className="flex w-full flex-col gap-y-4">
23
+ <ContentTitle type={title.type} element={title.element} functions={title.functions} module={Modules.HowTo} />
24
+
25
+ {pagesList.length > 0 && (
26
+ <div className="border-t pt-4">
27
+ <AttributeElement
28
+ title={t(`howto.fields.pages.label`)}
29
+ value={
30
+ <ul className="flex flex-col gap-y-1">
31
+ {pagesList.map((page, index) => (
32
+ <li key={index}>
33
+ <Link href={page} className="text-primary hover:underline">
34
+ {page}
35
+ </Link>
36
+ </li>
37
+ ))}
38
+ </ul>
39
+ }
40
+ />
41
+ </div>
42
+ )}
43
+ </div>
44
+ );
45
+ }
46
+
47
+ export default function HowToDetails() {
48
+ const { howTo } = useHowToContext();
49
+ if (!howTo) return null;
50
+
51
+ return <HowToDetailsInternal howTo={howTo} />;
52
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { CommonDeleter } from "../../../../components";
5
+ import { Modules } from "../../../../core";
6
+ import { usePageUrlGenerator } from "../../../../hooks";
7
+ import { HowToInterface } from "../../data/HowToInterface";
8
+ import { HowToService } from "../../data/HowToService";
9
+
10
+ type HowToDeleterProps = {
11
+ howTo: HowToInterface;
12
+ };
13
+
14
+ function HowToDeleterInternal({ howTo }: HowToDeleterProps) {
15
+ const t = useTranslations();
16
+ const generateUrl = usePageUrlGenerator();
17
+
18
+ if (!howTo) return null;
19
+
20
+ return (
21
+ <CommonDeleter
22
+ type={`howtos`}
23
+ deleteFunction={() => HowToService.delete({ howToId: howTo.id })}
24
+ redirectTo={generateUrl({ page: Modules.HowTo })}
25
+ />
26
+ );
27
+ }
28
+
29
+ export default function HowToDeleter(props: HowToDeleterProps) {
30
+ return <HowToDeleterInternal {...props} />;
31
+ }
@@ -0,0 +1,270 @@
1
+ "use client";
2
+
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import { PlusIcon, SearchIcon, XIcon } from "lucide-react";
5
+ import { useTranslations } from "next-intl";
6
+ import { ReactNode, useCallback, useMemo, useState } from "react";
7
+ import { useForm } from "react-hook-form";
8
+ import { v4 } from "uuid";
9
+ import { z } from "zod";
10
+
11
+ import { EditorSheet, FormInput } from "../../../../components";
12
+ import { BlockNoteEditorContainer } from "../../../../components";
13
+ import { ModuleRegistry, Modules } from "../../../../core";
14
+ import { useI18nRouter } from "../../../../hooks";
15
+ import {
16
+ Button,
17
+ Command,
18
+ CommandEmpty,
19
+ CommandItem,
20
+ CommandList,
21
+ Input,
22
+ Label,
23
+ Popover,
24
+ PopoverContent,
25
+ PopoverTrigger,
26
+ } from "../../../../shadcnui";
27
+ import { HowTo } from "../../data/HowTo";
28
+ import { HowToInput, HowToInterface } from "../../data/HowToInterface";
29
+ import { HowToService } from "../../data/HowToService";
30
+
31
+ function PageSelector({
32
+ value,
33
+ allPageUrls,
34
+ selectedPages,
35
+ placeholder,
36
+ emptyMessage,
37
+ onSelect,
38
+ onRemove,
39
+ }: {
40
+ value: string;
41
+ allPageUrls: { id: string; text: string }[];
42
+ selectedPages: string[];
43
+ placeholder: string;
44
+ emptyMessage: string;
45
+ onSelect: (value: string) => void;
46
+ onRemove: () => void;
47
+ }) {
48
+ const [open, setOpen] = useState(false);
49
+ const [search, setSearch] = useState("");
50
+
51
+ const selectedLabel = value ? allPageUrls.find((opt) => opt.id === value) : undefined;
52
+
53
+ const filteredOptions = useMemo(() => {
54
+ const available = allPageUrls.filter((opt) => opt.id === value || !selectedPages.includes(opt.id));
55
+ if (!search) return available;
56
+ const term = search.toLowerCase();
57
+ return available.filter((opt) => opt.text.toLowerCase().includes(term) || opt.id.toLowerCase().includes(term));
58
+ }, [allPageUrls, selectedPages, value, search]);
59
+
60
+ return (
61
+ <div className="flex gap-2">
62
+ <Popover open={open} onOpenChange={setOpen} modal={true}>
63
+ <PopoverTrigger className="flex-1">
64
+ <div className="bg-input/20 dark:bg-input/30 border-input flex h-9 w-full items-center rounded-md border px-3 text-sm">
65
+ {selectedLabel ? (
66
+ <span>
67
+ {selectedLabel.text} ({selectedLabel.id})
68
+ </span>
69
+ ) : (
70
+ <span className="text-muted-foreground">{placeholder}</span>
71
+ )}
72
+ </div>
73
+ </PopoverTrigger>
74
+ <PopoverContent align="start" className="w-(--anchor-width) p-0">
75
+ <Command shouldFilter={false}>
76
+ <div className="relative w-full border-b">
77
+ <SearchIcon className="text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4" />
78
+ <Input
79
+ placeholder={placeholder}
80
+ type="text"
81
+ className="rounded-none border-0 pl-8 focus-visible:ring-0"
82
+ onChange={(e) => setSearch(e.target.value)}
83
+ value={search}
84
+ />
85
+ </div>
86
+ <CommandList className="max-h-48">
87
+ <CommandEmpty>{emptyMessage}</CommandEmpty>
88
+ {filteredOptions.map((opt) => (
89
+ <CommandItem
90
+ key={opt.id}
91
+ className="cursor-pointer"
92
+ onSelect={() => {
93
+ onSelect(opt.id);
94
+ setOpen(false);
95
+ setSearch("");
96
+ }}
97
+ >
98
+ {opt.text} ({opt.id})
99
+ </CommandItem>
100
+ ))}
101
+ </CommandList>
102
+ </Command>
103
+ </PopoverContent>
104
+ </Popover>
105
+ <Button type="button" variant="outline" size="icon" onClick={onRemove}>
106
+ <XIcon className="h-4 w-4" />
107
+ </Button>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ type HowToEditorProps = {
113
+ howTo?: HowToInterface;
114
+ propagateChanges?: (howTo: HowToInterface) => void;
115
+ trigger?: ReactNode;
116
+ forceShow?: boolean;
117
+ onClose?: () => void;
118
+ onRevalidate?: (path: string) => Promise<void>;
119
+ dialogOpen?: boolean;
120
+ onDialogOpenChange?: (open: boolean) => void;
121
+ };
122
+
123
+ function HowToEditorInternal({
124
+ howTo,
125
+ propagateChanges,
126
+ trigger,
127
+ forceShow,
128
+ onClose,
129
+ onRevalidate,
130
+ dialogOpen,
131
+ onDialogOpenChange,
132
+ }: HowToEditorProps) {
133
+ const router = useI18nRouter();
134
+ const t = useTranslations();
135
+
136
+ const formSchema = useMemo(
137
+ () =>
138
+ z.object({
139
+ id: z.string().uuid(),
140
+ name: z.string().min(1, { message: t(`howto.fields.name.error`) }),
141
+ description: z.any(),
142
+ pages: z.array(z.string()),
143
+ }),
144
+ [t],
145
+ );
146
+
147
+ const getDefaultValues = useCallback(
148
+ () => ({
149
+ id: howTo?.id || v4(),
150
+ name: howTo?.name || "",
151
+ description: howTo?.description || [],
152
+ pages: HowTo.parsePagesFromString(howTo?.pages),
153
+ }),
154
+ [howTo],
155
+ );
156
+
157
+ const form = useForm<z.infer<typeof formSchema>>({
158
+ resolver: zodResolver(formSchema),
159
+ defaultValues: getDefaultValues(),
160
+ });
161
+
162
+ const handleDescriptionChange = useCallback(
163
+ (content: any) => {
164
+ form.setValue("description", content, { shouldDirty: true });
165
+ },
166
+ [form],
167
+ );
168
+
169
+ const allPageUrls = useMemo(() => ModuleRegistry.getAllPageUrls(), []);
170
+ const pages = form.watch("pages");
171
+
172
+ const addPage = () => {
173
+ form.setValue("pages", [...pages, ""], { shouldDirty: true });
174
+ };
175
+
176
+ const removePage = (index: number) => {
177
+ form.setValue(
178
+ "pages",
179
+ pages.filter((_: string, i: number) => i !== index),
180
+ { shouldDirty: true },
181
+ );
182
+ };
183
+
184
+ return (
185
+ <EditorSheet
186
+ form={form}
187
+ entityType={t(`entities.howtos`, { count: 1 })}
188
+ entityName={howTo?.name}
189
+ isEdit={!!howTo}
190
+ module={Modules.HowTo}
191
+ propagateChanges={propagateChanges}
192
+ size="lg"
193
+ onSubmit={async (values) => {
194
+ const payload: HowToInput = {
195
+ id: values.id,
196
+ name: values.name,
197
+ authorId: "",
198
+ description: values.description,
199
+ pages: HowTo.serializePagesToString(values.pages),
200
+ };
201
+
202
+ const updatedHowTo = howTo ? await HowToService.update(payload) : await HowToService.create(payload);
203
+
204
+ return updatedHowTo;
205
+ }}
206
+ onReset={() => {
207
+ return getDefaultValues();
208
+ }}
209
+ onRevalidate={onRevalidate}
210
+ onNavigate={(url) => router.push(url)}
211
+ onClose={onClose}
212
+ trigger={trigger}
213
+ forceShow={forceShow}
214
+ dialogOpen={dialogOpen}
215
+ onDialogOpenChange={onDialogOpenChange}
216
+ >
217
+ <div className="flex w-full flex-col gap-y-4">
218
+ <FormInput
219
+ form={form}
220
+ id="name"
221
+ name={t(`howto.fields.name.label`)}
222
+ placeholder={t(`howto.fields.name.placeholder`)}
223
+ isRequired
224
+ />
225
+ <div className="space-y-2">
226
+ <Label>{t(`howto.fields.description.label`)}</Label>
227
+ <div className="max-h-80 overflow-y-auto rounded-md border">
228
+ <BlockNoteEditorContainer
229
+ id={form.getValues("id")}
230
+ type="howto"
231
+ initialContent={form.getValues("description")}
232
+ onChange={handleDescriptionChange}
233
+ placeholder={t(`howto.fields.description.placeholder`)}
234
+ />
235
+ </div>
236
+ </div>
237
+ {/* Pages List */}
238
+ <div className="space-y-2">
239
+ <Label>{t(`howto.fields.pages.label`)}</Label>
240
+ <div className="space-y-2">
241
+ {pages.map((page: string, index: number) => (
242
+ <PageSelector
243
+ key={index}
244
+ value={page}
245
+ allPageUrls={allPageUrls}
246
+ selectedPages={pages}
247
+ placeholder={t(`howto.fields.pages.placeholder`)}
248
+ emptyMessage={t(`howto.command.empty`)}
249
+ onSelect={(value) => {
250
+ const updated = [...pages];
251
+ updated[index] = value;
252
+ form.setValue("pages", updated, { shouldDirty: true });
253
+ }}
254
+ onRemove={() => removePage(index)}
255
+ />
256
+ ))}
257
+ <Button type="button" variant="outline" size="sm" onClick={addPage}>
258
+ <PlusIcon className="mr-2 h-4 w-4" />
259
+ {t(`howto.fields.pages.add`)}
260
+ </Button>
261
+ </div>
262
+ </div>
263
+ </div>
264
+ </EditorSheet>
265
+ );
266
+ }
267
+
268
+ export default function HowToEditor(props: HowToEditorProps) {
269
+ return <HowToEditorInternal {...props} />;
270
+ }
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
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";
8
+ import { Modules } from "../../../../core";
9
+ import { DataListRetriever, useDataListRetriever, useDebounce } from "../../../../hooks";
10
+ import { HowToInterface } from "../../data/HowToInterface";
11
+ import { HowToService } from "../../data/HowToService";
12
+
13
+ type HowToMultiSelectType = {
14
+ id: string;
15
+ name: string;
16
+ };
17
+
18
+ type HowToMultiSelectorProps = {
19
+ id: string;
20
+ form: any;
21
+ currentHowTo?: HowToInterface;
22
+ label?: string;
23
+ placeholder?: string;
24
+ onChange?: (howTos?: HowToInterface[]) => void;
25
+ maxCount?: number;
26
+ isRequired?: boolean;
27
+ };
28
+
29
+ type HowToOption = Option & {
30
+ howToData?: HowToInterface;
31
+ };
32
+
33
+ export default function HowToMultiSelector({
34
+ id,
35
+ form,
36
+ currentHowTo,
37
+ label,
38
+ placeholder,
39
+ onChange,
40
+ maxCount = 3,
41
+ isRequired = false,
42
+ }: HowToMultiSelectorProps) {
43
+ 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
+
133
+ 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>
151
+ );
152
+ }
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import { CircleX, RefreshCwIcon, SearchIcon, XIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+
7
+ import {
8
+ Command,
9
+ CommandItem,
10
+ CommandList,
11
+ Input,
12
+ Popover,
13
+ PopoverContent,
14
+ PopoverTrigger,
15
+ } from "../../../../shadcnui";
16
+ import { FormFieldWrapper } from "../../../../components";
17
+ import { Modules } from "../../../../core";
18
+ import { DataListRetriever, useDataListRetriever, useDebounce } from "../../../../hooks";
19
+ import { HowToInterface } from "../../data/HowToInterface";
20
+ import { HowToService } from "../../data/HowToService";
21
+
22
+ type HowToSelectorProps = {
23
+ id: string;
24
+ form: any;
25
+ label?: string;
26
+ placeholder?: string;
27
+ onChange?: (howTo?: HowToInterface) => void;
28
+ isRequired?: boolean;
29
+ };
30
+
31
+ export default function HowToSelector({
32
+ id,
33
+ form,
34
+ label,
35
+ placeholder,
36
+ onChange,
37
+ isRequired = false,
38
+ }: HowToSelectorProps) {
39
+ const t = useTranslations();
40
+
41
+ const [open, setOpen] = useState<boolean>(false);
42
+
43
+ const searchTermRef = useRef<string>("");
44
+ const [searchTerm, setSearchTerm] = useState<string>("");
45
+
46
+ const [isSearching, setIsSearching] = useState<boolean>(false);
47
+
48
+ const data: DataListRetriever<HowToInterface> = useDataListRetriever({
49
+ retriever: (params) => {
50
+ return HowToService.findMany(params);
51
+ },
52
+ retrieverParams: {},
53
+ module: Modules.HowTo,
54
+ });
55
+
56
+ const search = useCallback(
57
+ async (searchedTerm: string) => {
58
+ try {
59
+ if (searchedTerm === searchTermRef.current) return;
60
+ setIsSearching(true);
61
+ searchTermRef.current = searchedTerm;
62
+ await data.search(searchedTerm);
63
+ } finally {
64
+ setIsSearching(false);
65
+ }
66
+ },
67
+ [searchTermRef, data],
68
+ );
69
+
70
+ const updateSearchTerm = useDebounce(search, 500);
71
+
72
+ useEffect(() => {
73
+ setIsSearching(true);
74
+ updateSearchTerm(searchTerm);
75
+ }, [updateSearchTerm, searchTerm]);
76
+
77
+ const setHowTo = (howTo?: HowToInterface) => {
78
+ if (onChange) onChange(howTo);
79
+ if (!howTo) {
80
+ form.setValue(id, undefined, { shouldDirty: true });
81
+ setOpen(false);
82
+ return;
83
+ }
84
+
85
+ form.setValue(id, { id: howTo.id, name: howTo.name }, { shouldDirty: true });
86
+ setOpen(false);
87
+
88
+ setTimeout(() => {
89
+ setOpen(false);
90
+ }, 0);
91
+ };
92
+
93
+ return (
94
+ <div className="flex w-full flex-col">
95
+ <FormFieldWrapper form={form} name={id} label={label} isRequired={isRequired}>
96
+ {(field: any) => (
97
+ <Popover open={open} onOpenChange={setOpen} modal={true}>
98
+ <div className="flex w-full flex-row items-center justify-between">
99
+ <PopoverTrigger className="w-full">
100
+ <div className="flex w-full flex-row items-center justify-start rounded-md">
101
+ {field.value ? (
102
+ <div className="bg-input/20 dark:bg-input/30 border-input flex h-7 w-full flex-row items-center justify-start rounded-md border px-2 py-0.5 text-sm md:text-xs/relaxed">
103
+ <span>{field.value?.name ?? ""}</span>
104
+ </div>
105
+ ) : (
106
+ <div className="bg-input/20 dark:bg-input/30 border-input text-muted-foreground flex h-7 w-full flex-row items-center justify-start rounded-md border px-2 py-0.5 text-sm md:text-xs/relaxed">
107
+ {placeholder ?? t(`generic.search.placeholder`, { type: t(`entities.howtos`, { count: 1 }) })}
108
+ </div>
109
+ )}
110
+ </div>
111
+ </PopoverTrigger>
112
+ {field.value && (
113
+ <CircleX
114
+ className="text-muted hover:text-destructive ml-2 h-4 w-4 shrink-0 cursor-pointer"
115
+ onClick={() => setHowTo()}
116
+ />
117
+ )}
118
+ </div>
119
+ <PopoverContent align="start" className="w-(--anchor-width)">
120
+ <Command shouldFilter={false}>
121
+ <div className="relative mb-2 w-full">
122
+ <SearchIcon className="text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4" />
123
+ <Input
124
+ placeholder={t(`generic.search.placeholder`, { type: t(`entities.howtos`, { count: 1 }) })}
125
+ type="text"
126
+ className="w-full pr-8 pl-8"
127
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
128
+ value={searchTerm}
129
+ />
130
+ {isSearching ? (
131
+ <RefreshCwIcon className="text-muted-foreground absolute top-2.5 right-2.5 h-4 w-4 animate-spin" />
132
+ ) : searchTermRef.current ? (
133
+ <XIcon
134
+ className={`absolute top-2.5 right-2.5 h-4 w-4 ${searchTermRef.current ? "cursor-pointer" : "text-muted-foreground"}`}
135
+ onClick={() => {
136
+ setSearchTerm("");
137
+ search("");
138
+ }}
139
+ />
140
+ ) : (
141
+ <></>
142
+ )}
143
+ </div>
144
+ <CommandList>
145
+ {data.data &&
146
+ data.data.length > 0 &&
147
+ (data.data as HowToInterface[]).map((howTo: HowToInterface) => (
148
+ <CommandItem
149
+ className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
150
+ key={howTo.id}
151
+ onSelect={() => setHowTo(howTo)}
152
+ >
153
+ {howTo.name}
154
+ </CommandItem>
155
+ ))}
156
+ </CommandList>
157
+ </Command>
158
+ </PopoverContent>
159
+ </Popover>
160
+ )}
161
+ </FormFieldWrapper>
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,11 @@
1
+ export { default as HowToCommand } from "./containers/HowToCommand";
2
+ export { default as HowToCommandViewer } from "./containers/HowToCommandViewer";
3
+ export { default as HowToContainer } from "./containers/HowToContainer";
4
+ export { default as HowToListContainer } from "./containers/HowToListContainer";
5
+ export { default as HowToContent } from "./details/HowToContent";
6
+ export { default as HowToDetails } from "./details/HowToDetails";
7
+ export { default as HowToDeleter } from "./forms/HowToDeleter";
8
+ export { default as HowToEditor } from "./forms/HowToEditor";
9
+ export { default as HowToMultiSelector } from "./forms/HowToMultiSelector";
10
+ export { default as HowToSelector } from "./forms/HowToSelector";
11
+ export { default as HowToList } from "./lists/HowToList";