@truedat/dd 8.12.4 → 8.12.6

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/dd",
3
- "version": "8.12.4",
3
+ "version": "8.12.6",
4
4
  "description": "Truedat Web Data Dictionary",
5
5
  "sideEffects": false,
6
6
  "module": "src/index.js",
@@ -54,7 +54,7 @@
54
54
  "@testing-library/jest-dom": "^6.6.3",
55
55
  "@testing-library/react": "^16.3.0",
56
56
  "@testing-library/user-event": "^14.6.1",
57
- "@truedat/test": "8.12.4",
57
+ "@truedat/test": "8.12.6",
58
58
  "identity-obj-proxy": "^3.0.0",
59
59
  "jest": "^29.7.0",
60
60
  "redux-saga-test-plan": "^4.0.6"
@@ -90,5 +90,5 @@
90
90
  "svg-pan-zoom": "^3.6.2",
91
91
  "swr": "^2.3.3"
92
92
  },
93
- "gitHead": "d5bc1af84c4d828bdec676b0da58385cf0d6015f"
93
+ "gitHead": "fe7375c2d3c6ebdaf3dacd985f56433eda477712"
94
94
  }
@@ -24,18 +24,27 @@ const linksField = (field) =>
24
24
  (s) => s.kind === "Field" && s.name.value === "links",
25
25
  );
26
26
 
27
- const expectRelationLinks = (document) => {
27
+ const expectResourceType = (field, value) => {
28
+ const resourceType = linksField(field).arguments.find(
29
+ (argument) => argument.name.value === "resource_type",
30
+ );
31
+ expect(resourceType.value.value).toBe(value);
32
+ };
33
+
34
+ const expectRelationLinks = (document, childrenResourceType) => {
28
35
  const parents = fieldAtPath(document, PARENT_PATH);
29
36
  const children = fieldAtPath(document, CHILD_PATH);
30
37
 
31
38
  expect(selectionNames(parents)).toEqual(expect.arrayContaining(["links"]));
32
39
  expect(selectionNames(children)).toEqual(expect.arrayContaining(["links"]));
33
40
 
34
- const resourceType = linksField(parents).arguments.find(
35
- (argument) => argument.name.value === "resource_type",
36
- );
37
- expect(resourceType.value.value).toBe("business_concept");
38
- expect(linksField(children).arguments).toEqual([]);
41
+ expectResourceType(parents, "business_concept");
42
+
43
+ if (childrenResourceType) {
44
+ expectResourceType(children, childrenResourceType);
45
+ } else {
46
+ expect(linksField(children).arguments).toEqual([]);
47
+ }
39
48
  };
40
49
 
41
50
  describe("LEGACY_DATA_STRUCTURE_VERSION_QUERY", () => {
@@ -46,10 +55,10 @@ describe("LEGACY_DATA_STRUCTURE_VERSION_QUERY", () => {
46
55
 
47
56
  describe("buildDSVQuery", () => {
48
57
  it("requests concept links on parent and child structure relations", () => {
49
- expectRelationLinks(buildDSVQuery(false));
58
+ expectRelationLinks(buildDSVQuery(false), "business_concept");
50
59
  });
51
60
 
52
61
  it("requests concept links on parent and child structure relations when loading metadata", () => {
53
- expectRelationLinks(buildDSVQuery(true));
62
+ expectRelationLinks(buildDSVQuery(true), "business_concept");
54
63
  });
55
64
  });
@@ -687,7 +687,7 @@ export const buildDSVQuery = (loadStructureMetadata) => gql`
687
687
  name
688
688
  description
689
689
  }
690
- links
690
+ links(resource_type: "business_concept")
691
691
  }
692
692
  }
693
693
 
@@ -7,6 +7,7 @@ import { FormattedMessage } from "react-intl";
7
7
  import { ConfirmModal, GroupActions } from "@truedat/core/components";
8
8
  import { linkTo } from "@truedat/core/routes";
9
9
  import { doStructureNoteAction } from "../routines";
10
+ import { canPerformAction } from "../utils/structureNoteActions";
10
11
 
11
12
  const ModalDelete = ({
12
13
  className,
@@ -67,6 +68,8 @@ export const StructureNoteActions = ({
67
68
  doStructureNoteAction,
68
69
  actions,
69
70
  structure,
71
+ template,
72
+ content,
70
73
  }) => {
71
74
  const actionToIcon = {
72
75
  deleted: "trash alternate outline",
@@ -88,6 +91,7 @@ export const StructureNoteActions = ({
88
91
  _.toPairs,
89
92
  _.reject(([action, _data]) => !_.get(action)(actionToIcon)),
90
93
  _.map(([action, data]) => {
94
+ const allowed = canPerformAction(action, content, template);
91
95
  const baseAction = {
92
96
  key: action,
93
97
  icon: _.get(action)(actionToIcon),
@@ -100,8 +104,11 @@ export const StructureNoteActions = ({
100
104
  ),
101
105
  as: actionToAs(action),
102
106
  to: actionToTo(action),
107
+ className: "td-structure-note-action",
108
+ disabled: !allowed,
109
+ ...(!allowed ? { style: { opacity: 0.45 } } : {}),
103
110
  onClick:
104
- action === "edited" || withModal(action)
111
+ action === "edited" || withModal(action) || !allowed
105
112
  ? undefined
106
113
  : () =>
107
114
  doStructureNoteAction({
@@ -135,6 +142,8 @@ StructureNoteActions.propTypes = {
135
142
  doStructureNoteAction: PropTypes.func,
136
143
  actions: PropTypes.object.isRequired,
137
144
  structure: PropTypes.object.isRequired,
145
+ template: PropTypes.object,
146
+ content: PropTypes.object,
138
147
  };
139
148
 
140
149
  export default connect(null, { doStructureNoteAction })(StructureNoteActions);
@@ -1,16 +1,24 @@
1
1
  import _ from "lodash/fp";
2
2
  import { lazy, useState } from "react";
3
3
  import PropTypes from "prop-types";
4
- import { useIntl, FormattedMessage } from "react-intl";
4
+ import { FormattedMessage, useIntl } from "react-intl";
5
5
  import Moment from "react-moment";
6
6
  import { connect } from "react-redux";
7
7
  import { Link } from "react-router";
8
- import { Button, Segment, Message, Loader, Label } from "semantic-ui-react";
8
+ import {
9
+ Button,
10
+ Segment,
11
+ Message,
12
+ Loader,
13
+ Label,
14
+ Icon,
15
+ } from "semantic-ui-react";
9
16
  import { linkTo } from "@truedat/core/routes";
10
17
  import {
11
18
  getActiveStructureNotes,
12
19
  getVersionedStructureNotes,
13
20
  } from "../selectors/getSortedStructureNotes";
21
+ import { getMissingMandatoryFields } from "../utils/structureNoteActions";
14
22
 
15
23
  import StructureNoteActions from "./StructureNoteActions";
16
24
 
@@ -27,6 +35,10 @@ const StructureNote = ({ structureNote, template, structure, notesSize }) => {
27
35
  structureNote?.status !== "published" ||
28
36
  !_.isEmpty(actions) ||
29
37
  notesSize > 1;
38
+ const noteContent =
39
+ structureNote?.dynamic_content || structureNote?.df_content || {};
40
+ const missingFields = getMissingMandatoryFields(noteContent, template);
41
+ const showMissingLabel = !_.isEmpty(missingFields);
30
42
  return (
31
43
  <Segment>
32
44
  <div
@@ -59,6 +71,21 @@ const StructureNote = ({ structureNote, template, structure, notesSize }) => {
59
71
  />
60
72
  </Label>
61
73
  )}
74
+ {showMissingLabel && (
75
+ <Label
76
+ basic
77
+ color="orange"
78
+ size="tiny"
79
+ className="td-missing-mandatory-label"
80
+ >
81
+ <Icon name="warning sign" />
82
+ <FormattedMessage
83
+ id="structure.notes.actions.missing_mandatory"
84
+ defaultMessage="Missing required fields: {fields}"
85
+ values={{ fields: missingFields.join(", ") }}
86
+ />
87
+ </Label>
88
+ )}
62
89
  </>
63
90
  )}
64
91
  </div>
@@ -70,7 +97,12 @@ const StructureNote = ({ structureNote, template, structure, notesSize }) => {
70
97
  />
71
98
  </Button>
72
99
  )}
73
- <StructureNoteActions actions={actions} structure={structure} />
100
+ <StructureNoteActions
101
+ actions={actions}
102
+ structure={structure}
103
+ template={template}
104
+ content={noteContent}
105
+ />
74
106
  </div>
75
107
  </div>
76
108
  {!collapsed && (
@@ -99,6 +131,7 @@ export const StructureNotes = ({
99
131
  template,
100
132
  }) => {
101
133
  const { formatMessage } = useIntl();
134
+
102
135
  const [isShowingVersioned, setShowingVersioned] = useState(false);
103
136
  const notesSize = _.size(activeStructureNotes);
104
137
 
@@ -163,6 +196,11 @@ StructureNotes.propTypes = {
163
196
  template: PropTypes.object,
164
197
  };
165
198
 
199
+ export const findTemplate = ({
200
+ templates,
201
+ structure: { structure_type } = {},
202
+ }) => _.find(_.propEq("id", _.prop("template_id")(structure_type)))(templates);
203
+
166
204
  const mapStateToProps = (state) => {
167
205
  const { templates, structure, structureNotes } = state;
168
206
  return {
@@ -170,9 +208,7 @@ const mapStateToProps = (state) => {
170
208
  versionedStructureNotes: getVersionedStructureNotes(state),
171
209
  structureNotes,
172
210
  structure,
173
- template: _.find(
174
- _.propEq("id", _.path("structure_type.template_id")(structure)),
175
- )(templates),
211
+ template: findTemplate({ templates, structure }),
176
212
  };
177
213
  };
178
214
 
@@ -1,12 +1,12 @@
1
1
  import _ from "lodash/fp";
2
- import { lazy, useEffect, useState } from "react";
2
+ import { lazy, useEffect, useRef, useState } from "react";
3
3
  import PropTypes from "prop-types";
4
4
  import { connect } from "react-redux";
5
5
  import { Button, Form, Segment } from "semantic-ui-react";
6
6
  import { useIntl } from "react-intl";
7
7
  import { HistoryBackButton } from "@truedat/core/components";
8
8
  import { selectTemplate, selectDomains } from "@truedat/df/routines";
9
- import { applyTemplate, validateContent } from "@truedat/df/utils";
9
+ import { applyTemplate } from "@truedat/df/utils";
10
10
  import SuggestionsWidget from "@truedat/ai/components/suggestions/SuggestionsWidget";
11
11
  import { doStructureNoteAction } from "../routines";
12
12
  import { getLatestDfContent } from "../selectors/getSortedStructureNotes";
@@ -17,6 +17,16 @@ const TemplateLoader = lazy(
17
17
  () => import("@truedat/df/templates/components/TemplateLoader"),
18
18
  );
19
19
 
20
+ const isInformed = _.flow(
21
+ _.cond([
22
+ [_.isArray, _.isEmpty],
23
+ [_.isObject, _.isEmpty],
24
+ [_.isString, _.isEmpty],
25
+ [_.stubTrue, _.isNil],
26
+ ]),
27
+ (value) => !value,
28
+ );
29
+
20
30
  export const StructureNotesEdit = ({
21
31
  applyTemplate,
22
32
  structure,
@@ -28,8 +38,9 @@ export const StructureNotesEdit = ({
28
38
  latestDfContent,
29
39
  }) => {
30
40
  const [content, setContent] = useState(latestDfContent);
31
-
41
+ const explicitEmptiesRef = useRef({});
32
42
  const isModification = !_.isEmpty(latestDfContent);
43
+ const initializedRef = useRef(false);
33
44
 
34
45
  useEffect(() => {
35
46
  selectDomains(structure.domain_ids);
@@ -37,18 +48,37 @@ export const StructureNotesEdit = ({
37
48
  }, [template, structure, selectTemplate, selectDomains]);
38
49
 
39
50
  useEffect(() => {
40
- if (template && latestDfContent) {
51
+ if (!initializedRef.current && template && latestDfContent) {
52
+ initializedRef.current = true;
41
53
  setContent(applyTemplate(latestDfContent, structure.domain_id));
42
54
  }
43
55
  }, [applyTemplate, template, latestDfContent, structure.domain_id]);
44
56
 
45
57
  const { formatMessage, locale } = useIntl();
46
58
 
47
- const submitDisabled =
48
- !template || _.size(validateContent(template)(content)) > 0;
59
+ const submitDisabled = !template;
60
+
61
+ const handleFormChange = (newContent) => {
62
+ const clearedFields = _.flow(
63
+ _.pickBy((value) => isInformed(value.value)),
64
+ _.omit(Object.keys(newContent)),
65
+ _.mapValues(() => ({ value: "", origin: "user" })),
66
+ )(content);
67
+
68
+ explicitEmptiesRef.current = {
69
+ ..._.omit(
70
+ Object.keys(_.pickBy((value) => isInformed(value.value))(newContent)),
71
+ )(explicitEmptiesRef.current),
72
+ ...clearedFields,
73
+ };
74
+ setContent(newContent);
75
+ };
49
76
 
50
77
  const handleSubmit = () => {
51
- const df_content = applyTemplate(content, structure.domain_id);
78
+ const df_content = {
79
+ ...applyTemplate(content, structure.domain_id),
80
+ ...explicitEmptiesRef.current,
81
+ };
52
82
  const draftNoteAction = _.flow(
53
83
  _.get("structureNotes"),
54
84
  _.find({ status: "draft" }),
@@ -96,7 +126,7 @@ export const StructureNotesEdit = ({
96
126
  />
97
127
  <Form>
98
128
  <DynamicForm
99
- onChange={setContent}
129
+ onChange={handleFormChange}
100
130
  content={content}
101
131
  isModification={isModification}
102
132
  />
@@ -202,4 +202,100 @@ describe("<StructureNoteActions />", () => {
202
202
  .querySelector(".clipboard.check.icon"),
203
203
  ).toBeInTheDocument();
204
204
  });
205
+
206
+ it("disables 'pending_approval' when mandatory fields are missing", async () => {
207
+ const doStructureNoteAction = jest.fn();
208
+ const template = {
209
+ content: [
210
+ {
211
+ name: "g1",
212
+ fields: [{ name: "required", type: "string", cardinality: "1" }],
213
+ },
214
+ ],
215
+ };
216
+ const props = {
217
+ doStructureNoteAction,
218
+ actions: {
219
+ pending_approval: {
220
+ href: "url",
221
+ input: { status: "pending_approval" },
222
+ method: "PATCH",
223
+ },
224
+ },
225
+ structure: { id: 1 },
226
+ template,
227
+ content: {},
228
+ };
229
+
230
+ const rendered = render(<StructureNoteActions {...props} />);
231
+ await waitForLoad(rendered);
232
+
233
+ const item = (await rendered.findByText(/pending_approval/i)).closest(
234
+ ".item",
235
+ );
236
+ expect(item).toHaveClass("disabled");
237
+ expect(doStructureNoteAction).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it("disables 'published' when mandatory fields are missing", async () => {
241
+ const doStructureNoteAction = jest.fn();
242
+ const template = {
243
+ content: [
244
+ {
245
+ name: "g1",
246
+ fields: [{ name: "required", type: "string", cardinality: "1" }],
247
+ },
248
+ ],
249
+ };
250
+ const props = {
251
+ doStructureNoteAction,
252
+ actions: {
253
+ published: {
254
+ href: "url",
255
+ input: { status: "published" },
256
+ method: "PATCH",
257
+ },
258
+ },
259
+ structure: { id: 1 },
260
+ template,
261
+ content: {},
262
+ };
263
+
264
+ const rendered = render(<StructureNoteActions {...props} />);
265
+ await waitForLoad(rendered);
266
+
267
+ const item = (await rendered.findByText(/published/i)).closest(".item");
268
+ expect(item).toHaveClass("disabled");
269
+ expect(doStructureNoteAction).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it("keeps 'edited' enabled when mandatory fields are missing", async () => {
273
+ const doStructureNoteAction = jest.fn();
274
+ const template = {
275
+ content: [
276
+ {
277
+ name: "g1",
278
+ fields: [{ name: "required", type: "string", cardinality: "1" }],
279
+ },
280
+ ],
281
+ };
282
+ const props = {
283
+ doStructureNoteAction,
284
+ actions: {
285
+ edited: {
286
+ href: "edit-url",
287
+ method: "GET",
288
+ },
289
+ },
290
+ structure: { id: 1 },
291
+ template,
292
+ content: {},
293
+ };
294
+
295
+ const rendered = render(<StructureNoteActions {...props} />);
296
+ await waitForLoad(rendered);
297
+
298
+ const item = (await rendered.findByText(/edited/i)).closest(".item");
299
+ expect(item).not.toHaveClass("disabled");
300
+ });
205
301
  });
@@ -1,6 +1,6 @@
1
1
  import userEvent from "@testing-library/user-event";
2
2
  import { render, waitForLoad } from "@truedat/test/render";
3
- import { StructureNotes } from "../StructureNotes";
3
+ import { StructureNotes, findTemplate } from "../StructureNotes";
4
4
 
5
5
  jest.mock("@truedat/df/components/DynamicFormViewer", () => {
6
6
  const DynamicFormViewer = () => <div>DynamicFormViewer</div>;
@@ -111,4 +111,173 @@ describe("<StructureNotes />", () => {
111
111
  );
112
112
  expect(rendered.container).toMatchSnapshot();
113
113
  });
114
+
115
+ it("disables publish/pending_approval when df_content has missing mandatory fields", async () => {
116
+ const props = {
117
+ structure: { id: 1 },
118
+ structureNotes: { _actions: {}, loading: false },
119
+ activeStructureNotes: [
120
+ {
121
+ id: 4,
122
+ status: "draft",
123
+ dynamic_content: null,
124
+ df_content: null,
125
+ version: "4",
126
+ _actions: {
127
+ pending_approval: {
128
+ href: "url",
129
+ input: { status: "pending_approval" },
130
+ method: "PATCH",
131
+ },
132
+ published: {
133
+ href: "url",
134
+ input: { status: "published" },
135
+ method: "PATCH",
136
+ },
137
+ },
138
+ },
139
+ ],
140
+ versionedStructureNotes: [],
141
+ template: {
142
+ id: 2,
143
+ content: [
144
+ {
145
+ name: "g1",
146
+ fields: [{ name: "required", type: "string", cardinality: "1" }],
147
+ },
148
+ ],
149
+ scope: "dd",
150
+ },
151
+ doStructureNoteAction: () => {},
152
+ };
153
+
154
+ const rendered = render(<StructureNotes {...props} />);
155
+ await waitForLoad(rendered);
156
+
157
+ const pendingItem = (
158
+ await rendered.findByText(/pending_approval/i)
159
+ ).closest(".item");
160
+ expect(pendingItem).toHaveClass("disabled");
161
+
162
+ const publishedItem = (await rendered.findByText(/published/i)).closest(
163
+ ".item",
164
+ );
165
+ expect(publishedItem).toHaveClass("disabled");
166
+ });
167
+
168
+ it("shows missing mandatory label in header when draft has missing fields", async () => {
169
+ const props = {
170
+ structure: { id: 1 },
171
+ structureNotes: { _actions: {}, loading: false },
172
+ activeStructureNotes: [
173
+ {
174
+ id: 4,
175
+ status: "draft",
176
+ dynamic_content: null,
177
+ df_content: null,
178
+ version: "4",
179
+ _actions: {
180
+ published: {
181
+ href: "url",
182
+ input: { status: "published" },
183
+ method: "PATCH",
184
+ },
185
+ },
186
+ },
187
+ ],
188
+ versionedStructureNotes: [],
189
+ template: {
190
+ id: 2,
191
+ content: [
192
+ {
193
+ name: "g1",
194
+ fields: [{ name: "required", type: "string", cardinality: "1" }],
195
+ },
196
+ ],
197
+ scope: "dd",
198
+ },
199
+ doStructureNoteAction: () => {},
200
+ };
201
+
202
+ const rendered = render(<StructureNotes {...props} />);
203
+ await waitForLoad(rendered);
204
+
205
+ expect(
206
+ await rendered.findByText(/missing required fields/i),
207
+ ).toBeInTheDocument();
208
+ expect(
209
+ rendered.container.querySelector(".td-missing-mandatory-label"),
210
+ ).toBeInTheDocument();
211
+ });
212
+
213
+ it("does not show missing mandatory label when all fields are complete", async () => {
214
+ const props = {
215
+ structure: { id: 1 },
216
+ structureNotes: { _actions: {}, loading: false },
217
+ activeStructureNotes: [
218
+ {
219
+ id: 4,
220
+ status: "draft",
221
+ dynamic_content: { required: { value: "ok", origin: "user" } },
222
+ df_content: { required: { value: "ok", origin: "user" } },
223
+ version: "4",
224
+ _actions: {
225
+ published: {
226
+ href: "url",
227
+ input: { status: "published" },
228
+ method: "PATCH",
229
+ },
230
+ },
231
+ },
232
+ ],
233
+ versionedStructureNotes: [],
234
+ template: {
235
+ id: 2,
236
+ content: [
237
+ {
238
+ name: "g1",
239
+ fields: [{ name: "required", type: "string", cardinality: "1" }],
240
+ },
241
+ ],
242
+ scope: "dd",
243
+ },
244
+ doStructureNoteAction: () => {},
245
+ };
246
+
247
+ const rendered = render(<StructureNotes {...props} />);
248
+ await waitForLoad(rendered);
249
+
250
+ expect(
251
+ rendered.queryByText(/missing required fields/i),
252
+ ).not.toBeInTheDocument();
253
+ expect(
254
+ rendered.container.querySelector(".td-missing-mandatory-label"),
255
+ ).not.toBeInTheDocument();
256
+ });
257
+
258
+ it("resolves the template from structure_type.template_id", () => {
259
+ const templates = [
260
+ { id: 1, name: "other" },
261
+ { id: 2, name: "Business Concept", content: [] },
262
+ ];
263
+ expect(
264
+ findTemplate({
265
+ templates,
266
+ structure: { structure_type: { template_id: 2 } },
267
+ }),
268
+ ).toEqual(templates[1]);
269
+ expect(
270
+ findTemplate({
271
+ templates,
272
+ structure: { structure_type: { template_id: 1 } },
273
+ }),
274
+ ).toEqual(templates[0]);
275
+ expect(
276
+ findTemplate({
277
+ templates,
278
+ structure: { structure_type: { template: { id: 2 } } },
279
+ }),
280
+ ).toBeUndefined();
281
+ expect(findTemplate({ templates, structure: {} })).toBeUndefined();
282
+ });
114
283
  });
@@ -1,5 +1,5 @@
1
1
  import userEvent from "@testing-library/user-event";
2
- import { waitFor } from "@testing-library/react";
2
+ import { act, waitFor } from "@testing-library/react";
3
3
  import { render, waitForLoad } from "@truedat/test/render";
4
4
  import { applyTemplate } from "@truedat/df/utils";
5
5
  import SuggestionsWidget from "@truedat/ai/components/suggestions/SuggestionsWidget";
@@ -10,9 +10,23 @@ jest.mock("@truedat/ai/components/suggestions/SuggestionsWidget", () => ({
10
10
  default: jest.fn(() => null),
11
11
  }));
12
12
 
13
+ let dynamicFormOnChange;
14
+ let dynamicFormContent;
15
+
16
+ jest.mock("@truedat/df/components/DynamicForm", () => ({
17
+ __esModule: true,
18
+ default: ({ onChange, content }) => {
19
+ dynamicFormOnChange = onChange;
20
+ dynamicFormContent = content;
21
+ return <div data-testid="dynamic-form" />;
22
+ },
23
+ }));
24
+
13
25
  describe("<StructureNotesEdit />", () => {
14
26
  beforeEach(() => {
15
27
  jest.clearAllMocks();
28
+ dynamicFormOnChange = undefined;
29
+ dynamicFormContent = undefined;
16
30
  });
17
31
 
18
32
  it("matches the latest snapshot", async () => {
@@ -121,4 +135,174 @@ describe("<StructureNotesEdit />", () => {
121
135
  }),
122
136
  );
123
137
  });
138
+
139
+ it("preserves explicit empty fields when a field is cleared", async () => {
140
+ const doStructureNoteAction = jest.fn();
141
+ const template = {
142
+ id: 2,
143
+ content: [
144
+ {
145
+ name: "test-group",
146
+ fields: [
147
+ {
148
+ name: "foo",
149
+ type: "string",
150
+ values: null,
151
+ },
152
+ {
153
+ name: "bar",
154
+ type: "string",
155
+ values: null,
156
+ },
157
+ ],
158
+ },
159
+ ],
160
+ };
161
+
162
+ const props = {
163
+ structure: { id: 1 },
164
+ template,
165
+ doStructureNoteAction,
166
+ selectTemplate: () => {},
167
+ selectDomain: () => {},
168
+ structureNotes: {
169
+ structureNotes: [],
170
+ },
171
+ latestDfContent: {
172
+ foo: { value: "foo value", origin: "user" },
173
+ bar: { value: "bar value", origin: "user" },
174
+ },
175
+ applyTemplate: applyTemplate(template),
176
+ selectDomains: jest.fn(),
177
+ };
178
+
179
+ const rendered = render(<StructureNotesEdit {...props} />);
180
+ await waitForLoad(rendered);
181
+
182
+ await act(async () => {
183
+ dynamicFormOnChange({
184
+ foo: { value: "foo value", origin: "user" },
185
+ });
186
+ });
187
+
188
+ const user = userEvent.setup({ delay: null });
189
+ await user.click(await rendered.findByText(/save/i));
190
+
191
+ await waitFor(() => {
192
+ expect(doStructureNoteAction.mock.calls.length).toBe(1);
193
+ });
194
+ expect(doStructureNoteAction).toHaveBeenCalledWith(
195
+ expect.objectContaining({
196
+ action: {
197
+ input: {
198
+ df_content: {
199
+ foo: { value: "foo value", origin: "user" },
200
+ bar: { value: "", origin: "user" },
201
+ },
202
+ },
203
+ },
204
+ dataStructureId: 1,
205
+ redirect: true,
206
+ }),
207
+ );
208
+ });
209
+
210
+ it("does not reset local content when latestDfContent changes)", async () => {
211
+ const doStructureNoteAction = jest.fn();
212
+ const template = {
213
+ id: 2,
214
+ content: [
215
+ {
216
+ name: "test-group",
217
+ fields: [
218
+ {
219
+ name: "foo",
220
+ type: "string",
221
+ values: null,
222
+ },
223
+ ],
224
+ },
225
+ ],
226
+ };
227
+
228
+ const props = {
229
+ structure: { id: 1 },
230
+ template,
231
+ doStructureNoteAction,
232
+ selectTemplate: () => {},
233
+ selectDomain: () => {},
234
+ structureNotes: {
235
+ structureNotes: [],
236
+ },
237
+ latestDfContent: { foo: { value: "saved value", origin: "user" } },
238
+ applyTemplate: applyTemplate(template),
239
+ selectDomains: jest.fn(),
240
+ };
241
+
242
+ const rendered = render(<StructureNotesEdit {...props} />);
243
+ await waitForLoad(rendered);
244
+
245
+ await act(async () => {
246
+ dynamicFormOnChange({
247
+ foo: { value: "local edit", origin: "user" },
248
+ });
249
+ });
250
+ expect(dynamicFormContent).toEqual({
251
+ foo: { value: "local edit", origin: "user" },
252
+ });
253
+
254
+ const updatedProps = {
255
+ ...props,
256
+ latestDfContent: { foo: { value: "saved value", origin: "user" } },
257
+ };
258
+ await act(async () => {
259
+ rendered.rerender(<StructureNotesEdit {...updatedProps} />);
260
+ });
261
+
262
+ expect(dynamicFormContent).toEqual({
263
+ foo: { value: "local edit", origin: "user" },
264
+ });
265
+ });
266
+
267
+ it("Save button is enabled when mandatory fields are missing", async () => {
268
+ const doStructureNoteAction = jest.fn();
269
+ const template = {
270
+ id: 2,
271
+ content: [
272
+ {
273
+ name: "test-group",
274
+ fields: [
275
+ {
276
+ name: "required_field",
277
+ type: "string",
278
+ cardinality: "1",
279
+ values: null,
280
+ },
281
+ ],
282
+ },
283
+ ],
284
+ };
285
+
286
+ const props = {
287
+ structure: { id: 1 },
288
+ template,
289
+ doStructureNoteAction,
290
+ selectTemplate: () => {},
291
+ selectDomain: () => {},
292
+ structureNotes: {
293
+ structureNotes: [],
294
+ },
295
+ latestDfContent: {},
296
+ applyTemplate: applyTemplate(template),
297
+ selectDomains: jest.fn(),
298
+ };
299
+
300
+ const rendered = render(<StructureNotesEdit {...props} />);
301
+ await waitForLoad(rendered);
302
+
303
+ const saveButton = await rendered.findByRole("button", {
304
+ name: /actions\.save/i,
305
+ });
306
+ expect(saveButton).not.toBeDisabled();
307
+ });
124
308
  });
@@ -21,8 +21,9 @@ exports[`<StructureNoteActions /> matches the latest snapshot 1`] = `
21
21
  >
22
22
  <div
23
23
  aria-checked="false"
24
+ aria-disabled="false"
24
25
  aria-selected="true"
25
- class="selected item td-icon-text-control"
26
+ class="selected item td-structure-note-action td-icon-text-control"
26
27
  role="option"
27
28
  style="pointer-events: all;"
28
29
  >
@@ -8,6 +8,9 @@ exports[`<StructureNotesEdit /> matches the latest snapshot 1`] = `
8
8
  <form
9
9
  class="ui form"
10
10
  >
11
+ <div
12
+ data-testid="dynamic-form"
13
+ />
11
14
  <button
12
15
  class="ui primary button"
13
16
  >
@@ -1,4 +1,5 @@
1
1
  import {
2
+ bulkUpdateStructures,
2
3
  createReferenceDataset,
3
4
  deleteReferenceDataset,
4
5
  updateReferenceDataset,
@@ -74,6 +75,53 @@ describe("reducers: ddMessage", () => {
74
75
  });
75
76
  });
76
77
 
78
+ it("should handle the bulkUpdateStructures.FAILURE action with error details", () => {
79
+ const ddMsg = ddMessage(fooState, {
80
+ type: bulkUpdateStructures.FAILURE,
81
+ payload: { status: 422, data: { error: { message: "some message" } } },
82
+ });
83
+ expect(ddMsg).toMatchObject({
84
+ error: true,
85
+ header: "structures.bulkUpdateStructures.failed.header",
86
+ content: "some message",
87
+ });
88
+ });
89
+
90
+ it("should handle bulkUpdateStructures.FAILURE with string payload (timeout)", () => {
91
+ const ddMsg = ddMessage(fooState, {
92
+ type: bulkUpdateStructures.FAILURE,
93
+ payload: "timeout of 0ms exceeded",
94
+ });
95
+ expect(ddMsg).toMatchObject({
96
+ error: true,
97
+ header: "structures.bulkUpdateStructures.failed.timeout.header",
98
+ content: "timeout of 0ms exceeded",
99
+ });
100
+ });
101
+
102
+ it("should handle bulkUpdateStructures.FAILURE with string payload (generic network error)", () => {
103
+ const ddMsg = ddMessage(fooState, {
104
+ type: bulkUpdateStructures.FAILURE,
105
+ payload: "Network Error",
106
+ });
107
+ expect(ddMsg).toMatchObject({
108
+ error: true,
109
+ header: "structures.bulkUpdateStructures.failed.header",
110
+ content: "Network Error",
111
+ });
112
+ });
113
+
114
+ it("should handle bulkUpdateStructures.FAILURE with 504 status as timeout", () => {
115
+ const ddMsg = ddMessage(fooState, {
116
+ type: bulkUpdateStructures.FAILURE,
117
+ payload: { status: 504, data: {} },
118
+ });
119
+ expect(ddMsg).toMatchObject({
120
+ error: true,
121
+ header: "structures.bulkUpdateStructures.failed.timeout.header",
122
+ });
123
+ });
124
+
77
125
  it("should handle the createReferenceDataset.SUCCESS action", () => {
78
126
  expect(ddMessage(fooState, createReferenceDataset.success())).toMatchObject(
79
127
  { header: "alert.createReferenceDataset.success.header" },
@@ -1,10 +1,12 @@
1
1
  import { clearRedirect } from "@truedat/core/routines";
2
+ import { STRUCTURES } from "@truedat/core/routes";
2
3
  import {
3
4
  createReferenceDataset,
4
5
  deleteReferenceDataset,
5
6
  updateReferenceDataset,
6
7
  updateStructureType,
7
8
  updateStructure,
9
+ bulkUpdateStructures,
8
10
  } from "../../routines";
9
11
  import { structureRedirect, initialState } from "../structureRedirect";
10
12
 
@@ -69,4 +71,24 @@ describe("reducers: structureRedirect", () => {
69
71
  ),
70
72
  ).toBe("/referenceDatasets/42");
71
73
  });
74
+
75
+ it("should redirect to STRUCTURES when bulkUpdateStructures.SUCCESS has no errors", () => {
76
+ const payload = { data: { errors: [] } };
77
+ expect(
78
+ structureRedirect(fooState, {
79
+ type: bulkUpdateStructures.SUCCESS,
80
+ payload,
81
+ }),
82
+ ).toBe(STRUCTURES);
83
+ });
84
+
85
+ it("should not redirect when bulkUpdateStructures.SUCCESS has errors", () => {
86
+ const payload = { data: { errors: [{ message: "err" }] } };
87
+ expect(
88
+ structureRedirect(fooState, {
89
+ type: bulkUpdateStructures.SUCCESS,
90
+ payload,
91
+ }),
92
+ ).toBe(fooState);
93
+ });
72
94
  });
@@ -19,6 +19,38 @@ const ddMessage = (state = initialState, { type, payload }) => {
19
19
  return initialState;
20
20
  case bulkUpdateStructures.SUCCESS:
21
21
  return getUploadAlertMessage(payload);
22
+ case bulkUpdateStructures.FAILURE: {
23
+ const isTimeout =
24
+ typeof payload === "string"
25
+ ? /timeout|timed out|ECONNABORTED/i.test(payload)
26
+ : payload?.status === 504 ||
27
+ /timeout/i.test(payload?.data?.error?.message || "");
28
+
29
+ if (isTimeout) {
30
+ return {
31
+ error: true,
32
+ header: "structures.bulkUpdateStructures.failed.timeout.header",
33
+ icon: "attention",
34
+ content:
35
+ typeof payload === "string"
36
+ ? payload
37
+ : payload?.data?.error?.message,
38
+ initialMaxListItems: 5,
39
+ };
40
+ }
41
+
42
+ return {
43
+ error: true,
44
+ header: "structures.bulkUpdateStructures.failed.header",
45
+ icon: "attention",
46
+ content:
47
+ typeof payload === "string"
48
+ ? payload
49
+ : payload?.data?.error?.message ||
50
+ "structures.bulkUpdateStructures.failed.generic.content",
51
+ initialMaxListItems: 5,
52
+ };
53
+ }
22
54
  case uploadStructuresDomains.SUCCESS: {
23
55
  const messagesImp = _.has("errors")(payload.data)
24
56
  ? _.flow(
@@ -47,8 +47,13 @@ export const structureRedirect = (
47
47
  return initialState;
48
48
  case updateStructure.SUCCESS:
49
49
  return payload?.redirect || state;
50
- case bulkUpdateStructures.SUCCESS:
51
- return STRUCTURES;
50
+ case bulkUpdateStructures.SUCCESS: {
51
+ const errors = payload?.data?.errors;
52
+ if (!errors || errors.length === 0) {
53
+ return STRUCTURES;
54
+ }
55
+ return state;
56
+ }
52
57
  case createSystem.SUCCESS: {
53
58
  const id = _.path("data.id")(payload);
54
59
  return linkTo.SYSTEM_STRUCTURES({ id });
@@ -53,4 +53,18 @@ describe("selectors: getUploadAlertMessage", () => {
53
53
  "structures.bulkUpdateStructures.failed.note.only_draft_are_editable",
54
54
  );
55
55
  });
56
+
57
+ it("returns uploadFailed with errors array when status is 422", () => {
58
+ const data = { errors: [{ message: "some_error" }] };
59
+ const result = getUploadAlertMessage({ status: 422, data });
60
+ expect(result.error).toBe(true);
61
+ expect(result.header).toBe("structures.bulkUpdateStructures.failed.header");
62
+ expect(result.messages.length).toBe(1);
63
+ });
64
+
65
+ it("returns uploadFailed when status is 500", () => {
66
+ const result = getUploadAlertMessage({ status: 500, data: {} });
67
+ expect(result.error).toBe(true);
68
+ expect(result.header).toBe("structures.bulkUpdateStructures.failed.header");
69
+ });
56
70
  });
@@ -48,6 +48,32 @@ const uploadAccepted = () => {
48
48
  };
49
49
  };
50
50
 
51
+ const uploadFailed = (data) => ({
52
+ error: true,
53
+ header: "structures.bulkUpdateStructures.failed.header",
54
+ icon: "attention",
55
+ color: "red",
56
+ text: "",
57
+ messages: _.has("errors")(data)
58
+ ? _.flow(
59
+ _.propOr([], "errors"),
60
+ _.map((error) => ({
61
+ id: `structures.bulkUpdateStructures.failed.note.${error.message}`,
62
+ fields: {
63
+ row: error.row,
64
+ id: error.id,
65
+ field: error.field,
66
+ externalId: error.external_id,
67
+ message: error.message,
68
+ },
69
+ defaultMessage: "structures.bulkUploadStructures.success.errors",
70
+ })),
71
+ )(data)
72
+ : [],
73
+ fields: data?.errors ? { count_errors: data.errors.length } : {},
74
+ initialMaxListItems: 5,
75
+ });
76
+
51
77
  export const getUploadAlertMessage = ({ status, data }) => {
52
78
  if (status === 202) {
53
79
  return uploadAccepted();
@@ -55,4 +81,8 @@ export const getUploadAlertMessage = ({ status, data }) => {
55
81
  if (status === 200) {
56
82
  return uploadWithErrors(data);
57
83
  }
84
+ if (status >= 400) {
85
+ return uploadFailed(data || {});
86
+ }
87
+ return uploadAccepted();
58
88
  };
@@ -0,0 +1,196 @@
1
+ import {
2
+ hasMandatoryErrors,
3
+ canPerformAction,
4
+ getMissingMandatoryFields,
5
+ } from "../structureNoteActions";
6
+
7
+ describe("hasMandatoryErrors", () => {
8
+ const template = {
9
+ content: [
10
+ {
11
+ name: "group",
12
+ fields: [
13
+ {
14
+ name: "required_field",
15
+ type: "string",
16
+ cardinality: "1",
17
+ },
18
+ {
19
+ name: "optional_field",
20
+ type: "string",
21
+ cardinality: "?",
22
+ },
23
+ ],
24
+ },
25
+ ],
26
+ };
27
+
28
+ it("returns false when content is null/undefined", () => {
29
+ expect(hasMandatoryErrors(null, template)).toBe(false);
30
+ expect(hasMandatoryErrors(undefined, template)).toBe(false);
31
+ });
32
+
33
+ it("returns false when template is null/undefined", () => {
34
+ expect(hasMandatoryErrors({}, null)).toBe(false);
35
+ expect(hasMandatoryErrors({}, undefined)).toBe(false);
36
+ });
37
+
38
+ it("returns true when a mandatory field is missing", () => {
39
+ const content = { optional_field: { value: "x", origin: "user" } };
40
+ expect(hasMandatoryErrors(content, template)).toBe(true);
41
+ });
42
+
43
+ it("returns false when all mandatory fields are populated", () => {
44
+ const content = {
45
+ required_field: { value: "ok", origin: "user" },
46
+ optional_field: { value: "x", origin: "user" },
47
+ };
48
+ expect(hasMandatoryErrors(content, template)).toBe(false);
49
+ });
50
+
51
+ it("returns true when a mandatory field is present but empty", () => {
52
+ const content = {
53
+ required_field: { value: "", origin: "user" },
54
+ };
55
+ expect(hasMandatoryErrors(content, template)).toBe(true);
56
+ });
57
+
58
+ it("returns true when a mandatory field is null", () => {
59
+ const content = {
60
+ required_field: { value: null, origin: "user" },
61
+ };
62
+ expect(hasMandatoryErrors(content, template)).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe("getMissingMandatoryFields", () => {
67
+ const template = {
68
+ content: [
69
+ {
70
+ name: "group",
71
+ fields: [
72
+ {
73
+ name: "required_field",
74
+ type: "string",
75
+ cardinality: "1",
76
+ },
77
+ {
78
+ name: "another_required",
79
+ type: "string",
80
+ cardinality: "+",
81
+ },
82
+ {
83
+ name: "optional_field",
84
+ type: "string",
85
+ cardinality: "?",
86
+ },
87
+ ],
88
+ },
89
+ ],
90
+ };
91
+
92
+ it("returns empty array when content is null/undefined", () => {
93
+ expect(getMissingMandatoryFields(null, template)).toEqual([]);
94
+ expect(getMissingMandatoryFields(undefined, template)).toEqual([]);
95
+ });
96
+
97
+ it("returns empty array when template is null/undefined", () => {
98
+ expect(getMissingMandatoryFields({}, null)).toEqual([]);
99
+ expect(getMissingMandatoryFields({}, undefined)).toEqual([]);
100
+ });
101
+
102
+ it("returns list of missing field names", () => {
103
+ const content = { optional_field: { value: "x", origin: "user" } };
104
+ const missing = getMissingMandatoryFields(content, template);
105
+ expect(missing).toContain("required_field");
106
+ expect(missing).toContain("another_required");
107
+ expect(missing).toHaveLength(2);
108
+ });
109
+
110
+ it("returns empty array when all mandatory fields are populated", () => {
111
+ const content = {
112
+ required_field: { value: "ok", origin: "user" },
113
+ another_required: { value: ["a"], origin: "user" },
114
+ optional_field: { value: "x", origin: "user" },
115
+ };
116
+ expect(getMissingMandatoryFields(content, template)).toEqual([]);
117
+ });
118
+ });
119
+
120
+ describe("canPerformAction (TD-7093)", () => {
121
+ const template = {
122
+ content: [
123
+ {
124
+ name: "group",
125
+ fields: [
126
+ {
127
+ name: "required_field",
128
+ type: "string",
129
+ cardinality: "1",
130
+ },
131
+ ],
132
+ },
133
+ ],
134
+ };
135
+
136
+ const fullContent = { required_field: { value: "ok", origin: "user" } };
137
+ const partialContent = { optional_field: { value: "x", origin: "user" } };
138
+
139
+ describe("publishing actions", () => {
140
+ it("returns false for 'published' when mandatory fields are missing", () => {
141
+ expect(canPerformAction("published", partialContent, template)).toBe(
142
+ false,
143
+ );
144
+ });
145
+
146
+ it("returns true for 'published' when all mandatory fields are populated", () => {
147
+ expect(canPerformAction("published", fullContent, template)).toBe(true);
148
+ });
149
+
150
+ it("returns false for 'pending_approval' when mandatory fields are missing", () => {
151
+ expect(
152
+ canPerformAction("pending_approval", partialContent, template),
153
+ ).toBe(false);
154
+ });
155
+
156
+ it("returns true for 'pending_approval' when all mandatory fields are populated", () => {
157
+ expect(canPerformAction("pending_approval", fullContent, template)).toBe(
158
+ true,
159
+ );
160
+ });
161
+ });
162
+
163
+ describe("draft actions", () => {
164
+ it("always allows 'edited' even with partial content", () => {
165
+ expect(canPerformAction("edited", partialContent, template)).toBe(true);
166
+ });
167
+
168
+ it("always allows 'draft' even with partial content", () => {
169
+ expect(canPerformAction("draft", partialContent, template)).toBe(true);
170
+ });
171
+
172
+ it("always allows 'deleted' even with partial content", () => {
173
+ expect(canPerformAction("deleted", partialContent, template)).toBe(true);
174
+ });
175
+
176
+ it("always allows 'rejected' even with partial content", () => {
177
+ expect(canPerformAction("rejected", partialContent, template)).toBe(true);
178
+ });
179
+
180
+ it("always allows 'deprecated' even with partial content", () => {
181
+ expect(canPerformAction("deprecated", partialContent, template)).toBe(
182
+ true,
183
+ );
184
+ });
185
+ });
186
+
187
+ describe("edge cases", () => {
188
+ it("returns true for publishing actions when template is missing (no validation possible)", () => {
189
+ expect(canPerformAction("published", partialContent, null)).toBe(true);
190
+ });
191
+
192
+ it("returns true for publishing actions when content is missing (no validation possible)", () => {
193
+ expect(canPerformAction("published", null, template)).toBe(true);
194
+ });
195
+ });
196
+ });
@@ -0,0 +1,22 @@
1
+ import { validateContent } from "@truedat/df/utils";
2
+
3
+ export const getMissingMandatoryFields = (content, template) => {
4
+ if (!template || !content) return [];
5
+ const errors = validateContent(template)(content);
6
+ return Array.isArray(errors)
7
+ ? errors
8
+ .filter((err) => err && err.error === "missing required" && err.field)
9
+ .map((err) => err.field)
10
+ : [];
11
+ };
12
+
13
+ export const hasMandatoryErrors = (content, template) => {
14
+ return getMissingMandatoryFields(content, template).length > 0;
15
+ };
16
+
17
+ export const canPerformAction = (action, content, template) => {
18
+ if (action === "pending_approval" || action === "published") {
19
+ return !hasMandatoryErrors(content, template);
20
+ }
21
+ return true;
22
+ };