@truedat/df 8.2.4 → 8.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/df",
3
- "version": "8.2.4",
3
+ "version": "8.3.0",
4
4
  "description": "Truedat Web Data Quality Module",
5
5
  "sideEffects": false,
6
6
  "module": "src/index.js",
@@ -48,14 +48,14 @@
48
48
  "@testing-library/jest-dom": "^6.6.3",
49
49
  "@testing-library/react": "^16.3.0",
50
50
  "@testing-library/user-event": "^14.6.1",
51
- "@truedat/test": "8.2.4",
51
+ "@truedat/test": "8.3.0",
52
52
  "identity-obj-proxy": "^3.0.0",
53
53
  "jest": "^29.7.0",
54
54
  "redux-saga-test-plan": "^4.0.6"
55
55
  },
56
56
  "dependencies": {
57
57
  "@apollo/client": "^3.13.8",
58
- "@truedat/core": "8.2.4",
58
+ "@truedat/core": "8.3.0",
59
59
  "axios": "^1.13.5",
60
60
  "graphql": "^16.11.0",
61
61
  "is-hotkey": "^0.2.0",
@@ -84,5 +84,5 @@
84
84
  "semantic-ui-react": "^3.0.0-beta.2",
85
85
  "swr": "^2.3.3"
86
86
  },
87
- "gitHead": "b98f63284093c90c57faa1794b6ccc1fcf761d89"
87
+ "gitHead": "d1abe685a3ae0cd2999e7bb62d3c0fd56522095f"
88
88
  }
@@ -57,7 +57,7 @@ export default function SelectableDynamicForm({
57
57
 
58
58
  return (
59
59
  <>
60
- {!actionKey || actionKey == "create" ? (
60
+ {(!actionKey || actionKey == "create") && !disableSelector ? (
61
61
  <TemplateSelector
62
62
  scope={scope}
63
63
  domainIds={domainIds}
@@ -70,7 +70,7 @@ export default function SelectableDynamicForm({
70
70
  required={required}
71
71
  requiredError={requiredError}
72
72
  selectedValue={template?.id}
73
- disabled={disabled || disableSelector}
73
+ disabled={disabled}
74
74
  />
75
75
  ) : null}
76
76
  {!disabled && template ? (
@@ -175,4 +175,48 @@ describe("<SelectableDynamicForm />", () => {
175
175
  expect(rendered.container).toMatchSnapshot();
176
176
  });
177
177
  });
178
+
179
+ describe("with disableSelector and selectedTemplate", () => {
180
+ const selectedTemplate = {
181
+ id: 1,
182
+ name: "template1",
183
+ content: [
184
+ {
185
+ name: "g1",
186
+ fields: [
187
+ {
188
+ name: "field1",
189
+ label: "field1",
190
+ type: "string",
191
+ },
192
+ ],
193
+ },
194
+ ],
195
+ };
196
+
197
+ it("does not render template selector when disableSelector is true", async () => {
198
+ const rendered = render(
199
+ <SelectableDynamicForm
200
+ {...props}
201
+ disableSelector
202
+ selectedTemplate={selectedTemplate}
203
+ />
204
+ );
205
+ await waitForLoad(rendered);
206
+ expect(rendered.container.querySelector(".ui.search.selection.dropdown")).not.toBeInTheDocument();
207
+ expect(rendered.queryByText(/template\.selector\.placeholder/i)).not.toBeInTheDocument();
208
+ });
209
+
210
+ it("matches snapshot when disableSelector and selectedTemplate are set", async () => {
211
+ const rendered = render(
212
+ <SelectableDynamicForm
213
+ {...props}
214
+ disableSelector
215
+ selectedTemplate={selectedTemplate}
216
+ />
217
+ );
218
+ await waitForLoad(rendered);
219
+ expect(rendered.container).toMatchSnapshot();
220
+ });
221
+ });
178
222
  });
@@ -1,5 +1,40 @@
1
1
  // Jest Snapshot v1, https://goo.gl/fbAQLP
2
2
 
3
+ exports[`<SelectableDynamicForm /> with disableSelector and selectedTemplate matches snapshot when disableSelector and selectedTemplate are set 1`] = `
4
+ <div>
5
+ <div
6
+ class="ui segment"
7
+ >
8
+ <h4
9
+ class="ui header"
10
+ >
11
+ g1
12
+ </h4>
13
+ <div
14
+ class="field"
15
+ data-testid="form-field"
16
+ >
17
+ <label>
18
+ field1
19
+ </label>
20
+ <div
21
+ class="field"
22
+ >
23
+ <div
24
+ class="ui input"
25
+ >
26
+ <input
27
+ name="field1"
28
+ type="text"
29
+ value=""
30
+ />
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ `;
37
+
3
38
  exports[`<SelectableDynamicForm /> with multiple templates matches error snapshot 1`] = `<div />`;
4
39
 
5
40
  exports[`<SelectableDynamicForm /> with multiple templates matches the latest snapshot 1`] = `
@@ -0,0 +1,43 @@
1
+ import useSWR from "swr";
2
+ import { apiJson } from "@truedat/core/services/api";
3
+ import { API_TEMPLATE_RELATIONS } from "@truedat/df/templates/api";
4
+
5
+ const buildTemplateRelationsUrl = ({
6
+ resourceType,
7
+ templateId,
8
+ resourceId,
9
+ listAll,
10
+ }) => {
11
+ const params = new URLSearchParams();
12
+ params.set("resource_type", resourceType);
13
+ if (templateId != null) params.set("template_id", String(templateId));
14
+ if (resourceId != null) params.set("resource_id", String(resourceId));
15
+ if (listAll) params.set("list_all", "true");
16
+ return `${API_TEMPLATE_RELATIONS}?${params.toString()}`;
17
+ };
18
+
19
+ export const useTemplateRelations = (options = {}) => {
20
+ const { resourceType, templateId, resourceId, scope, listAll } = options;
21
+ const canFetch = !!resourceType;
22
+ const url = canFetch
23
+ ? buildTemplateRelationsUrl({
24
+ resourceType,
25
+ templateId,
26
+ resourceId,
27
+ listAll,
28
+ })
29
+ : null;
30
+ const key = url && scope != null ? [url, scope] : url;
31
+ const fetcher = (keyOrArray) =>
32
+ apiJson(Array.isArray(keyOrArray) ? keyOrArray[0] : keyOrArray);
33
+ const { data, error, mutate } = useSWR(key, fetcher);
34
+ const response = data?.data ?? data;
35
+
36
+ return {
37
+ default: response?.default ?? null,
38
+ resource: response?.resource ?? [],
39
+ error,
40
+ loading: !error && !data,
41
+ mutate,
42
+ };
43
+ };
@@ -1,4 +1,5 @@
1
1
  const API_TEMPLATE = "/api/templates/:id/";
2
2
  const API_TEMPLATES = "/api/templates";
3
+ const API_TEMPLATE_RELATIONS = "/api/template_relations";
3
4
 
4
- export { API_TEMPLATE, API_TEMPLATES };
5
+ export { API_TEMPLATE, API_TEMPLATES, API_TEMPLATE_RELATIONS };
@@ -1,12 +1,14 @@
1
1
  import _ from "lodash/fp";
2
- import { useState, useEffect } from "react";
2
+ import { useState, useEffect, useCallback } from "react";
3
3
  import PropTypes from "prop-types";
4
4
  import { connect } from "react-redux";
5
5
  import { Header, Form, Grid, Label, Divider } from "semantic-ui-react";
6
6
  import { useIntl, FormattedMessage } from "react-intl";
7
+ import { useWebContext } from "@truedat/core/webContext";
7
8
  import GroupsList from "./GroupsList";
8
9
  import ActiveGroupForm from "./ActiveGroupForm";
9
10
  import TemplateFormActions from "./TemplateFormActions";
11
+ import TemplateRelationsForm from "./TemplateRelationsForm";
10
12
  import { parseContentValidation } from "./contentValidation";
11
13
 
12
14
  const scopeOptions = (formatMessage) =>
@@ -31,10 +33,12 @@ const scopeOptions = (formatMessage) =>
31
33
 
32
34
  export const TemplateForm = ({ loading, template, onSubmit }) => {
33
35
  const { formatMessage } = useIntl();
36
+ const { scopesWithRelations = [] } = useWebContext();
34
37
  const [activeGroup, setActiveGroup] = useState(0);
35
38
  const [editedTemplate, setEditedTemplate] = useState({
36
39
  ...template,
37
40
  content: template?.content || [],
41
+ template_resource_relations: template?.template_resource_relations ?? [],
38
42
  });
39
43
 
40
44
  useEffect(() => {
@@ -61,6 +65,10 @@ export const TemplateForm = ({ loading, template, onSubmit }) => {
61
65
  content: [...editedTemplate.content, { name: "", fields: [] }],
62
66
  });
63
67
 
68
+ const handleRelationsChange = useCallback((relations) => {
69
+ setEditedTemplate((prev) => _.assoc("template_resource_relations", relations)(prev));
70
+ }, []);
71
+
64
72
  const formattedFields = (fields) => _.flow(
65
73
  _.map((field) => {
66
74
  const fieldProperties = [
@@ -205,9 +213,16 @@ export const TemplateForm = ({ loading, template, onSubmit }) => {
205
213
  label={formatMessage({ id: "template.form.subscope" })}
206
214
  value={subscopeValue}
207
215
  onChange={handleChange}
208
- disabled={editedTemplate?.scope !== "bg" && editedTemplate?.scope !== "ri"}
216
+ disabled={editedTemplate?.scope !== "bg" && editedTemplate?.scope !== "ri"}
209
217
  />
210
218
  </Form.Group>
219
+ {scopesWithRelations?.includes(editedTemplate.scope) && (
220
+ <TemplateRelationsForm
221
+ scope={editedTemplate.scope}
222
+ template_id={editedTemplate.id}
223
+ onRelationsChange={handleRelationsChange}
224
+ />
225
+ )}
211
226
  <Divider horizontal>
212
227
  <Header as="h4">
213
228
  <FormattedMessage id="template.form.fieldGroups" />
@@ -0,0 +1,124 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import _ from "lodash/fp";
3
+ import PropTypes from "prop-types";
4
+ import { Form, Header, Divider } from "semantic-ui-react";
5
+ import { FormattedMessage, useIntl } from "react-intl";
6
+ import { useGrantableSystems } from "@truedat/dd/hooks/useSystems";
7
+ import { useTemplateRelations } from "@truedat/df/hooks/useTemplateRelations";
8
+
9
+ const scopeToResourceType = { gr: "system" };
10
+
11
+ const buildRelations = (resourceType, defaultTemplate, resourceRelations) => {
12
+ if (!resourceType) return [];
13
+ return [
14
+ ...(defaultTemplate
15
+ ? [{ resource_type: resourceType, resource_id: null }]
16
+ : []),
17
+ ...(resourceRelations || []).map((resource_id) => ({
18
+ resource_type: resourceType,
19
+ resource_id,
20
+ })),
21
+ ];
22
+ };
23
+
24
+ const TemplateRelationsForm = ({ scope, template_id, onRelationsChange }) => {
25
+ const { formatMessage } = useIntl();
26
+ const { data: grantableSystems } = useGrantableSystems();
27
+ const resourceType = scopeToResourceType[scope];
28
+ const {
29
+ default: defaultRelation,
30
+ resource: templateResourceRelations,
31
+ loading,
32
+ } = useTemplateRelations({
33
+ resourceType,
34
+ templateId: template_id,
35
+ scope,
36
+ });
37
+
38
+ const isServerDefault = defaultRelation?.template_id === template_id;
39
+ const [defaultTemplate, setDefaultTemplate] = useState(false);
40
+ const [resourceRelations, setResourceRelations] = useState([]);
41
+
42
+ const serverSnapshot = loading
43
+ ? null
44
+ : [
45
+ isServerDefault,
46
+ (templateResourceRelations || []).map((r) => r.resource_id).join(","),
47
+ ].join("|");
48
+ const lastSyncedRef = useRef(null);
49
+
50
+ useEffect(() => {
51
+ if (serverSnapshot === null) return;
52
+ if (lastSyncedRef.current === serverSnapshot) return;
53
+
54
+ lastSyncedRef.current = serverSnapshot;
55
+ setDefaultTemplate(isServerDefault);
56
+ setResourceRelations(
57
+ _.map(({ resource_id }) => resource_id)(templateResourceRelations || []),
58
+ );
59
+ }, [serverSnapshot, isServerDefault, templateResourceRelations]);
60
+
61
+ useEffect(() => {
62
+ if (typeof onRelationsChange !== "function" || !resourceType) return;
63
+ onRelationsChange(
64
+ buildRelations(resourceType, defaultTemplate, resourceRelations),
65
+ );
66
+ }, [resourceType, defaultTemplate, resourceRelations, onRelationsChange]);
67
+
68
+ return (
69
+ <>
70
+ <Divider horizontal>
71
+ <Header as="h4">
72
+ <FormattedMessage id="template.relations.header" />
73
+ </Header>
74
+ </Divider>
75
+ <Form.Group>
76
+ <Form.Checkbox
77
+ id="template_relation_default"
78
+ error={
79
+ !isServerDefault && defaultTemplate
80
+ ? {
81
+ content: formatMessage({
82
+ id: "template.relations.default.content",
83
+ }),
84
+ pointing: "left",
85
+ }
86
+ : false
87
+ }
88
+ name="template_relation_default"
89
+ label={formatMessage({ id: "template.relations.default" })}
90
+ onChange={(_e, { checked }) => setDefaultTemplate(checked)}
91
+ checked={defaultTemplate}
92
+ />
93
+ </Form.Group>
94
+ <Form.Group>
95
+ <Form.Dropdown
96
+ label={formatMessage({ id: "template.relations.related" })}
97
+ name="template_relation_resource"
98
+ placeholder={formatMessage({
99
+ id: "template.relations.systems.placeholder",
100
+ })}
101
+ search
102
+ selection
103
+ multiple
104
+ clearable
105
+ options={_.map(({ id, name }) => ({
106
+ key: id,
107
+ value: id,
108
+ text: name,
109
+ }))(grantableSystems?.data)}
110
+ value={resourceRelations}
111
+ onChange={(_e, { value }) => setResourceRelations(value)}
112
+ />
113
+ </Form.Group>
114
+ </>
115
+ );
116
+ };
117
+
118
+ TemplateRelationsForm.propTypes = {
119
+ scope: PropTypes.string.isRequired,
120
+ template_id: PropTypes.number,
121
+ onRelationsChange: PropTypes.func,
122
+ };
123
+
124
+ export default TemplateRelationsForm;
@@ -1,71 +1,169 @@
1
- import { render } from "@truedat/test/render";
1
+ import React from "react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { waitFor } from "@testing-library/react";
4
+ import { render, waitForLoad } from "@truedat/test/render";
2
5
  import TemplateForm from "../TemplateForm";
3
6
 
4
- const emptyTemplate = {
5
- id: null,
6
- name: "",
7
- label: "",
8
- scope: "",
9
- content: [],
10
- };
7
+ jest.mock("@truedat/core/webContext", () => ({
8
+ useWebContext: jest.fn(() => ({ scopesWithRelations: ["gr"] })),
9
+ }));
11
10
 
12
- const template = {
13
- id: 1,
14
- name: "Name",
15
- label: "Label",
16
- scope: "bg",
17
- };
11
+ jest.mock("../TemplateRelationsForm", () => {
12
+ const React = require("react");
13
+ return {
14
+ __esModule: true,
15
+ default: ({ onRelationsChange }) => {
16
+ React.useEffect(() => {
17
+ if (typeof onRelationsChange === "function") {
18
+ onRelationsChange([
19
+ { resource_type: "system", resource_id: null },
20
+ { resource_type: "system", resource_id: 1 },
21
+ ]);
22
+ }
23
+ }, [onRelationsChange]);
24
+ return <div data-testid="template-relations-form" />;
25
+ },
26
+ };
27
+ });
18
28
 
19
- const templateContent = [
20
- {
21
- fields: [
22
- {
23
- ai_suggestion: false,
24
- cardinality: "*",
25
- default: {
26
- origin: "default",
27
- value: "",
28
- },
29
- label: "field",
30
- name: "field",
31
- subscribable: false,
32
- type: "string",
33
- values: null,
34
- widget: "string",
35
- disabledName: true,
36
- },
37
- ],
38
- name: "group",
39
- },
40
- ];
41
-
42
- const filledTemplate = {
43
- ...template,
44
- content: templateContent,
29
+ const validTemplate = {
30
+ name: "Test Template",
31
+ label: "Test Label",
32
+ scope: "gr",
33
+ content: [{ name: "Group1", fields: [{ name: "field1", label: "Field 1", type: "string" }] }],
45
34
  };
46
35
 
47
36
  describe("<TemplateForm />", () => {
48
- it("matches the latest snapshot for empty template", () => {
49
- const state = { templateSaving: false, templateDeleting: false };
50
- const renderOpts = { state };
51
- const props = { emptyTemplate, onSubmit: jest.fn() };
52
- const { container } = render(<TemplateForm {...props} />, renderOpts);
53
- expect(container).toMatchSnapshot();
37
+ beforeEach(() => {
38
+ const { useWebContext } = require("@truedat/core/webContext");
39
+ useWebContext.mockReturnValue({ scopesWithRelations: ["gr"] });
40
+ });
41
+
42
+ it("matches snapshot when scope has relations", async () => {
43
+ const renderOpts = {
44
+ state: {
45
+ templateDeleting: false,
46
+ templateSaving: false,
47
+ },
48
+ };
49
+ const rendered = render(
50
+ <TemplateForm template={validTemplate} onSubmit={jest.fn()} loading={false} />,
51
+ renderOpts
52
+ );
53
+ await waitForLoad(rendered);
54
+ expect(rendered.container).toMatchSnapshot();
54
55
  });
55
56
 
56
- it("matches the latest snapshot for filled template", () => {
57
- const state = { templateSaving: false, templateDeleting: false };
58
- const renderOpts = { state };
59
- const props = { template: filledTemplate, onSubmit: jest.fn() };
60
- const { container } = render(<TemplateForm {...props} />, renderOpts);
61
- expect(container).toMatchSnapshot();
57
+ it("matches snapshot when scope has no relations", async () => {
58
+ const { useWebContext } = require("@truedat/core/webContext");
59
+ useWebContext.mockReturnValue({ scopesWithRelations: [] });
60
+ const templateOtherScope = { ...validTemplate, scope: "dd" };
61
+ const renderOpts = {
62
+ state: {
63
+ templateDeleting: false,
64
+ templateSaving: false,
65
+ },
66
+ };
67
+ const rendered = render(
68
+ <TemplateForm template={templateOtherScope} onSubmit={jest.fn()} loading={false} />,
69
+ renderOpts
70
+ );
71
+ await waitForLoad(rendered);
72
+ expect(rendered.container).toMatchSnapshot();
62
73
  });
63
74
 
64
- it("matches the latest snapshot (loading)", () => {
65
- const state = { templateSaving: true, templateDeleting: false };
66
- const renderOpts = { state };
67
- const props = { template, onSubmit: jest.fn() };
68
- const { container } = render(<TemplateForm {...props} />, renderOpts);
69
- expect(container).toMatchSnapshot();
75
+ it("includes template_resource_relations in submit payload when scope has relations", async () => {
76
+ const onSubmit = jest.fn();
77
+ const renderOpts = {
78
+ state: {
79
+ templateDeleting: false,
80
+ templateSaving: false,
81
+ },
82
+ };
83
+ const rendered = render(
84
+ <TemplateForm template={validTemplate} onSubmit={onSubmit} loading={false} />,
85
+ renderOpts
86
+ );
87
+ await waitForLoad(rendered);
88
+
89
+ await waitFor(() => {
90
+ expect(rendered.getByTestId("template-relations-form")).toBeInTheDocument();
91
+ });
92
+
93
+ const user = userEvent.setup({ delay: null });
94
+ await user.click(rendered.getByRole("button", { name: /save/i }));
95
+
96
+ await waitFor(() => {
97
+ expect(onSubmit).toHaveBeenCalled();
98
+ });
99
+ const submittedPayload = onSubmit.mock.calls[0][0];
100
+ expect(submittedPayload.template.template_resource_relations).toEqual([
101
+ { resource_type: "system", resource_id: null },
102
+ { resource_type: "system", resource_id: 1 },
103
+ ]);
104
+ });
105
+
106
+ it("initializes template_resource_relations from template when provided", async () => {
107
+ const onSubmit = jest.fn();
108
+ const initialRelations = [
109
+ { resource_type: "system", resource_id: null },
110
+ { resource_type: "system", resource_id: 2 },
111
+ ];
112
+ const templateWithRelations = {
113
+ ...validTemplate,
114
+ template_resource_relations: initialRelations,
115
+ };
116
+ const renderOpts = {
117
+ state: {
118
+ templateDeleting: false,
119
+ templateSaving: false,
120
+ },
121
+ };
122
+ const rendered = render(
123
+ <TemplateForm template={templateWithRelations} onSubmit={onSubmit} loading={false} />,
124
+ renderOpts
125
+ );
126
+ await waitForLoad(rendered);
127
+
128
+ const user = userEvent.setup({ delay: null });
129
+ await user.click(rendered.getByRole("button", { name: /save/i }));
130
+
131
+ await waitFor(() => {
132
+ expect(onSubmit).toHaveBeenCalled();
133
+ });
134
+ const submittedPayload = onSubmit.mock.calls[0][0];
135
+ expect(submittedPayload.template.template_resource_relations).toEqual([
136
+ { resource_type: "system", resource_id: null },
137
+ { resource_type: "system", resource_id: 1 },
138
+ ]);
139
+ });
140
+
141
+ it("does not render TemplateRelationsForm when scope is not in scopesWithRelations", async () => {
142
+ const { useWebContext } = require("@truedat/core/webContext");
143
+ useWebContext.mockReturnValue({ scopesWithRelations: [] });
144
+ const templateOtherScope = { ...validTemplate, scope: "dd" };
145
+ const onSubmit = jest.fn();
146
+ const renderOpts = {
147
+ state: {
148
+ templateDeleting: false,
149
+ templateSaving: false,
150
+ },
151
+ };
152
+ const rendered = render(
153
+ <TemplateForm template={templateOtherScope} onSubmit={onSubmit} loading={false} />,
154
+ renderOpts
155
+ );
156
+ await waitForLoad(rendered);
157
+
158
+ expect(rendered.queryByTestId("template-relations-form")).not.toBeInTheDocument();
159
+
160
+ const user = userEvent.setup({ delay: null });
161
+ await user.click(rendered.getByRole("button", { name: /save/i }));
162
+
163
+ await waitFor(() => {
164
+ expect(onSubmit).toHaveBeenCalled();
165
+ });
166
+ const submittedPayload = onSubmit.mock.calls[0][0];
167
+ expect(submittedPayload.template.template_resource_relations).toEqual([]);
70
168
  });
71
169
  });