@truedat/dd 8.13.5 → 8.13.7

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 (38) hide show
  1. package/package.json +3 -3
  2. package/src/components/GraphGenerationDialog.js +125 -0
  3. package/src/components/GraphGenerationNotices.js +67 -0
  4. package/src/components/GraphLoader.js +12 -17
  5. package/src/components/GraphRedirector.js +4 -4
  6. package/src/components/LineageGraph.js +38 -49
  7. package/src/components/LineageRoutes.js +20 -13
  8. package/src/components/StructureNotesEdit.js +8 -2
  9. package/src/components/SystemForm.js +10 -1
  10. package/src/components/__tests__/GraphGenerationDialog.spec.js +117 -0
  11. package/src/components/__tests__/GraphGenerationNotices.spec.js +60 -0
  12. package/src/components/__tests__/GraphGenerationTranslations.spec.js +74 -0
  13. package/src/components/__tests__/GraphLoader.spec.js +83 -0
  14. package/src/components/__tests__/LineageGraphInteraction.spec.js +78 -7
  15. package/src/components/__tests__/StructureNotesEdit.spec.js +105 -1
  16. package/src/components/__tests__/SystemForm.dateRestrictions.spec.js +102 -0
  17. package/src/components/__tests__/__snapshots__/LineageRoutes.spec.js.snap +12 -1
  18. package/src/components/__tests__/fixtures/graphGenerationMessages.json +28 -0
  19. package/src/messages/en.js +0 -29
  20. package/src/messages/es.js +0 -30
  21. package/src/reducers/__tests__/graphGenerationState.spec.js +113 -0
  22. package/src/reducers/__tests__/structureNoteErrorMessage.spec.js +78 -0
  23. package/src/reducers/ddMessage.js +16 -1
  24. package/src/reducers/graphGenerationState.js +113 -0
  25. package/src/reducers/graphLoading.js +2 -0
  26. package/src/reducers/index.js +2 -0
  27. package/src/routines.js +3 -0
  28. package/src/sagas/__tests__/createGraph.spec.js +57 -122
  29. package/src/sagas/__tests__/fetchGraph.spec.js +19 -0
  30. package/src/sagas/createGraph.js +13 -65
  31. package/src/sagas/fetchGraph.js +6 -38
  32. package/src/sagas/graph/__tests__/backgroundGraphs.spec.js +22 -0
  33. package/src/sagas/graph/__tests__/generation.spec.js +247 -0
  34. package/src/sagas/graph/__tests__/polling.spec.js +88 -0
  35. package/src/sagas/graph/generation.js +99 -0
  36. package/src/sagas/graph/polling.js +26 -35
  37. package/src/sagas/index.js +2 -0
  38. package/src/styles/LineageGraph.less +117 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/dd",
3
- "version": "8.13.5",
3
+ "version": "8.13.7",
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.13.5",
57
+ "@truedat/test": "8.13.7",
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": "9b87cbb7143f20b305b84077c1fcf004e5b27726"
93
+ "gitHead": "704dcfcba34b6094a368a93059de7f4937502f32"
94
94
  }
@@ -0,0 +1,125 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import PropTypes from "prop-types";
3
+ import { FormattedMessage, useIntl } from "react-intl";
4
+ import { Button, Modal } from "semantic-ui-react";
5
+
6
+ export const GraphGenerationDialog = ({
7
+ open,
8
+ requestKey,
9
+ onBackground,
10
+ onClose = onBackground,
11
+ }) => {
12
+ const { formatMessage } = useIntl();
13
+ const [delayed, setDelayed] = useState(false);
14
+ const [waiting, setWaiting] = useState(false);
15
+ const previousFocus = useRef(null);
16
+ const attachDialog = useCallback((node) => {
17
+ if (node) {
18
+ previousFocus.current = document.activeElement;
19
+ node.querySelector("button")?.focus();
20
+ } else if (previousFocus.current?.isConnected) {
21
+ previousFocus.current.focus();
22
+ }
23
+ }, []);
24
+ const trapFocus = (event) => {
25
+ if (event.key !== "Tab") return;
26
+ const buttons = [
27
+ ...event.currentTarget.querySelectorAll("button:not(:disabled)"),
28
+ ];
29
+ const first = buttons[0];
30
+ const last = buttons[buttons.length - 1];
31
+ if (event.shiftKey && document.activeElement === first) {
32
+ event.preventDefault();
33
+ last?.focus();
34
+ } else if (!event.shiftKey && document.activeElement === last) {
35
+ event.preventDefault();
36
+ first?.focus();
37
+ }
38
+ };
39
+
40
+ useEffect(() => {
41
+ setDelayed(false);
42
+ setWaiting(false);
43
+ if (!open) return undefined;
44
+ // One timer per request; polling updates do not restart the explanation.
45
+ const timeout = setTimeout(() => setDelayed(true), 3000);
46
+ return () => clearTimeout(timeout);
47
+ }, [open, requestKey]);
48
+
49
+ return (
50
+ <Modal
51
+ open={open}
52
+ basic
53
+ size="tiny"
54
+ className={`lineage-generation-dialog${delayed ? " lineage-generation-dialog--expanded" : ""}`}
55
+ dimmer={{ className: "lineage-generation-dimmer" }}
56
+ onClose={onClose}
57
+ closeOnDimmerClick={false}
58
+ aria-labelledby="lineage-generation-title"
59
+ aria-describedby={delayed ? "lineage-generation-description" : undefined}
60
+ >
61
+ <div
62
+ className="lineage-graph__loading-card"
63
+ ref={attachDialog}
64
+ onKeyDown={trapFocus}
65
+ >
66
+ <button
67
+ type="button"
68
+ className="lineage-generation-dialog__close"
69
+ onClick={onClose}
70
+ aria-label={formatMessage({ id: "lineage.graph.wait.close" })}
71
+ >
72
+ ×
73
+ </button>
74
+ <span className="lineage-graph__loading-spinner" aria-hidden="true" />
75
+ <p
76
+ id="lineage-generation-title"
77
+ className="lineage-graph__loading-title"
78
+ role="status"
79
+ >
80
+ <FormattedMessage
81
+ id="lineage.graph.loading"
82
+ defaultMessage="Analyzing..."
83
+ />
84
+ </p>
85
+ {delayed ? (
86
+ <>
87
+ <p
88
+ id="lineage-generation-description"
89
+ className="lineage-graph__loading-note"
90
+ >
91
+ {formatMessage({ id: "lineage.graph.wait.explanation" })}
92
+ </p>
93
+ <div className="lineage-generation-dialog__actions">
94
+ <Button
95
+ type="button"
96
+ primary
97
+ disabled={waiting}
98
+ onClick={(event) => {
99
+ setWaiting(true);
100
+ event.currentTarget.nextElementSibling?.focus();
101
+ }}
102
+ >
103
+ {formatMessage({
104
+ id: waiting
105
+ ? "lineage.graph.wait.waiting"
106
+ : "lineage.graph.wait.continue",
107
+ })}
108
+ </Button>
109
+ <Button type="button" onClick={onBackground}>
110
+ {formatMessage({ id: "lineage.graph.wait.background" })}
111
+ </Button>
112
+ </div>
113
+ </>
114
+ ) : null}
115
+ </div>
116
+ </Modal>
117
+ );
118
+ };
119
+ GraphGenerationDialog.propTypes = {
120
+ open: PropTypes.bool.isRequired,
121
+ requestKey: PropTypes.string,
122
+ onBackground: PropTypes.func.isRequired,
123
+ onClose: PropTypes.func,
124
+ };
125
+ export default GraphGenerationDialog;
@@ -0,0 +1,67 @@
1
+ import { useIntl } from "react-intl";
2
+ import PropTypes from "prop-types";
3
+ import { connect } from "react-redux";
4
+ import { Link } from "react-router";
5
+ import { linkTo } from "@truedat/core/routes";
6
+ import { dismissGraphGeneration } from "../routines";
7
+
8
+ export const GraphGenerationNotices = ({
9
+ jobs = {},
10
+ dismissGraphGeneration,
11
+ }) => {
12
+ const { formatMessage } = useIntl();
13
+ return (
14
+ <div className="lineage-generation-notices" aria-live="polite">
15
+ {Object.values(jobs)
16
+ .filter(
17
+ (job) => job.notify || (!job.background && job.status === "failed"),
18
+ )
19
+ .map((job) => {
20
+ const pending = ["pending", "awaitingEvent"].includes(job.status);
21
+ const message = pending
22
+ ? "background.notice"
23
+ : job.status === "failed"
24
+ ? "failed"
25
+ : "completed";
26
+ return (
27
+ <div
28
+ className={`lineage-generation-notices__item${job.status === "completed" ? " lineage-generation-notices__item--ready" : ""}`}
29
+ key={job.requestId}
30
+ role={job.status === "failed" ? "alert" : "status"}
31
+ >
32
+ <span>
33
+ {formatMessage({ id: `lineage.graph.wait.${message}` })}
34
+ </span>
35
+ {job.status === "completed" && job.graphId != null ? (
36
+ <Link
37
+ to={linkTo.GRAPH({ id: job.graphId })}
38
+ onClick={() => dismissGraphGeneration(job.requestId)}
39
+ >
40
+ {formatMessage({ id: "lineage.graph.wait.open" })}
41
+ </Link>
42
+ ) : null}
43
+ {!pending ? (
44
+ <button
45
+ type="button"
46
+ onClick={() => dismissGraphGeneration(job.requestId)}
47
+ aria-label={formatMessage({
48
+ id: "lineage.graph.wait.dismiss",
49
+ })}
50
+ >
51
+ ×
52
+ </button>
53
+ ) : null}
54
+ </div>
55
+ );
56
+ })}
57
+ </div>
58
+ );
59
+ };
60
+ GraphGenerationNotices.propTypes = {
61
+ jobs: PropTypes.object,
62
+ dismissGraphGeneration: PropTypes.func.isRequired,
63
+ };
64
+ export default connect(
65
+ ({ graphGenerationState }) => ({ jobs: graphGenerationState?.jobs }),
66
+ { dismissGraphGeneration },
67
+ )(GraphGenerationNotices);
@@ -1,21 +1,23 @@
1
1
  import _ from "lodash/fp";
2
- import { useEffect } from "react";
2
+ import { useEffect, useRef } from "react";
3
3
  import PropTypes from "prop-types";
4
4
  import { connect } from "react-redux";
5
- import { useParams, useNavigate } from "react-router";
5
+ import { useParams, useNavigate, useLocation } from "react-router";
6
6
  import { GRAPH } from "@truedat/core/routes";
7
- import { fetchGraph, clearGraph, cancelPoll } from "../routines";
7
+ import { fetchGraph, clearGraph } from "../routines";
8
8
 
9
9
  export const GraphLoader = ({
10
10
  clearGraph,
11
11
  fetchGraph,
12
12
  graph,
13
- cancelPoll,
14
13
  graphRedirect,
15
14
  }) => {
16
15
  const { id } = useParams();
17
16
 
18
17
  const navigate = useNavigate();
18
+ const { key: navigationKey } = useLocation();
19
+ const routeKey = `${navigationKey}:${id}`;
20
+ const requestedId = useRef(null);
19
21
 
20
22
  useEffect(() => {
21
23
  return () => {
@@ -24,27 +26,21 @@ export const GraphLoader = ({
24
26
  }, [clearGraph]);
25
27
 
26
28
  useEffect(() => {
27
- if (id) {
29
+ // Keep one request per route, including failed or still pending responses.
30
+ if (id && requestedId.current !== routeKey) {
28
31
  if (_.isEmpty(graph)) {
32
+ requestedId.current = routeKey;
29
33
  fetchGraph({ id });
30
34
  /* If graphRedirect is present, a new graph has already been loaded, so
31
35
  * there is no need to fetchGraph. The URL is being changed by
32
36
  * GraphLoader history.push to the new graph id.
33
37
  */
34
38
  } else if (!graphRedirect && id != graph.id) {
39
+ requestedId.current = routeKey;
35
40
  fetchGraph({ id });
36
41
  }
37
42
  }
38
- }, [graph, graphRedirect, fetchGraph, id]);
39
-
40
- useEffect(() => {
41
- return () => {
42
- // && history.location.pathname === "any specific path")
43
- if (history.action === "POP") {
44
- cancelPoll();
45
- }
46
- };
47
- }, [history]);
43
+ }, [graph, graphRedirect, fetchGraph, id, routeKey]);
48
44
 
49
45
  return null;
50
46
  };
@@ -53,7 +49,6 @@ GraphLoader.propTypes = {
53
49
  clearGraph: PropTypes.func.isRequired,
54
50
  fetchGraph: PropTypes.func.isRequired,
55
51
  graph: PropTypes.object,
56
- cancelPoll: PropTypes.func,
57
52
  graphRedirect: PropTypes.string,
58
53
  };
59
54
 
@@ -62,6 +57,6 @@ const mapStateToProps = ({ graph, graphRedirect }) => ({
62
57
  graphRedirect,
63
58
  });
64
59
 
65
- export default connect(mapStateToProps, { clearGraph, fetchGraph, cancelPoll })(
60
+ export default connect(mapStateToProps, { clearGraph, fetchGraph })(
66
61
  GraphLoader,
67
62
  );
@@ -10,11 +10,11 @@ export const GraphRedirector = ({ graph, graphRedirect, clearRedirect }) => {
10
10
  const { pathname } = useLocation();
11
11
  const navigate = useNavigate();
12
12
  useEffect(() => {
13
- if (graphRedirect && !_.isEqual(pathname, graphRedirect)) {
14
- navigate(graphRedirect);
15
- clearRedirect();
13
+ if (graphRedirect) {
14
+ if (!_.isEqual(pathname, graphRedirect)) navigate(graphRedirect);
15
+ else clearRedirect();
16
16
  }
17
- }, [history, pathname, graphRedirect, clearRedirect]);
17
+ }, [navigate, pathname, graphRedirect, clearRedirect]);
18
18
  return null;
19
19
  };
20
20
 
@@ -5,7 +5,7 @@ import { connect } from "react-redux";
5
5
  import { FormattedMessage, useIntl } from "react-intl";
6
6
  import Graph from "@truedat/core/components/Graph";
7
7
  import { EdgeHoverContext } from "@truedat/core/components/graph/ColoredEdge";
8
- import { cancelPoll } from "../routines";
8
+ import { backgroundGraph } from "../routines";
9
9
  import { layoutVisibleLineageGraph } from "../utils/lineageLayout";
10
10
  import {
11
11
  createLineageGraphIndex,
@@ -62,6 +62,7 @@ import {
62
62
  spreadLegacyFieldIndexes,
63
63
  legacyPathEdges,
64
64
  } from "../utils/lineageGraph";
65
+ import GraphGenerationDialog from "./GraphGenerationDialog";
65
66
  import LineageGraphNode from "./LineageGraphNode";
66
67
  import LineageModuleButton from "./LineageModuleButton";
67
68
  import LineageGraphToolbar, { visibleLegendKeys } from "./LineageGraphToolbar";
@@ -126,8 +127,8 @@ export const LineageGraph = ({
126
127
  resources: legacyResources = [],
127
128
  topology,
128
129
  loading,
129
- loadingInfo,
130
- cancelPoll,
130
+ generationJob,
131
+ backgroundGraph,
131
132
  graphType = "impact",
132
133
  nodeDesign = "redesigned",
133
134
  onGraphReady,
@@ -188,7 +189,10 @@ export const LineageGraph = ({
188
189
  const defaultCollapseSignatureRef = useRef("");
189
190
  const layoutAnchorNodeIdRef = useRef(null);
190
191
  const previousLayoutRef = useRef([]);
191
- const { formatMessage } = useIntl();
192
+ const [waitDismissed, setWaitDismissed] = useState(false);
193
+ useEffect(() => {
194
+ setWaitDismissed(false);
195
+ }, [generationJob?.requestId, loading]);
192
196
  const positionedLayout = useMemo(
193
197
  () =>
194
198
  Boolean(preparedTopology) ||
@@ -223,8 +227,6 @@ export const LineageGraph = ({
223
227
  [groups, resources],
224
228
  );
225
229
 
226
- useEffect(() => () => cancelPoll(), [cancelPoll]);
227
-
228
230
  useEffect(() => {
229
231
  const timeoutId = setTimeout(() => setSearchTerm(searchInput), 300);
230
232
 
@@ -1438,51 +1440,33 @@ export const LineageGraph = ({
1438
1440
  return (
1439
1441
  <div className="lineage-graph">
1440
1442
  <div className="lineage-graph__canvas">
1441
- {loading || (hasGraph && fittedGraphKey !== graphIdForFit) ? (
1442
- <div
1443
- className="lineage-graph__loading"
1444
- role="status"
1445
- aria-live="polite"
1446
- >
1443
+ {!simplified && generationJob ? (
1444
+ <GraphGenerationDialog
1445
+ open={
1446
+ !generationJob?.background &&
1447
+ !waitDismissed &&
1448
+ (loading || (hasGraph && fittedGraphKey !== graphIdForFit))
1449
+ }
1450
+ requestKey={generationJob?.requestId}
1451
+ onClose={() => {
1452
+ setWaitDismissed(true);
1453
+ backgroundGraph?.({ notify: false });
1454
+ }}
1455
+ onBackground={() => {
1456
+ setWaitDismissed(true);
1457
+ backgroundGraph?.();
1458
+ }}
1459
+ />
1460
+ ) : loading ? (
1461
+ <div className="lineage-graph__loading" role="status">
1447
1462
  <div className="lineage-graph__loading-card">
1448
1463
  <span
1449
1464
  className="lineage-graph__loading-spinner"
1450
1465
  aria-hidden="true"
1451
1466
  />
1452
1467
  <p className="lineage-graph__loading-title">
1453
- <FormattedMessage
1454
- id="lineage.graph.loading"
1455
- defaultMessage="Analyzing..."
1456
- />
1468
+ <FormattedMessage id="lineage.graph.loading" />
1457
1469
  </p>
1458
- {loadingInfo?.data?.graph_hash && loadingInfo?.data?.status ? (
1459
- <p className="lineage-graph__loading-note">
1460
- {formatMessage({
1461
- id:
1462
- _.toLower(loadingInfo?.data?.status) === "already_started"
1463
- ? "lineage.graph.loading.background.existing"
1464
- : _.toLower(loadingInfo?.data?.status) ===
1465
- "just_started" &&
1466
- "lineage.graph.loading.background.launched",
1467
- })}
1468
- </p>
1469
- ) : null}
1470
- {loadingInfo?.data?.graph_hash ? (
1471
- <p className="lineage-graph__loading-note">
1472
- Graph hash:{" "}
1473
- <span className="graph-hash">
1474
- {loadingInfo?.data?.graph_hash}
1475
- </span>
1476
- </p>
1477
- ) : null}
1478
- {loadingInfo?.data?.retry ? (
1479
- <p className="lineage-graph__loading-note">
1480
- {formatMessage(
1481
- { id: "lineage.graph.loading.polling" },
1482
- { retry: loadingInfo?.data?.retry },
1483
- )}
1484
- </p>
1485
- ) : null}
1486
1470
  </div>
1487
1471
  </div>
1488
1472
  ) : null}
@@ -1569,8 +1553,8 @@ LineageGraph.propTypes = {
1569
1553
  topology: PropTypes.object,
1570
1554
  height: PropTypes.string,
1571
1555
  loading: PropTypes.bool,
1572
- loadingInfo: PropTypes.object,
1573
- cancelPoll: PropTypes.func,
1556
+ generationJob: PropTypes.object,
1557
+ backgroundGraph: PropTypes.func,
1574
1558
  graphType: PropTypes.string,
1575
1559
  nodeDesign: PropTypes.oneOf(["classic", "redesigned"]),
1576
1560
  onGraphReady: PropTypes.func,
@@ -1582,7 +1566,10 @@ LineageGraph.propTypes = {
1582
1566
  structureId: PropTypes.string,
1583
1567
  };
1584
1568
 
1585
- const mapStateToProps = ({ graph = {}, graphLoading }, ownProps) => {
1569
+ const mapStateToProps = (
1570
+ { graph = {}, graphLoading, graphGenerationState },
1571
+ ownProps,
1572
+ ) => {
1586
1573
  const graphData = _.propOr(graph, "graph")(ownProps);
1587
1574
  const paths = _.propOr([], "paths")(graphData);
1588
1575
  const links = _.propOr(paths, "links")(graphData);
@@ -1599,11 +1586,13 @@ const mapStateToProps = ({ graph = {}, graphLoading }, ownProps) => {
1599
1586
  groups: _.prop("groups")(graphData),
1600
1587
  resources: _.prop("resources")(graphData),
1601
1588
  loading: graphLoading !== false,
1602
- loadingInfo: graphLoading,
1589
+ generationJob: ownProps.simplified
1590
+ ? undefined
1591
+ : graphGenerationState?.jobs[graphGenerationState.active],
1603
1592
  };
1604
1593
  };
1605
1594
 
1606
- export default connect(mapStateToProps, { cancelPoll })(LineageGraph);
1595
+ export default connect(mapStateToProps, { backgroundGraph })(LineageGraph);
1607
1596
 
1608
1597
  export const LineageGraphEmptyState = () => {
1609
1598
  const { formatMessage } = useIntl();
@@ -4,23 +4,30 @@ import { ProtectedRoute } from "@truedat/core/router";
4
4
  import { GRAPHS } from "@truedat/core/routes";
5
5
  import GraphLoader from "./GraphLoader";
6
6
  import Graphs from "./Graphs";
7
+ import GraphGenerationNotices from "./GraphGenerationNotices";
7
8
 
8
9
  export const LineageRoutes = () => {
9
10
  return (
10
- <Routes>
11
- <Route path={GRAPHS} element={<ProtectedRoute authorization="lineage" />}>
11
+ <>
12
+ <GraphGenerationNotices />
13
+ <Routes>
12
14
  <Route
13
- path=":id?"
14
- element={
15
- <>
16
- <Graphs />
17
- <GraphLoader />
18
- </>
19
- }
20
- />
21
- </Route>
22
- <Route path="*" element={null} />
23
- </Routes>
15
+ path={GRAPHS}
16
+ element={<ProtectedRoute authorization="lineage" />}
17
+ >
18
+ <Route
19
+ path=":id?"
20
+ element={
21
+ <>
22
+ <Graphs />
23
+ <GraphLoader />
24
+ </>
25
+ }
26
+ />
27
+ </Route>
28
+ <Route path="*" element={null} />
29
+ </Routes>
30
+ </>
24
31
  );
25
32
  };
26
33
 
@@ -6,7 +6,7 @@ import { Button, Form, Segment } from "semantic-ui-react";
6
6
  import { useIntl } from "react-intl";
7
7
  import HistoryBackButton from "@truedat/core/components/HistoryBackButton";
8
8
  import { selectTemplate, selectDomains } from "@truedat/df/routines";
9
- import { applyTemplate } from "@truedat/df/utils";
9
+ import { applyTemplate, validateContent } 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";
@@ -38,6 +38,7 @@ export const StructureNotesEdit = ({
38
38
  latestDfContent,
39
39
  }) => {
40
40
  const [content, setContent] = useState(latestDfContent);
41
+ const [originalContent] = useState(latestDfContent);
41
42
  const explicitEmptiesRef = useRef({});
42
43
  const isModification = !_.isEmpty(latestDfContent);
43
44
  const initializedRef = useRef(false);
@@ -56,7 +57,11 @@ export const StructureNotesEdit = ({
56
57
 
57
58
  const { formatMessage, locale } = useIntl();
58
59
 
59
- const submitDisabled = !template;
60
+ const hasDateRestrictionErrors = validateContent(
61
+ template,
62
+ originalContent,
63
+ )(content).some(({ error }) => error === "date out of range");
64
+ const submitDisabled = !template || hasDateRestrictionErrors;
60
65
 
61
66
  const handleFormChange = (newContent) => {
62
67
  const clearedFields = _.flow(
@@ -128,6 +133,7 @@ export const StructureNotesEdit = ({
128
133
  <DynamicForm
129
134
  onChange={handleFormChange}
130
135
  content={content}
136
+ originalContent={originalContent}
131
137
  isModification={isModification}
132
138
  />
133
139
  <Button
@@ -31,9 +31,17 @@ export const SystemForm = ({
31
31
  }) => {
32
32
  const { formatMessage } = useIntl();
33
33
  const [system, setSystem] = useState(editSystem || {});
34
+ const [originalContent] = useState(() =>
35
+ _.isEmpty(editSystem)
36
+ ? undefined
37
+ : _.cloneDeep(editSystem.dynamic_content || {}),
38
+ );
34
39
 
35
40
  const isNonEmpty = _.flow(_.trim, _.negate(_.isEmpty));
36
- const isValidContent = _.flow(validateContent(template), _.isEmpty);
41
+ const isValidContent = _.flow(
42
+ validateContent(template, originalContent),
43
+ _.isEmpty,
44
+ );
37
45
  const isValid = _.conforms({
38
46
  name: isNonEmpty,
39
47
  external_id: isNonEmpty,
@@ -105,6 +113,7 @@ export const SystemForm = ({
105
113
  template={template}
106
114
  onChange={handleContentChange}
107
115
  content={dynamic_content || {}}
116
+ originalContent={originalContent}
108
117
  />
109
118
  )}
110
119
  <div className="actions">