@carlonicora/nextjs-jsonapi 1.73.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 (105) hide show
  1. package/dist/{ApiDataInterface-DPP8s46n.d.mts → ApiDataInterface-BcZeXy5X.d.mts} +1 -0
  2. package/dist/{ApiDataInterface-DPP8s46n.d.ts → ApiDataInterface-BcZeXy5X.d.ts} +1 -0
  3. package/dist/{ApiResponseInterface-CAIAeP5d.d.ts → ApiResponseInterface-CWLvSCvS.d.ts} +1 -1
  4. package/dist/{ApiResponseInterface-zeewugD7.d.mts → ApiResponseInterface-rsXRL_Hn.d.mts} +1 -1
  5. package/dist/{AuthComponent-Di8DsZ2I.d.ts → AuthComponent-Blbs06ud.d.ts} +2 -2
  6. package/dist/{AuthComponent-DXe3kPzb.d.mts → AuthComponent-huIaK5rm.d.mts} +2 -2
  7. package/dist/{BlockNoteEditor-MZ6G4XN4.mjs → BlockNoteEditor-NJMTHPO4.mjs} +4 -4
  8. package/dist/{BlockNoteEditor-ETTYTXDX.js → BlockNoteEditor-SLT4VOLL.js} +14 -14
  9. package/dist/{BlockNoteEditor-ETTYTXDX.js.map → BlockNoteEditor-SLT4VOLL.js.map} +1 -1
  10. package/dist/{HowToInterface-NaqSG9sE.d.ts → HowToInterface-BKhnkzBp.d.ts} +1 -1
  11. package/dist/{HowToInterface-DtVWAE1s.d.mts → HowToInterface-Cj8OuQFf.d.mts} +1 -1
  12. package/dist/{ModulePathsInterface-49EWvbWy.d.mts → ModulePathsInterface-BrdqgteS.d.mts} +1 -1
  13. package/dist/{ModulePathsInterface-wVS5Raa4.d.ts → ModulePathsInterface-DJKs7s_s.d.ts} +1 -1
  14. package/dist/{auth.interface-C4uJzBec.d.mts → auth.interface-Bdq7-8iV.d.mts} +2 -2
  15. package/dist/{auth.interface-BTco8PWs.d.ts → auth.interface-CQJ6A2Cj.d.ts} +2 -2
  16. package/dist/billing/index.d.mts +3 -3
  17. package/dist/billing/index.d.ts +3 -3
  18. package/dist/billing/index.js +346 -346
  19. package/dist/billing/index.mjs +3 -3
  20. package/dist/{chunk-OPUWDWFH.js → chunk-DTE6RZXF.js} +1208 -1128
  21. package/dist/chunk-DTE6RZXF.js.map +1 -0
  22. package/dist/{chunk-QLICTZL7.js → chunk-FKLP4NED.js} +134 -129
  23. package/dist/chunk-FKLP4NED.js.map +1 -0
  24. package/dist/{chunk-HP6AJBWE.mjs → chunk-JOJZRGZL.mjs} +2 -2
  25. package/dist/{chunk-5QTDS6V7.js → chunk-OTZEXASK.js} +11 -11
  26. package/dist/{chunk-5QTDS6V7.js.map → chunk-OTZEXASK.js.map} +1 -1
  27. package/dist/{chunk-6O3YOOQM.mjs → chunk-Q7JKB777.mjs} +2383 -2303
  28. package/dist/chunk-Q7JKB777.mjs.map +1 -0
  29. package/dist/{chunk-73ANSE3J.mjs → chunk-XI35ALWY.mjs} +6 -1
  30. package/dist/chunk-XI35ALWY.mjs.map +1 -0
  31. package/dist/client/index.d.mts +10 -10
  32. package/dist/client/index.d.ts +10 -10
  33. package/dist/client/index.js +4 -4
  34. package/dist/client/index.mjs +3 -3
  35. package/dist/components/index.d.mts +39 -13
  36. package/dist/components/index.d.ts +39 -13
  37. package/dist/components/index.js +6 -4
  38. package/dist/components/index.js.map +1 -1
  39. package/dist/components/index.mjs +5 -3
  40. package/dist/{config-n0lfSf27.d.ts → config-B3jKt9P7.d.ts} +1 -1
  41. package/dist/{config-Bmr_0qTn.d.mts → config-DkHF61xA.d.mts} +1 -1
  42. package/dist/contexts/index.d.mts +5 -5
  43. package/dist/contexts/index.d.ts +5 -5
  44. package/dist/contexts/index.js +4 -4
  45. package/dist/contexts/index.mjs +3 -3
  46. package/dist/core/index.d.mts +19 -17
  47. package/dist/core/index.d.ts +19 -17
  48. package/dist/core/index.js +2 -2
  49. package/dist/core/index.mjs +1 -1
  50. package/dist/{feature.interface-CIWxo8NP.d.ts → feature.interface-BO25VLlx.d.ts} +1 -1
  51. package/dist/{feature.interface-BxFFOPNq.d.mts → feature.interface-CXb1-vNq.d.mts} +1 -1
  52. package/dist/index.d.mts +17 -17
  53. package/dist/index.d.ts +17 -17
  54. package/dist/index.js +3 -3
  55. package/dist/index.mjs +2 -2
  56. package/dist/{notification.interface-DrHu_1MM.d.mts → notification.interface-DG6obXUH.d.mts} +3 -2
  57. package/dist/{notification.interface-DYDZENx2.d.ts → notification.interface-DcSuc9CL.d.ts} +3 -2
  58. package/dist/{oauth.interface-vL7za9Bz.d.ts → oauth.interface-B6xmfDzK.d.ts} +1 -1
  59. package/dist/{oauth.interface-DsZ5ecSX.d.mts → oauth.interface-o5FLpiN7.d.mts} +1 -1
  60. package/dist/{s3.service-TsN2unZr.d.mts → s3.service-DGilbikH.d.mts} +4 -4
  61. package/dist/{s3.service-DK2KKXbR.d.ts → s3.service-DjwEQJPe.d.ts} +4 -4
  62. package/dist/scripts/generate-web-module/templates/components/multi-selector.template.d.ts.map +1 -1
  63. package/dist/scripts/generate-web-module/templates/components/multi-selector.template.js +14 -120
  64. package/dist/scripts/generate-web-module/templates/components/multi-selector.template.js.map +1 -1
  65. package/dist/server/index.d.mts +6 -6
  66. package/dist/server/index.d.ts +6 -6
  67. package/dist/server/index.js +3 -3
  68. package/dist/server/index.mjs +1 -1
  69. package/dist/{stripe-promotion-code.interface-BcJty0rv.d.ts → stripe-promotion-code.interface-C3qqh3mi.d.ts} +2 -2
  70. package/dist/{stripe-promotion-code.interface-Dnm2DJKQ.d.mts → stripe-promotion-code.interface-ClZ7DxS9.d.mts} +2 -2
  71. package/dist/testing/index.d.mts +2 -2
  72. package/dist/testing/index.d.ts +2 -2
  73. package/dist/testing/index.js +5 -1
  74. package/dist/testing/index.js.map +1 -1
  75. package/dist/testing/index.mjs +5 -1
  76. package/dist/testing/index.mjs.map +1 -1
  77. package/dist/{useRbacState-BYaSdA78.d.ts → useRbacState-C88O-5L8.d.ts} +3 -3
  78. package/dist/{useRbacState-CQEJ_ysV.d.mts → useRbacState-mqYiRp3J.d.mts} +3 -3
  79. package/dist/{useSocket-Cjt_qvkI.d.ts → useSocket-8eUtnL7J.d.ts} +1 -1
  80. package/dist/{useSocket-VAGetcT3.d.mts → useSocket-CmzVtg32.d.mts} +1 -1
  81. package/package.json +1 -1
  82. package/scripts/generate-web-module/templates/components/multi-selector.template.ts +14 -120
  83. package/src/client/hooks/__tests__/useRehydration.test.ts +3 -0
  84. package/src/components/forms/EntityMultiSelector.tsx +325 -0
  85. package/src/components/forms/index.ts +1 -0
  86. package/src/components/navigations/RecentPagesNavigator.tsx +16 -13
  87. package/src/core/abstracts/AbstractApiData.ts +10 -0
  88. package/src/core/abstracts/__tests__/identifier.spec.ts +117 -0
  89. package/src/core/factories/__tests__/JsonApiDataFactory.test.ts +3 -0
  90. package/src/core/factories/__tests__/RehydrationFactory.test.ts +3 -0
  91. package/src/core/interfaces/ApiDataInterface.ts +1 -0
  92. package/src/core/registry/__tests__/DataClassRegistry.getByJsonApiType.spec.ts +6 -0
  93. package/src/core/registry/__tests__/DataClassRegistry.test.ts +3 -0
  94. package/src/core/registry/__tests__/ModuleRegistrar.test.ts +6 -0
  95. package/src/features/how-to/components/forms/HowToMultiSelector.tsx +14 -120
  96. package/src/features/rbac/hooks/useRbacState.test.ts +2 -0
  97. package/src/features/user/components/forms/UserMultiSelect.tsx +34 -181
  98. package/src/permissions/types.ts +1 -0
  99. package/src/testing/factories/createMockApiData.ts +7 -0
  100. package/dist/chunk-6O3YOOQM.mjs.map +0 -1
  101. package/dist/chunk-73ANSE3J.mjs.map +0 -1
  102. package/dist/chunk-OPUWDWFH.js.map +0 -1
  103. package/dist/chunk-QLICTZL7.js.map +0 -1
  104. /package/dist/{BlockNoteEditor-MZ6G4XN4.mjs.map → BlockNoteEditor-NJMTHPO4.mjs.map} +0 -0
  105. /package/dist/{chunk-HP6AJBWE.mjs.map → chunk-JOJZRGZL.mjs.map} +0 -0
@@ -1,6 +1,6 @@
1
- import { F as FeatureInterface } from './feature.interface-CIWxo8NP.js';
2
- import { R as RoleInterface } from './notification.interface-DYDZENx2.js';
3
- import { P as PermissionMappingInterface, M as ModulePathsInterface, A as ActionType, a as PermissionValue, b as PermissionsMap } from './ModulePathsInterface-wVS5Raa4.js';
1
+ import { F as FeatureInterface } from './feature.interface-BO25VLlx.js';
2
+ import { R as RoleInterface } from './notification.interface-DcSuc9CL.js';
3
+ import { P as PermissionMappingInterface, M as ModulePathsInterface, A as ActionType, a as PermissionValue, b as PermissionsMap } from './ModulePathsInterface-DJKs7s_s.js';
4
4
 
5
5
  type PageInfo = {
6
6
  startItem: number;
@@ -1,6 +1,6 @@
1
- import { F as FeatureInterface } from './feature.interface-BxFFOPNq.mjs';
2
- import { R as RoleInterface } from './notification.interface-DrHu_1MM.mjs';
3
- import { P as PermissionMappingInterface, M as ModulePathsInterface, A as ActionType, a as PermissionValue, b as PermissionsMap } from './ModulePathsInterface-49EWvbWy.mjs';
1
+ import { F as FeatureInterface } from './feature.interface-CXb1-vNq.mjs';
2
+ import { R as RoleInterface } from './notification.interface-DG6obXUH.mjs';
3
+ import { P as PermissionMappingInterface, M as ModulePathsInterface, A as ActionType, a as PermissionValue, b as PermissionsMap } from './ModulePathsInterface-BrdqgteS.mjs';
4
4
 
5
5
  type PageInfo = {
6
6
  startItem: number;
@@ -1,4 +1,4 @@
1
- import { N as NotificationInterface } from './notification.interface-DYDZENx2.js';
1
+ import { N as NotificationInterface } from './notification.interface-DcSuc9CL.js';
2
2
 
3
3
  interface UseSocketOptions {
4
4
  token: string;
@@ -1,4 +1,4 @@
1
- import { N as NotificationInterface } from './notification.interface-DrHu_1MM.mjs';
1
+ import { N as NotificationInterface } from './notification.interface-DG6obXUH.mjs';
2
2
 
3
3
  interface UseSocketOptions {
4
4
  token: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "1.73.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
  `;
@@ -40,6 +40,9 @@ class MockArticle implements Partial<ApiDataInterface> {
40
40
  createJsonApi(data: any) {
41
41
  return { type: this.type, attributes: data };
42
42
  }
43
+ get identifier() {
44
+ return "MockArticle";
45
+ }
43
46
  }
44
47
 
45
48
  const mockModule = { name: "articles", model: MockArticle };
@@ -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";
@@ -7,6 +7,7 @@ import { recentPagesAtom } from "../../atoms";
7
7
  import {
8
8
  DropdownMenu,
9
9
  DropdownMenuContent,
10
+ DropdownMenuGroup,
10
11
  DropdownMenuItem,
11
12
  DropdownMenuLabel,
12
13
  DropdownMenuSeparator,
@@ -32,20 +33,22 @@ export function RecentPagesNavigator() {
32
33
  </span>
33
34
  </DropdownMenuTrigger>
34
35
  <DropdownMenuContent align="start" className="w-96">
35
- <DropdownMenuLabel>{t(`common.recent_pages`)}</DropdownMenuLabel>
36
- <DropdownMenuSeparator />
37
- {recentPages.map((page, index) => (
38
- <DropdownMenuItem key={`${page.url}-${index}`}>
39
- <Link href={page.url} className="flex items-center gap-2">
40
- <div className="flex flex-col">
41
- <div className="truncate text-sm">{page.title}</div>
42
- <div className="text-muted-foreground text-xs font-normal">
43
- {t(`entities.${page.moduleType}`, { count: 1 })}
36
+ <DropdownMenuGroup>
37
+ <DropdownMenuLabel>{t(`common.recent_pages`)}</DropdownMenuLabel>
38
+ <DropdownMenuSeparator />
39
+ {recentPages.map((page, index) => (
40
+ <DropdownMenuItem key={`${page.url}-${index}`}>
41
+ <Link href={page.url} className="flex items-center gap-2">
42
+ <div className="flex flex-col">
43
+ <div className="truncate text-sm">{page.title}</div>
44
+ <div className="text-muted-foreground text-xs font-normal">
45
+ {t(`entities.${page.moduleType}`, { count: 1 })}
46
+ </div>
44
47
  </div>
45
- </div>
46
- </Link>
47
- </DropdownMenuItem>
48
- ))}
48
+ </Link>
49
+ </DropdownMenuItem>
50
+ ))}
51
+ </DropdownMenuGroup>
49
52
  </DropdownMenuContent>
50
53
  </DropdownMenu>
51
54
  );
@@ -4,6 +4,16 @@ import { ApiRequestDataTypeInterface } from "../interfaces/ApiRequestDataTypeInt
4
4
  import { JsonApiHydratedDataInterface } from "../interfaces/JsonApiHydratedDataInterface";
5
5
 
6
6
  export abstract class AbstractApiData implements ApiDataInterface {
7
+ static identifierFields: string[] = ["name"];
8
+
9
+ get identifier(): string {
10
+ const fields = (this.constructor as typeof AbstractApiData).identifierFields;
11
+ return fields
12
+ .map((field) => this._jsonApi?.attributes?.[field])
13
+ .filter((v) => v != null && v !== "")
14
+ .join(" ");
15
+ }
16
+
7
17
  protected _jsonApi?: any;
8
18
  protected _included?: any[];
9
19