@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.
- package/dist/{ApiDataInterface-DPP8s46n.d.mts → ApiDataInterface-BcZeXy5X.d.mts} +1 -0
- package/dist/{ApiDataInterface-DPP8s46n.d.ts → ApiDataInterface-BcZeXy5X.d.ts} +1 -0
- package/dist/{ApiResponseInterface-CAIAeP5d.d.ts → ApiResponseInterface-CWLvSCvS.d.ts} +1 -1
- package/dist/{ApiResponseInterface-zeewugD7.d.mts → ApiResponseInterface-rsXRL_Hn.d.mts} +1 -1
- package/dist/{AuthComponent-Di8DsZ2I.d.ts → AuthComponent-Blbs06ud.d.ts} +2 -2
- package/dist/{AuthComponent-DXe3kPzb.d.mts → AuthComponent-huIaK5rm.d.mts} +2 -2
- package/dist/{BlockNoteEditor-MZ6G4XN4.mjs → BlockNoteEditor-NJMTHPO4.mjs} +4 -4
- package/dist/{BlockNoteEditor-ETTYTXDX.js → BlockNoteEditor-SLT4VOLL.js} +14 -14
- package/dist/{BlockNoteEditor-ETTYTXDX.js.map → BlockNoteEditor-SLT4VOLL.js.map} +1 -1
- package/dist/{HowToInterface-NaqSG9sE.d.ts → HowToInterface-BKhnkzBp.d.ts} +1 -1
- package/dist/{HowToInterface-DtVWAE1s.d.mts → HowToInterface-Cj8OuQFf.d.mts} +1 -1
- package/dist/{ModulePathsInterface-49EWvbWy.d.mts → ModulePathsInterface-BrdqgteS.d.mts} +1 -1
- package/dist/{ModulePathsInterface-wVS5Raa4.d.ts → ModulePathsInterface-DJKs7s_s.d.ts} +1 -1
- package/dist/{auth.interface-C4uJzBec.d.mts → auth.interface-Bdq7-8iV.d.mts} +2 -2
- package/dist/{auth.interface-BTco8PWs.d.ts → auth.interface-CQJ6A2Cj.d.ts} +2 -2
- package/dist/billing/index.d.mts +3 -3
- package/dist/billing/index.d.ts +3 -3
- package/dist/billing/index.js +346 -346
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-OPUWDWFH.js → chunk-DTE6RZXF.js} +1208 -1128
- package/dist/chunk-DTE6RZXF.js.map +1 -0
- package/dist/{chunk-QLICTZL7.js → chunk-FKLP4NED.js} +134 -129
- package/dist/chunk-FKLP4NED.js.map +1 -0
- package/dist/{chunk-HP6AJBWE.mjs → chunk-JOJZRGZL.mjs} +2 -2
- package/dist/{chunk-5QTDS6V7.js → chunk-OTZEXASK.js} +11 -11
- package/dist/{chunk-5QTDS6V7.js.map → chunk-OTZEXASK.js.map} +1 -1
- package/dist/{chunk-6O3YOOQM.mjs → chunk-Q7JKB777.mjs} +2383 -2303
- package/dist/chunk-Q7JKB777.mjs.map +1 -0
- package/dist/{chunk-73ANSE3J.mjs → chunk-XI35ALWY.mjs} +6 -1
- package/dist/chunk-XI35ALWY.mjs.map +1 -0
- package/dist/client/index.d.mts +10 -10
- package/dist/client/index.d.ts +10 -10
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +39 -13
- package/dist/components/index.d.ts +39 -13
- package/dist/components/index.js +6 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +5 -3
- package/dist/{config-n0lfSf27.d.ts → config-B3jKt9P7.d.ts} +1 -1
- package/dist/{config-Bmr_0qTn.d.mts → config-DkHF61xA.d.mts} +1 -1
- package/dist/contexts/index.d.mts +5 -5
- package/dist/contexts/index.d.ts +5 -5
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +19 -17
- package/dist/core/index.d.ts +19 -17
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/{feature.interface-CIWxo8NP.d.ts → feature.interface-BO25VLlx.d.ts} +1 -1
- package/dist/{feature.interface-BxFFOPNq.d.mts → feature.interface-CXb1-vNq.d.mts} +1 -1
- package/dist/index.d.mts +17 -17
- package/dist/index.d.ts +17 -17
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/{notification.interface-DrHu_1MM.d.mts → notification.interface-DG6obXUH.d.mts} +3 -2
- package/dist/{notification.interface-DYDZENx2.d.ts → notification.interface-DcSuc9CL.d.ts} +3 -2
- package/dist/{oauth.interface-vL7za9Bz.d.ts → oauth.interface-B6xmfDzK.d.ts} +1 -1
- package/dist/{oauth.interface-DsZ5ecSX.d.mts → oauth.interface-o5FLpiN7.d.mts} +1 -1
- package/dist/{s3.service-TsN2unZr.d.mts → s3.service-DGilbikH.d.mts} +4 -4
- package/dist/{s3.service-DK2KKXbR.d.ts → s3.service-DjwEQJPe.d.ts} +4 -4
- package/dist/scripts/generate-web-module/templates/components/multi-selector.template.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/multi-selector.template.js +14 -120
- package/dist/scripts/generate-web-module/templates/components/multi-selector.template.js.map +1 -1
- package/dist/server/index.d.mts +6 -6
- package/dist/server/index.d.ts +6 -6
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/dist/{stripe-promotion-code.interface-BcJty0rv.d.ts → stripe-promotion-code.interface-C3qqh3mi.d.ts} +2 -2
- package/dist/{stripe-promotion-code.interface-Dnm2DJKQ.d.mts → stripe-promotion-code.interface-ClZ7DxS9.d.mts} +2 -2
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +5 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/index.mjs +5 -1
- package/dist/testing/index.mjs.map +1 -1
- package/dist/{useRbacState-BYaSdA78.d.ts → useRbacState-C88O-5L8.d.ts} +3 -3
- package/dist/{useRbacState-CQEJ_ysV.d.mts → useRbacState-mqYiRp3J.d.mts} +3 -3
- package/dist/{useSocket-Cjt_qvkI.d.ts → useSocket-8eUtnL7J.d.ts} +1 -1
- package/dist/{useSocket-VAGetcT3.d.mts → useSocket-CmzVtg32.d.mts} +1 -1
- package/package.json +1 -1
- package/scripts/generate-web-module/templates/components/multi-selector.template.ts +14 -120
- package/src/client/hooks/__tests__/useRehydration.test.ts +3 -0
- package/src/components/forms/EntityMultiSelector.tsx +325 -0
- package/src/components/forms/index.ts +1 -0
- package/src/components/navigations/RecentPagesNavigator.tsx +16 -13
- package/src/core/abstracts/AbstractApiData.ts +10 -0
- package/src/core/abstracts/__tests__/identifier.spec.ts +117 -0
- package/src/core/factories/__tests__/JsonApiDataFactory.test.ts +3 -0
- package/src/core/factories/__tests__/RehydrationFactory.test.ts +3 -0
- package/src/core/interfaces/ApiDataInterface.ts +1 -0
- package/src/core/registry/__tests__/DataClassRegistry.getByJsonApiType.spec.ts +6 -0
- package/src/core/registry/__tests__/DataClassRegistry.test.ts +3 -0
- package/src/core/registry/__tests__/ModuleRegistrar.test.ts +6 -0
- package/src/features/how-to/components/forms/HowToMultiSelector.tsx +14 -120
- package/src/features/rbac/hooks/useRbacState.test.ts +2 -0
- package/src/features/user/components/forms/UserMultiSelect.tsx +34 -181
- package/src/permissions/types.ts +1 -0
- package/src/testing/factories/createMockApiData.ts +7 -0
- package/dist/chunk-6O3YOOQM.mjs.map +0 -1
- package/dist/chunk-73ANSE3J.mjs.map +0 -1
- package/dist/chunk-OPUWDWFH.js.map +0 -1
- package/dist/chunk-QLICTZL7.js.map +0 -1
- /package/dist/{BlockNoteEditor-MZ6G4XN4.mjs.map → BlockNoteEditor-NJMTHPO4.mjs.map} +0 -0
- /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
|
+
});
|
|
@@ -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 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 {
|
|
5
|
-
import { useWatch } from "react-hook-form";
|
|
6
|
-
|
|
7
|
-
import { FormFieldWrapper, MultipleSelector, Option } from "../../../../components";
|
|
4
|
+
import { EntityMultiSelector } from "../../../../components/forms/EntityMultiSelector";
|
|
8
5
|
import { Modules } from "../../../../core";
|
|
9
|
-
import { DataListRetriever, useDataListRetriever, useDebounce } from "../../../../hooks";
|
|
10
6
|
import { HowToInterface } from "../../data/HowToInterface";
|
|
11
7
|
import { HowToService } from "../../data/HowToService";
|
|
12
8
|
|
|
13
|
-
type HowToMultiSelectType = {
|
|
14
|
-
id: string;
|
|
15
|
-
name: string;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
9
|
type HowToMultiSelectorProps = {
|
|
19
10
|
id: string;
|
|
20
11
|
form: any;
|
|
@@ -26,10 +17,6 @@ type HowToMultiSelectorProps = {
|
|
|
26
17
|
isRequired?: boolean;
|
|
27
18
|
};
|
|
28
19
|
|
|
29
|
-
type HowToOption = Option & {
|
|
30
|
-
howToData?: HowToInterface;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
20
|
export default function HowToMultiSelector({
|
|
34
21
|
id,
|
|
35
22
|
form,
|
|
@@ -37,116 +24,23 @@ export default function HowToMultiSelector({
|
|
|
37
24
|
label,
|
|
38
25
|
placeholder,
|
|
39
26
|
onChange,
|
|
40
|
-
maxCount = 3,
|
|
41
27
|
isRequired = false,
|
|
42
28
|
}: HowToMultiSelectorProps) {
|
|
43
29
|
const t = useTranslations();
|
|
44
|
-
const [howToOptions, setHowToOptions] = useState<HowToOption[]>([]);
|
|
45
|
-
const [searchTerm, setSearchTerm] = useState<string>("");
|
|
46
|
-
|
|
47
|
-
const selectedHowTos: HowToMultiSelectType[] = useWatch({ control: form.control, name: id }) || [];
|
|
48
|
-
|
|
49
|
-
const data: DataListRetriever<HowToInterface> = useDataListRetriever({
|
|
50
|
-
retriever: (params) => HowToService.findMany(params),
|
|
51
|
-
retrieverParams: {},
|
|
52
|
-
ready: true,
|
|
53
|
-
module: Modules.HowTo,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const updateSearch = useCallback(
|
|
57
|
-
(searchedTerm: string) => {
|
|
58
|
-
if (searchedTerm.trim()) {
|
|
59
|
-
data.addAdditionalParameter("search", searchedTerm.trim());
|
|
60
|
-
} else {
|
|
61
|
-
data.removeAdditionalParameter("search");
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
[data],
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
const debouncedUpdateSearch = useDebounce(updateSearch, 500);
|
|
68
|
-
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
debouncedUpdateSearch(searchTerm);
|
|
71
|
-
}, [debouncedUpdateSearch, searchTerm]);
|
|
72
|
-
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
if (data.data && data.data.length > 0) {
|
|
75
|
-
const howTos = data.data as HowToInterface[];
|
|
76
|
-
const filteredHowTos = howTos.filter((howTo) => howTo.id !== currentHowTo?.id);
|
|
77
|
-
|
|
78
|
-
const options: HowToOption[] = filteredHowTos.map((howTo) => ({
|
|
79
|
-
label: howTo.name,
|
|
80
|
-
value: howTo.id,
|
|
81
|
-
howToData: howTo,
|
|
82
|
-
}));
|
|
83
|
-
|
|
84
|
-
// Add options for any already selected that aren't in search results
|
|
85
|
-
const existingOptionIds = new Set(options.map((option) => option.value));
|
|
86
|
-
const missingOptions: HowToOption[] = selectedHowTos
|
|
87
|
-
.filter((howTo) => !existingOptionIds.has(howTo.id))
|
|
88
|
-
.map((howTo) => ({
|
|
89
|
-
label: howTo.name,
|
|
90
|
-
value: howTo.id,
|
|
91
|
-
howToData: howTo as unknown as HowToInterface,
|
|
92
|
-
}));
|
|
93
|
-
|
|
94
|
-
setHowToOptions([...options, ...missingOptions]);
|
|
95
|
-
}
|
|
96
|
-
}, [data.data, currentHowTo, selectedHowTos]);
|
|
97
|
-
|
|
98
|
-
// Convert selected to Option[] format
|
|
99
|
-
const selectedOptions = useMemo(() => {
|
|
100
|
-
return selectedHowTos.map((howTo) => ({
|
|
101
|
-
value: howTo.id,
|
|
102
|
-
label: howTo.name,
|
|
103
|
-
}));
|
|
104
|
-
}, [selectedHowTos]);
|
|
105
|
-
|
|
106
|
-
const handleChange = (options: Option[]) => {
|
|
107
|
-
// Convert to form format
|
|
108
|
-
const formValues = options.map((option) => ({
|
|
109
|
-
id: option.value,
|
|
110
|
-
name: option.label,
|
|
111
|
-
}));
|
|
112
|
-
|
|
113
|
-
form.setValue(id, formValues, { shouldDirty: true, shouldTouch: true });
|
|
114
|
-
|
|
115
|
-
if (onChange) {
|
|
116
|
-
// Get full data for onChange callback
|
|
117
|
-
const fullData = options
|
|
118
|
-
.map((option) => {
|
|
119
|
-
const howToOption = howToOptions.find((opt) => opt.value === option.value);
|
|
120
|
-
return howToOption?.howToData;
|
|
121
|
-
})
|
|
122
|
-
.filter(Boolean) as HowToInterface[];
|
|
123
|
-
onChange(fullData);
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// Search handler
|
|
128
|
-
const handleSearchSync = (search: string): Option[] => {
|
|
129
|
-
setSearchTerm(search);
|
|
130
|
-
return howToOptions;
|
|
131
|
-
};
|
|
132
30
|
|
|
133
31
|
return (
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
/>
|
|
148
|
-
)}
|
|
149
|
-
</FormFieldWrapper>
|
|
150
|
-
</div>
|
|
32
|
+
<EntityMultiSelector<HowToInterface>
|
|
33
|
+
id={id}
|
|
34
|
+
form={form}
|
|
35
|
+
label={label}
|
|
36
|
+
placeholder={placeholder || t("ui.search.button")}
|
|
37
|
+
emptyText={t("ui.search.no_results_generic")}
|
|
38
|
+
isRequired={isRequired}
|
|
39
|
+
retriever={(params) => HowToService.findMany(params)}
|
|
40
|
+
module={Modules.HowTo}
|
|
41
|
+
getLabel={(howTo) => howTo.name}
|
|
42
|
+
excludeId={currentHowTo?.id}
|
|
43
|
+
onChange={onChange}
|
|
44
|
+
/>
|
|
151
45
|
);
|
|
152
46
|
}
|
|
@@ -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 {
|
|
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 {
|
|
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
|
-
<
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
}
|
package/src/permissions/types.ts
CHANGED
|
@@ -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
|