@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
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AbstractApiData } from "../AbstractApiData";
3
+ import { JsonApiHydratedDataInterface } from "../../interfaces/JsonApiHydratedDataInterface";
4
+
5
+ // Concrete test subclass — uses default identifierFields ["name"]
6
+ class DefaultModel extends AbstractApiData {
7
+ rehydrate(data: JsonApiHydratedDataInterface): this {
8
+ super.rehydrate(data);
9
+ return this;
10
+ }
11
+ }
12
+
13
+ // Subclass with custom identifierFields
14
+ class PersonModel extends AbstractApiData {
15
+ rehydrate(data: JsonApiHydratedDataInterface): this {
16
+ super.rehydrate(data);
17
+ return this;
18
+ }
19
+ }
20
+ // Simulate what ModuleFactory does at registration time
21
+ (PersonModel as any).identifierFields = ["first_name", "last_name"];
22
+
23
+ // Subclass with single non-name field
24
+ class OrderModel extends AbstractApiData {
25
+ rehydrate(data: JsonApiHydratedDataInterface): this {
26
+ super.rehydrate(data);
27
+ return this;
28
+ }
29
+ }
30
+ (OrderModel as any).identifierFields = ["number"];
31
+
32
+ function makeHydratedData(attributes: Record<string, any>): JsonApiHydratedDataInterface {
33
+ return {
34
+ jsonApi: {
35
+ type: "test",
36
+ id: "1",
37
+ attributes,
38
+ },
39
+ included: [],
40
+ };
41
+ }
42
+
43
+ describe("AbstractApiData.identifier", () => {
44
+ it("returns name attribute by default", () => {
45
+ const model = new DefaultModel();
46
+ model.rehydrate(makeHydratedData({ name: "Acme Corp" }));
47
+ expect(model.identifier).toBe("Acme Corp");
48
+ });
49
+
50
+ it("joins multiple fields with a space", () => {
51
+ const model = new PersonModel();
52
+ model.rehydrate(makeHydratedData({ first_name: "John", last_name: "Doe" }));
53
+ expect(model.identifier).toBe("John Doe");
54
+ });
55
+
56
+ it("filters out null fields", () => {
57
+ const model = new PersonModel();
58
+ model.rehydrate(makeHydratedData({ first_name: null, last_name: "Doe" }));
59
+ expect(model.identifier).toBe("Doe");
60
+ });
61
+
62
+ it("filters out undefined fields", () => {
63
+ const model = new PersonModel();
64
+ model.rehydrate(makeHydratedData({ last_name: "Doe" }));
65
+ expect(model.identifier).toBe("Doe");
66
+ });
67
+
68
+ it("filters out empty string fields", () => {
69
+ const model = new PersonModel();
70
+ model.rehydrate(makeHydratedData({ first_name: "", last_name: "Doe" }));
71
+ expect(model.identifier).toBe("Doe");
72
+ });
73
+
74
+ it("preserves numeric zero values", () => {
75
+ class ZeroModel extends AbstractApiData {
76
+ rehydrate(data: JsonApiHydratedDataInterface): this {
77
+ super.rehydrate(data);
78
+ return this;
79
+ }
80
+ }
81
+ (ZeroModel as any).identifierFields = ["quantity"];
82
+ const model = new ZeroModel();
83
+ model.rehydrate(makeHydratedData({ quantity: 0 }));
84
+ expect(model.identifier).toBe("0");
85
+ });
86
+
87
+ it("returns single non-name field", () => {
88
+ const model = new OrderModel();
89
+ model.rehydrate(makeHydratedData({ number: "ORD-001" }));
90
+ expect(model.identifier).toBe("ORD-001");
91
+ });
92
+
93
+ it("returns empty string when not rehydrated", () => {
94
+ const model = new DefaultModel();
95
+ expect(model.identifier).toBe("");
96
+ });
97
+
98
+ it("returns empty string when attribute is missing", () => {
99
+ const model = new DefaultModel();
100
+ model.rehydrate(makeHydratedData({}));
101
+ expect(model.identifier).toBe("");
102
+ });
103
+
104
+ it("does not share identifierFields between subclasses", () => {
105
+ const defaultModel = new DefaultModel();
106
+ const personModel = new PersonModel();
107
+ const orderModel = new OrderModel();
108
+
109
+ defaultModel.rehydrate(makeHydratedData({ name: "Test" }));
110
+ personModel.rehydrate(makeHydratedData({ first_name: "John", last_name: "Doe" }));
111
+ orderModel.rehydrate(makeHydratedData({ number: "ORD-001" }));
112
+
113
+ expect(defaultModel.identifier).toBe("Test");
114
+ expect(personModel.identifier).toBe("John Doe");
115
+ expect(orderModel.identifier).toBe("ORD-001");
116
+ });
117
+ });
@@ -37,6 +37,9 @@ class MockDataClass implements Partial<ApiDataInterface> {
37
37
  rehydrate(data: any) {
38
38
  return this;
39
39
  }
40
+ get identifier() {
41
+ return "MockDataClass";
42
+ }
40
43
  }
41
44
 
42
45
  describe("JsonApiDataFactory", () => {
@@ -39,6 +39,9 @@ class MockDataClass implements Partial<ApiDataInterface> {
39
39
  createJsonApi(data: any) {
40
40
  return { type: this.type, attributes: data };
41
41
  }
42
+ get identifier() {
43
+ return "MockDataClass";
44
+ }
42
45
  }
43
46
 
44
47
  describe("RehydrationFactory", () => {
@@ -8,6 +8,7 @@ export interface ApiDataInterface {
8
8
  get updatedAt(): Date;
9
9
  get self(): string | undefined;
10
10
  get jsonApi(): any;
11
+ get identifier(): string;
11
12
  generateApiUrl(params?: any): string;
12
13
  dehydrate(): JsonApiHydratedDataInterface;
13
14
  rehydrate(data: JsonApiHydratedDataInterface): this;
@@ -40,6 +40,9 @@ class MockTaxonomy implements ApiDataInterface {
40
40
  dehydrate(): any {
41
41
  return {};
42
42
  }
43
+ get identifier(): string {
44
+ return "MockTaxonomy";
45
+ }
43
46
  }
44
47
 
45
48
  class MockLeafTaxonomy implements ApiDataInterface {
@@ -79,6 +82,9 @@ class MockLeafTaxonomy implements ApiDataInterface {
79
82
  dehydrate(): any {
80
83
  return {};
81
84
  }
85
+ get identifier(): string {
86
+ return "MockLeafTaxonomy";
87
+ }
82
88
  }
83
89
 
84
90
  describe("DataClassRegistry.getByJsonApiType", () => {
@@ -34,6 +34,9 @@ class MockArticle implements Partial<ApiDataInterface> {
34
34
  createJsonApi(data: any) {
35
35
  return { type: this.type, attributes: data };
36
36
  }
37
+ get identifier() {
38
+ return "MockArticle";
39
+ }
37
40
  }
38
41
 
39
42
  describe("DataClassRegistry", () => {
@@ -34,6 +34,9 @@ class MockUser implements Partial<ApiDataInterface> {
34
34
  createJsonApi(data: any) {
35
35
  return { type: this.type, attributes: data };
36
36
  }
37
+ get identifier() {
38
+ return "MockUser";
39
+ }
37
40
  }
38
41
 
39
42
  class MockArticle implements Partial<ApiDataInterface> {
@@ -66,6 +69,9 @@ class MockArticle implements Partial<ApiDataInterface> {
66
69
  createJsonApi(data: any) {
67
70
  return { type: this.type, attributes: data };
68
71
  }
72
+ get identifier() {
73
+ return "MockArticle";
74
+ }
69
75
  }
70
76
 
71
77
  // Mock Modules class with static getters
@@ -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
  }
@@ -13,6 +13,7 @@ const mockModule: ModuleInterface = {
13
13
  included: [],
14
14
  createdAt: new Date(),
15
15
  updatedAt: new Date(),
16
+ identifier: "pipelines",
16
17
  name: "pipelines",
17
18
  permissions: { create: true, read: true, update: true, delete: false },
18
19
  } as ModuleInterface;
@@ -24,6 +25,7 @@ const mockFeatures: FeatureInterface[] = [
24
25
  included: [],
25
26
  createdAt: new Date(),
26
27
  updatedAt: new Date(),
28
+ identifier: "CRM",
27
29
  name: "CRM",
28
30
  isCore: false,
29
31
  modules: [mockModule],
@@ -1,41 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { Loader2 } from "lucide-react";
4
3
  import { useTranslations } from "next-intl";
5
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
- import { useWatch } from "react-hook-form";
7
- import { FormFieldWrapper } from "../../../../components/forms";
4
+ import { EntityMultiSelector } from "../../../../components/forms/EntityMultiSelector";
8
5
  import { Modules } from "../../../../core";
9
- import { DataListRetriever, useDataListRetriever, useDebounce } from "../../../../hooks";
10
- import { Avatar, AvatarFallback, AvatarImage, MultipleSelector } from "../../../../shadcnui";
11
- import { Option } from "../../../../shadcnui";
6
+ import { Avatar, AvatarFallback, AvatarImage } from "../../../../shadcnui";
12
7
  import { useCurrentUserContext } from "../../contexts";
13
8
  import { UserInterface } from "../../data";
14
9
  import { UserService } from "../../data/user.service";
15
10
 
16
- // Type for user objects in the form
17
- type UserSelectType = {
18
- id: string;
19
- name: string;
20
- avatar?: string;
21
- };
22
-
23
- type UserMultiSelectProps = {
24
- id: string;
25
- form: any;
26
- currentUser?: UserInterface;
27
- label?: string;
28
- placeholder?: string;
29
- onChange?: (users?: UserInterface[]) => void;
30
- maxCount?: number;
31
- isRequired?: boolean;
32
- };
33
-
34
- type UserOption = Option & {
35
- userData?: UserInterface;
36
- avatar?: string;
37
- };
38
-
39
11
  function UserAvatarIcon({ className, url, name }: { className?: string; url?: string; name?: string }) {
40
12
  return (
41
13
  <Avatar className={`${className || "h-4 w-4"}`}>
@@ -52,6 +24,17 @@ function UserAvatarIcon({ className, url, name }: { className?: string; url?: st
52
24
  );
53
25
  }
54
26
 
27
+ type UserMultiSelectProps = {
28
+ id: string;
29
+ form: any;
30
+ currentUser?: UserInterface;
31
+ label?: string;
32
+ placeholder?: string;
33
+ onChange?: (users?: UserInterface[]) => void;
34
+ maxCount?: number;
35
+ isRequired?: boolean;
36
+ };
37
+
55
38
  export function UserMultiSelect({
56
39
  id,
57
40
  form,
@@ -59,162 +42,32 @@ export function UserMultiSelect({
59
42
  label,
60
43
  placeholder,
61
44
  onChange,
62
- maxCount = 3,
63
45
  isRequired = false,
64
46
  }: UserMultiSelectProps) {
65
47
  const t = useTranslations();
66
48
  const { company } = useCurrentUserContext<UserInterface>();
67
49
 
68
- const searchTermRef = useRef<string>("");
69
- const [searchTerm, _setSearchTerm] = useState<string>("");
70
- const [isSearching, setIsSearching] = useState<boolean>(false);
71
- const [userOptions, setUserOptions] = useState<UserOption[]>([]);
72
-
73
- // Get the current selected users from the form
74
- const selectedUsers: UserSelectType[] = useWatch({ control: form.control, name: id }) || [];
75
-
76
- const data: DataListRetriever<UserInterface> = useDataListRetriever({
77
- ready: !!company,
78
- retriever: (params) => {
79
- return UserService.findAllUsers(params);
80
- },
81
- retrieverParams: { companyId: company?.id },
82
- module: Modules.User,
83
- }) as DataListRetriever<UserInterface>;
84
-
85
- useEffect(() => {
86
- if (company) data.setReady(true);
87
- }, [company]);
88
-
89
- const search = useCallback(
90
- async (searchedTerm: string) => {
91
- try {
92
- if (searchedTerm === searchTermRef.current) return;
93
- setIsSearching(true);
94
- searchTermRef.current = searchedTerm;
95
- await data.search(searchedTerm);
96
- } finally {
97
- setIsSearching(false);
98
- }
99
- },
100
- [searchTermRef, data],
101
- );
102
-
103
- const updateSearchTerm = useDebounce(search, 500);
104
-
105
- useEffect(() => {
106
- setIsSearching(true);
107
- updateSearchTerm(searchTerm);
108
- }, [updateSearchTerm, searchTerm]);
109
-
110
- // Update userOptions when data changes or when initial selected users are available
111
- useEffect(() => {
112
- if (data.data && data.data.length > 0) {
113
- const users = data.data as UserInterface[];
114
- const filteredUsers = users.filter((user) => user.id !== currentUser?.id);
115
-
116
- const options: UserOption[] = filteredUsers.map((user) => ({
117
- label: user.name,
118
- value: user.id,
119
- userData: user,
120
- avatar: user.avatar,
121
- }));
122
-
123
- // Add options for any already selected users that aren't in search results
124
- const existingOptionIds = new Set(options.map((option) => option.value));
125
- const missingOptions: UserOption[] = selectedUsers
126
- .filter((user) => !existingOptionIds.has(user.id))
127
- .map((user) => ({
128
- label: user.name,
129
- value: user.id,
130
- userData: user as unknown as UserInterface,
131
- avatar: user.avatar,
132
- }));
133
-
134
- setUserOptions([...options, ...missingOptions]);
135
- }
136
- }, [data.data, currentUser, selectedUsers]);
137
-
138
- // Convert selected users to Option[] format
139
- const selectedOptions = useMemo(() => {
140
- return selectedUsers.map((user) => ({
141
- value: user.id,
142
- label: user.name,
143
- }));
144
- }, [selectedUsers]);
145
-
146
- const handleChange = (options: Option[]) => {
147
- // Convert to form format
148
- const formValues = options.map((option) => {
149
- const userOption = userOptions.find((opt) => opt.value === option.value);
150
- return {
151
- id: option.value,
152
- name: option.label,
153
- avatar: userOption?.avatar,
154
- };
155
- });
156
-
157
- form.setValue(id, formValues, { shouldDirty: true, shouldTouch: true });
158
-
159
- if (onChange) {
160
- // Get full user data for onChange callback
161
- const fullUsers = options
162
- .map((option) => {
163
- const userOption = userOptions.find((opt) => opt.value === option.value);
164
- return userOption?.userData;
165
- })
166
- .filter(Boolean) as UserInterface[];
167
- onChange(fullUsers);
168
- }
169
- };
170
-
171
- // Custom render function for dropdown options (with avatar)
172
- const renderOption = (option: Option) => {
173
- const userOption = option as UserOption;
174
- return (
175
- <span className="flex items-center gap-2">
176
- <UserAvatarIcon url={userOption.avatar} name={option.label} />
177
- {option.label}
178
- </span>
179
- );
180
- };
181
-
182
- // Search handler
183
- const handleSearchSync = (search: string): Option[] => {
184
- _setSearchTerm(search);
185
- return userOptions;
186
- };
187
-
188
50
  return (
189
- <div className="flex w-full flex-col">
190
- <FormFieldWrapper form={form} name={id} label={label} isRequired={isRequired}>
191
- {() => (
192
- <MultipleSelector
193
- value={selectedOptions}
194
- onChange={handleChange}
195
- options={userOptions}
196
- placeholder={placeholder}
197
- maxDisplayCount={maxCount}
198
- hideClearAllButton
199
- onSearchSync={handleSearchSync}
200
- delay={0}
201
- renderOption={renderOption}
202
- loadingIndicator={
203
- isSearching ? (
204
- <div className="flex items-center justify-center py-2">
205
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
206
- <span className="ml-2 text-sm text-muted-foreground">{t("ui.search.button")}</span>
207
- </div>
208
- ) : undefined
209
- }
210
- emptyIndicator={
211
- <span className="text-muted-foreground">
212
- {t("ui.search.no_results", { type: t("entities.users", { count: 2 }) })}
213
- </span>
214
- }
215
- />
216
- )}
217
- </FormFieldWrapper>
218
- </div>
51
+ <EntityMultiSelector<UserInterface>
52
+ id={id}
53
+ form={form}
54
+ label={label}
55
+ placeholder={placeholder || t("ui.search.button")}
56
+ emptyText={t("ui.search.no_results", { type: t("entities.users", { count: 2 }) })}
57
+ isRequired={isRequired}
58
+ retriever={(params) => UserService.findAllUsers(params)}
59
+ retrieverParams={{ companyId: company?.id }}
60
+ module={Modules.User}
61
+ getLabel={(user) => user.name}
62
+ toFormValue={(user) => ({ id: user.id, name: user.name, avatar: user.avatar })}
63
+ excludeId={currentUser?.id}
64
+ onChange={onChange}
65
+ renderOption={(user) => (
66
+ <span className="flex items-center gap-2">
67
+ <UserAvatarIcon url={user.avatar} name={user.name} />
68
+ {user.name}
69
+ </span>
70
+ )}
71
+ />
219
72
  );
220
73
  }
@@ -103,6 +103,7 @@ export type ModuleFactory = (params: {
103
103
  feature?: string;
104
104
  moduleId?: string;
105
105
  icon?: LucideIcon;
106
+ identifier?: string[];
106
107
  inclusions?: Record<
107
108
  string,
108
109
  {
@@ -103,6 +103,13 @@ export function createMockApiData(options: CreateMockApiDataOptions): ApiDataInt
103
103
  id,
104
104
  attributes: data,
105
105
  }),
106
+ get identifier() {
107
+ const identifierFields = ["name"];
108
+ return identifierFields
109
+ .map((field) => attributes[field])
110
+ .filter((v) => v != null && v !== "")
111
+ .join(" ");
112
+ },
106
113
  };
107
114
 
108
115
  // Add attribute accessors to the mock object