@truedat/lm 8.2.0 → 8.2.2

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 (26) hide show
  1. package/package.json +3 -3
  2. package/src/api.js +4 -0
  3. package/src/components/ConceptLinkForm.js +111 -0
  4. package/src/components/LinksPagination.js +15 -0
  5. package/src/components/LinksSearch.js +139 -0
  6. package/src/components/QualityControlConcepts.js +156 -0
  7. package/src/components/QualityControlStructures.js +183 -0
  8. package/src/components/RelationTagForm.js +4 -0
  9. package/src/components/StructureLinkForm.js +105 -0
  10. package/src/components/__tests__/ConceptLinkForm.spec.js +252 -0
  11. package/src/components/__tests__/LinksSearch.spec.js +299 -0
  12. package/src/components/__tests__/QualityControlConcepts.spec.js +262 -0
  13. package/src/components/__tests__/QualityControlStructures.spec.js +213 -0
  14. package/src/components/__tests__/StructureLinkForm.spec.js +284 -0
  15. package/src/components/__tests__/__snapshots__/ConceptLinkForm.spec.js.snap +48 -0
  16. package/src/components/__tests__/__snapshots__/LinksSearch.spec.js.snap +29 -0
  17. package/src/components/__tests__/__snapshots__/NewRelationTag.spec.js.snap +13 -0
  18. package/src/components/__tests__/__snapshots__/QualityControlConcepts.spec.js.snap +49 -0
  19. package/src/components/__tests__/__snapshots__/QualityControlStructures.spec.js.snap +49 -0
  20. package/src/components/__tests__/__snapshots__/RelationTagForm.spec.js.snap +13 -0
  21. package/src/components/__tests__/__snapshots__/StructureLinkForm.spec.js.snap +48 -0
  22. package/src/hooks/useRelations.js +53 -0
  23. package/src/messages/en.js +1 -0
  24. package/src/messages/es.js +1 -0
  25. package/src/selectors/index.js +1 -0
  26. package/src/selectors/messages.js +48 -0
@@ -0,0 +1,105 @@
1
+ import _ from "lodash/fp";
2
+ import { lazy, useState, useEffect } from "react";
3
+ import { useSelector, useDispatch } from "react-redux";
4
+ import { useIntl } from "react-intl";
5
+ import { clearSelectedRelationTags } from "../routines";
6
+ import { Divider, Button } from "semantic-ui-react";
7
+ import { HistoryBackButton } from "@truedat/core/components";
8
+ import { useWebContext } from "@truedat/core/webContext";
9
+ import RelationTagsLoader from "./RelationTagsLoader";
10
+ import { useCreateRelation } from "../hooks/useRelations";
11
+ import { useNavigate } from "react-router";
12
+ import { getRelationErrorAlertMessage } from "../selectors/messages";
13
+
14
+ const TagTypeDropdownSelector = lazy(
15
+ () => import("@truedat/lm/components/TagTypeDropdownSelector")
16
+ );
17
+
18
+ const StructureSelector = lazy(
19
+ () => import("@truedat/dd/components/StructureSelector")
20
+ );
21
+
22
+ export const StructureLinkForm = ({
23
+ sourceId,
24
+ sourceType,
25
+ redirectUrl,
26
+ selectTagOptions,
27
+ }) => {
28
+ const navigate = useNavigate();
29
+ const selectedRelationTags = useSelector(
30
+ (state) => state?.selectedRelationTags
31
+ );
32
+ const tagOptions = useSelector(selectTagOptions);
33
+ const { trigger: createRelation, isMutating: creatingRelation } =
34
+ useCreateRelation();
35
+ const dispatch = useDispatch();
36
+ const [selectedStructure, setSelectedStructure] = useState(null);
37
+ const { formatMessage } = useIntl();
38
+ const { setAlertMessage } = useWebContext();
39
+
40
+ useEffect(() => {
41
+ return () => {
42
+ dispatch(clearSelectedRelationTags.trigger());
43
+ };
44
+ }, [clearSelectedRelationTags, dispatch]);
45
+
46
+ const handleSubmit = () => {
47
+ const link = {
48
+ source_id: sourceId,
49
+ source_type: sourceType,
50
+ target_id: selectedStructure?.id,
51
+ target_type: "data_structure",
52
+ tag_ids: selectedRelationTags ? selectedRelationTags : [],
53
+ };
54
+ createRelation({ relation: link })
55
+ .then(({ data }) => {
56
+ navigate(redirectUrl, {
57
+ state: {
58
+ createdRelation: {
59
+ ...data?.data,
60
+ target_name: selectedStructure?.name,
61
+ target_data: selectedStructure,
62
+ },
63
+ },
64
+ });
65
+ })
66
+ .catch((error) => {
67
+ const alertMessage = getRelationErrorAlertMessage(error, formatMessage);
68
+ if (alertMessage) {
69
+ setAlertMessage(alertMessage);
70
+ }
71
+ });
72
+ };
73
+
74
+ const disabled = !(selectedStructure && selectedRelationTags);
75
+
76
+ return (
77
+ <>
78
+ <Divider hidden />
79
+ <RelationTagsLoader />
80
+ {!_.isEmpty(tagOptions) && (
81
+ <>
82
+ <TagTypeDropdownSelector options={tagOptions} />
83
+ <Divider hidden />
84
+ </>
85
+ )}
86
+ <StructureSelector
87
+ selectedStructure={selectedStructure}
88
+ onSelect={setSelectedStructure}
89
+ />
90
+ <Divider hidden />
91
+ <Button.Group>
92
+ <Button
93
+ primary
94
+ loading={creatingRelation}
95
+ content={formatMessage({ id: "actions.create" })}
96
+ disabled={disabled}
97
+ onClick={handleSubmit}
98
+ />
99
+ <HistoryBackButton content={formatMessage({ id: "actions.cancel" })} />
100
+ </Button.Group>
101
+ </>
102
+ );
103
+ };
104
+
105
+ export default StructureLinkForm;
@@ -0,0 +1,252 @@
1
+ import userEvent from "@testing-library/user-event";
2
+ import { waitFor } from "@testing-library/react";
3
+ import { render, waitForLoad } from "@truedat/test/render";
4
+ import { ConceptLinkForm } from "../ConceptLinkForm";
5
+
6
+ const mockNavigate = jest.fn();
7
+ const mockCreateRelation = jest.fn();
8
+ const mockDispatch = jest.fn();
9
+
10
+ jest.mock("react-router", () => ({
11
+ ...jest.requireActual("react-router"),
12
+ useNavigate: () => mockNavigate,
13
+ }));
14
+
15
+ jest.mock("../../hooks/useRelations", () => ({
16
+ useCreateRelation: () => ({
17
+ trigger: mockCreateRelation,
18
+ isMutating: false,
19
+ }),
20
+ }));
21
+
22
+ jest.mock("react-redux", () => ({
23
+ ...jest.requireActual("react-redux"),
24
+ useDispatch: jest.fn(() => mockDispatch),
25
+ }));
26
+
27
+ jest.mock("react-intl", () => ({
28
+ ...jest.requireActual("react-intl"),
29
+ useIntl: () => ({
30
+ formatMessage: ({ id }) => id,
31
+ }),
32
+ }));
33
+
34
+ jest.mock("../../routines", () => ({
35
+ clearSelectedRelationTags: {
36
+ trigger: jest.fn(() => ({ type: "CLEAR_SELECTED_RELATION_TAGS" })),
37
+ },
38
+ }));
39
+
40
+ jest.mock("../RelationTagsLoader", () =>
41
+ jest.fn(() => <div>RelationTagsLoader</div>)
42
+ );
43
+
44
+ jest.mock("@truedat/lm/components/TagTypeDropdownSelector", () =>
45
+ jest.fn(({ options }) => (
46
+ <div>
47
+ <div>TagTypeDropdownSelector</div>
48
+ {options?.map((option, i) => (
49
+ <button
50
+ key={i}
51
+ onClick={() => option.onClick && option.onClick(option.value)}
52
+ >
53
+ {option.text}
54
+ </button>
55
+ ))}
56
+ </div>
57
+ ))
58
+ );
59
+
60
+ jest.mock("@truedat/bg/concepts/relations/components/ConceptSelector", () =>
61
+ jest.fn(({ handleConceptSelected }) => (
62
+ <div>
63
+ <div>ConceptSelector</div>
64
+ <button
65
+ onClick={() =>
66
+ handleConceptSelected({
67
+ business_concept_id: 123,
68
+ name: "Test Concept",
69
+ id: 456,
70
+ })
71
+ }
72
+ >
73
+ Select Concept
74
+ </button>
75
+ </div>
76
+ ))
77
+ );
78
+
79
+ jest.mock("@truedat/core/components", () => ({
80
+ HistoryBackButton: ({ content }) => <button>{content}</button>,
81
+ }));
82
+
83
+ const props = {
84
+ targetId: 10,
85
+ targetType: "quality_control",
86
+ redirectUrl: "/qualityControls/10/version/1/links/concepts",
87
+ selectTagOptions: jest.fn(() => [{ id: 1, text: "relates_to", value: 1 }]),
88
+ };
89
+
90
+ const renderOpts = {
91
+ state: {
92
+ relationTags: [{ value: { target_type: "quality_control" } }],
93
+ selectedRelationTags: [1],
94
+ },
95
+ };
96
+
97
+ describe("<ConceptLinkForm />", () => {
98
+ beforeEach(() => {
99
+ jest.clearAllMocks();
100
+ mockCreateRelation.mockResolvedValue({
101
+ data: {
102
+ data: {
103
+ id: 789,
104
+ source_id: 123,
105
+ target_id: 10,
106
+ },
107
+ },
108
+ });
109
+ });
110
+
111
+ it("matches the latest snapshot", async () => {
112
+ const rendered = render(<ConceptLinkForm {...props} />, renderOpts);
113
+ await waitForLoad(rendered);
114
+ expect(rendered.container).toMatchSnapshot();
115
+ });
116
+
117
+ it("renders all form components", async () => {
118
+ const rendered = render(<ConceptLinkForm {...props} />, renderOpts);
119
+ await waitForLoad(rendered);
120
+
121
+ expect(rendered.getByText("RelationTagsLoader")).toBeInTheDocument();
122
+ expect(rendered.getByText("TagTypeDropdownSelector")).toBeInTheDocument();
123
+ expect(rendered.getByText("ConceptSelector")).toBeInTheDocument();
124
+ expect(rendered.getByText("actions.create")).toBeInTheDocument();
125
+ expect(rendered.getByText("actions.cancel")).toBeInTheDocument();
126
+ });
127
+
128
+ it("disables create button when concept or tags are not selected", async () => {
129
+ const rendered = render(<ConceptLinkForm {...props} />, {
130
+ state: {
131
+ relationTags: [],
132
+ selectedRelationTags: null,
133
+ },
134
+ });
135
+ await waitForLoad(rendered);
136
+
137
+ const createButton = rendered.getByText("actions.create");
138
+ expect(createButton).toBeDisabled();
139
+ });
140
+
141
+ it("enables create button when both concept and tags are selected", async () => {
142
+ const user = userEvent.setup({ delay: null });
143
+ const rendered = render(<ConceptLinkForm {...props} />, renderOpts);
144
+ await waitForLoad(rendered);
145
+
146
+ const createButton = rendered.getByText("actions.create");
147
+ expect(createButton).toBeDisabled();
148
+
149
+ await user.click(rendered.getByText("Select Concept"));
150
+
151
+ await waitFor(() => {
152
+ expect(createButton).toBeEnabled();
153
+ });
154
+ });
155
+
156
+ it("calls createRelation and navigates on submit", async () => {
157
+ const user = userEvent.setup({ delay: null });
158
+ const rendered = render(<ConceptLinkForm {...props} />, renderOpts);
159
+ await waitForLoad(rendered);
160
+
161
+ await user.click(rendered.getByText("Select Concept"));
162
+
163
+ await waitFor(() => {
164
+ expect(rendered.getByText("actions.create")).toBeEnabled();
165
+ });
166
+
167
+ await user.click(rendered.getByText("actions.create"));
168
+
169
+ await waitFor(() => {
170
+ expect(mockCreateRelation).toHaveBeenCalledWith({
171
+ relation: {
172
+ source_id: 123,
173
+ source_type: "business_concept",
174
+ target_id: 10,
175
+ target_type: "quality_control",
176
+ tag_ids: [1],
177
+ },
178
+ });
179
+ });
180
+
181
+ await waitFor(() => {
182
+ expect(mockNavigate).toHaveBeenCalledWith(props.redirectUrl, {
183
+ state: {
184
+ createdRelation: {
185
+ id: 789,
186
+ source_id: 123,
187
+ target_id: 10,
188
+ source_name: "Test Concept",
189
+ source_data: {
190
+ business_concept_id: 123,
191
+ name: "Test Concept",
192
+ id: 456,
193
+ },
194
+ },
195
+ },
196
+ });
197
+ });
198
+ });
199
+
200
+ it("dispatches clearSelectedRelationTags on unmount", () => {
201
+ const { unmount } = render(<ConceptLinkForm {...props} />, renderOpts);
202
+ unmount();
203
+
204
+ expect(mockDispatch).toHaveBeenCalledWith({
205
+ type: "CLEAR_SELECTED_RELATION_TAGS",
206
+ });
207
+ });
208
+
209
+ it("handles empty tag_ids when selectedRelationTags is empty", async () => {
210
+ const user = userEvent.setup({ delay: null });
211
+ const rendered = render(<ConceptLinkForm {...props} />, {
212
+ state: { selectedRelationTags: [] },
213
+ });
214
+ await waitForLoad(rendered);
215
+
216
+ await user.click(rendered.getByText("Select Concept"));
217
+
218
+ await waitFor(() => {
219
+ expect(rendered.getByText("actions.create")).toBeEnabled();
220
+ });
221
+
222
+ await user.click(rendered.getByText("actions.create"));
223
+
224
+ await waitFor(() => {
225
+ expect(mockCreateRelation).toHaveBeenCalledWith({
226
+ relation: {
227
+ source_id: 123,
228
+ source_type: "business_concept",
229
+ target_id: 10,
230
+ target_type: "quality_control",
231
+ tag_ids: [],
232
+ },
233
+ });
234
+ });
235
+ });
236
+
237
+ it("does not render TagTypeDropdownSelector when tagOptions is empty", async () => {
238
+ const rendered = render(
239
+ <ConceptLinkForm
240
+ {...{ ...props, selectTagOptions: jest.fn(() => []) }}
241
+ />,
242
+ {
243
+ state: {},
244
+ }
245
+ );
246
+ await waitForLoad(rendered);
247
+
248
+ expect(
249
+ rendered.queryByText("TagTypeDropdownSelector")
250
+ ).not.toBeInTheDocument();
251
+ });
252
+ });
@@ -0,0 +1,299 @@
1
+ import { waitFor } from "@testing-library/react";
2
+ import { render, waitForLoad } from "@truedat/test/render";
3
+ import LinksSearch from "../LinksSearch";
4
+
5
+ const mockNavigate = jest.fn();
6
+ const mockSearchRelations = jest.fn();
7
+ const mockSearchRelationsFilters = jest.fn();
8
+
9
+ jest.mock("react-router", () => ({
10
+ ...jest.requireActual("react-router"),
11
+ useNavigate: () => mockNavigate,
12
+ useLocation: () => ({ pathname: "/test/path" }),
13
+ }));
14
+
15
+ jest.mock("../../hooks/useRelations", () => ({
16
+ useRelations: () => ({
17
+ trigger: mockSearchRelations,
18
+ }),
19
+ useRelationFilters: () => ({
20
+ trigger: mockSearchRelationsFilters,
21
+ }),
22
+ }));
23
+
24
+ jest.mock("@truedat/core/search/SearchContext", () => {
25
+ const originalModule = jest.requireActual(
26
+ "@truedat/core/search/SearchContext"
27
+ );
28
+
29
+ return {
30
+ __esModule: true,
31
+ ...originalModule,
32
+ useSearchContext: jest.fn(),
33
+ };
34
+ });
35
+
36
+ jest.mock("../LinksPagination", () =>
37
+ jest.fn(() => <div>LinksPagination</div>)
38
+ );
39
+
40
+ jest.mock("@truedat/core/search/SearchWidget", () =>
41
+ jest.fn(() => <div>SearchWidget</div>)
42
+ );
43
+
44
+ const { useSearchContext } = require("@truedat/core/search/SearchContext");
45
+
46
+ const columns = [
47
+ {
48
+ header: "concepts.props.name",
49
+ fieldSelector: (item) => item.name,
50
+ },
51
+ {
52
+ header: "concepts.props.domain",
53
+ fieldSelector: (item) => item.domain,
54
+ },
55
+ ];
56
+
57
+ const defaultFilters = {
58
+ target_id: 10,
59
+ target_type: "quality_control",
60
+ source_type: "business_concept",
61
+ };
62
+
63
+ const searchData = {
64
+ data: [
65
+ { id: 1, name: "Concept 1", domain: "Domain 1" },
66
+ { id: 2, name: "Concept 2", domain: "Domain 2" },
67
+ { id: 3, name: "Concept 3", domain: "Domain 1" },
68
+ ],
69
+ };
70
+
71
+ const filterParams = {
72
+ must: defaultFilters,
73
+ page: 0,
74
+ size: 10,
75
+ };
76
+
77
+ describe("<LinksSearch />", () => {
78
+ beforeEach(() => {
79
+ jest.clearAllMocks();
80
+ mockSearchRelations.mockResolvedValue({ data: searchData });
81
+ mockSearchRelationsFilters.mockResolvedValue({ data: {} });
82
+ });
83
+
84
+ it("matches the latest snapshot", async () => {
85
+ useSearchContext.mockReturnValue({
86
+ searchData: { data: [] },
87
+ loading: false,
88
+ filterParams,
89
+ });
90
+
91
+ const rendered = render(
92
+ <LinksSearch columns={columns} defaultFilters={defaultFilters} />
93
+ );
94
+ await waitForLoad(rendered);
95
+ expect(rendered.container).toMatchSnapshot();
96
+ });
97
+
98
+ it("renders SearchWidget and LinksPagination", async () => {
99
+ useSearchContext.mockReturnValue({
100
+ searchData: { data: [] },
101
+ loading: false,
102
+ filterParams,
103
+ });
104
+
105
+ const rendered = render(
106
+ <LinksSearch columns={columns} defaultFilters={defaultFilters} />
107
+ );
108
+ await waitForLoad(rendered);
109
+
110
+ expect(rendered.getByText("SearchWidget")).toBeInTheDocument();
111
+ expect(rendered.getByText("LinksPagination")).toBeInTheDocument();
112
+ });
113
+
114
+ it("displays empty message when no relations", async () => {
115
+ useSearchContext.mockReturnValue({
116
+ searchData: { data: [] },
117
+ loading: false,
118
+ filterParams,
119
+ });
120
+
121
+ const rendered = render(
122
+ <LinksSearch columns={columns} defaultFilters={defaultFilters} />
123
+ );
124
+ await waitForLoad(rendered);
125
+
126
+ await waitFor(() => {
127
+ expect(rendered.getByText("linked_concepts.empty")).toBeInTheDocument();
128
+ });
129
+ });
130
+
131
+ it("displays relations table when data is available", async () => {
132
+ useSearchContext.mockReturnValue({
133
+ searchData,
134
+ loading: false,
135
+ filterParams,
136
+ });
137
+
138
+ const rendered = render(
139
+ <LinksSearch columns={columns} defaultFilters={defaultFilters} />
140
+ );
141
+ await waitForLoad(rendered);
142
+
143
+ await waitFor(() => {
144
+ expect(rendered.getByText("Concept 1")).toBeInTheDocument();
145
+ expect(rendered.getByText("Concept 2")).toBeInTheDocument();
146
+ });
147
+ });
148
+
149
+ it("includes extraRow when provided and not in data", async () => {
150
+ useSearchContext.mockReturnValue({
151
+ searchData,
152
+ loading: false,
153
+ filterParams,
154
+ });
155
+
156
+ const extraRow = { id: 999, name: "New Concept", domain: "New Domain" };
157
+
158
+ const rendered = render(
159
+ <LinksSearch
160
+ columns={columns}
161
+ defaultFilters={defaultFilters}
162
+ extraRow={extraRow}
163
+ />
164
+ );
165
+ await waitForLoad(rendered);
166
+
167
+ await waitFor(() => {
168
+ expect(rendered.getByText("New Concept")).toBeInTheDocument();
169
+ expect(rendered.getByText("Concept 1")).toBeInTheDocument();
170
+ });
171
+ });
172
+
173
+ it("does not include extraRow when it already exists in data", async () => {
174
+ useSearchContext.mockReturnValue({
175
+ searchData,
176
+ loading: false,
177
+ filterParams,
178
+ });
179
+
180
+ const extraRow = { id: 1, name: "Concept 1", domain: "Domain 1" };
181
+
182
+ const rendered = render(
183
+ <LinksSearch
184
+ columns={columns}
185
+ defaultFilters={defaultFilters}
186
+ extraRow={extraRow}
187
+ />
188
+ );
189
+ await waitForLoad(rendered);
190
+
191
+ await waitFor(() => {
192
+ const concept1Elements = rendered.queryAllByText("Concept 1");
193
+ expect(concept1Elements.length).toBe(1);
194
+ });
195
+ });
196
+
197
+ it("filters out deletedLinkId from data", async () => {
198
+ useSearchContext.mockReturnValue({
199
+ searchData,
200
+ loading: false,
201
+ filterParams,
202
+ });
203
+
204
+ const rendered = render(
205
+ <LinksSearch
206
+ columns={columns}
207
+ defaultFilters={defaultFilters}
208
+ deletedLinkId={2}
209
+ />
210
+ );
211
+ await waitForLoad(rendered);
212
+
213
+ await waitFor(() => {
214
+ expect(rendered.getByText("Concept 1")).toBeInTheDocument();
215
+ expect(rendered.queryByText("Concept 2")).not.toBeInTheDocument();
216
+ expect(rendered.getByText("Concept 3")).toBeInTheDocument();
217
+ });
218
+ });
219
+
220
+ it("triggers refetch when refetchSearch is true", async () => {
221
+ const setRefetchSearch = jest.fn();
222
+
223
+ useSearchContext.mockReturnValue({
224
+ searchData: { data: [] },
225
+ loading: false,
226
+ filterParams,
227
+ });
228
+
229
+ const rendered = render(
230
+ <LinksSearch
231
+ columns={columns}
232
+ defaultFilters={defaultFilters}
233
+ refetchSearch={true}
234
+ setRefetchSearch={setRefetchSearch}
235
+ />
236
+ );
237
+ await waitForLoad(rendered);
238
+
239
+ await waitFor(() => {
240
+ expect(mockSearchRelations).toHaveBeenCalledWith(filterParams);
241
+ expect(mockSearchRelationsFilters).toHaveBeenCalledWith(filterParams);
242
+ expect(setRefetchSearch).toHaveBeenCalledWith(false);
243
+ });
244
+ });
245
+
246
+ it("clears extraRow when filterParams change", async () => {
247
+ const initialFilterParams = { ...filterParams, page: 0 };
248
+ const changedFilterParams = { ...filterParams, page: 1 };
249
+
250
+ useSearchContext
251
+ .mockReturnValueOnce({
252
+ searchData,
253
+ loading: false,
254
+ filterParams: initialFilterParams,
255
+ })
256
+ .mockReturnValueOnce({
257
+ searchData,
258
+ loading: false,
259
+ filterParams: changedFilterParams,
260
+ });
261
+
262
+ const extraRow = { id: 999, name: "New Concept", domain: "New Domain" };
263
+
264
+ const rendered = render(
265
+ <LinksSearch
266
+ columns={columns}
267
+ defaultFilters={defaultFilters}
268
+ extraRow={extraRow}
269
+ />
270
+ );
271
+ await waitForLoad(rendered);
272
+
273
+ await waitFor(() => {
274
+ expect(rendered.getByText("New Concept")).toBeInTheDocument();
275
+ });
276
+
277
+ // Simulate filterParams change by re-rendering with new filterParams
278
+ useSearchContext.mockReturnValue({
279
+ searchData,
280
+ loading: false,
281
+ filterParams: changedFilterParams,
282
+ });
283
+
284
+ rendered.rerender(
285
+ <LinksSearch
286
+ columns={columns}
287
+ defaultFilters={defaultFilters}
288
+ extraRow={extraRow}
289
+ />
290
+ );
291
+
292
+ await waitFor(() => {
293
+ expect(mockNavigate).toHaveBeenCalledWith("/test/path", {
294
+ replace: true,
295
+ state: null,
296
+ });
297
+ });
298
+ });
299
+ });