@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
@@ -0,0 +1,117 @@
1
+ import { act, fireEvent, screen } from "@testing-library/react";
2
+ import { render } from "@truedat/test/render";
3
+ import { GraphGenerationDialog } from "../GraphGenerationDialog";
4
+
5
+ beforeEach(() => jest.useFakeTimers());
6
+ afterEach(() => {
7
+ jest.runOnlyPendingTimers();
8
+ jest.useRealTimers();
9
+ });
10
+
11
+ test("reveals choices after three seconds and keeps waiting without closing", () => {
12
+ const onBackground = jest.fn();
13
+ render(
14
+ <GraphGenerationDialog open requestKey="a" onBackground={onBackground} />,
15
+ );
16
+ act(() => jest.advanceTimersByTime(2999));
17
+ expect(
18
+ screen.queryByRole("button", { name: "lineage.graph.wait.continue" }),
19
+ ).not.toBeInTheDocument();
20
+ act(() => jest.advanceTimersByTime(1));
21
+ fireEvent.click(
22
+ screen.getByRole("button", { name: "lineage.graph.wait.continue" }),
23
+ );
24
+ expect(
25
+ screen.getByRole("button", { name: "lineage.graph.wait.waiting" }),
26
+ ).toBeDisabled();
27
+ expect(onBackground).not.toHaveBeenCalled();
28
+ fireEvent.click(
29
+ screen.getByRole("button", { name: "lineage.graph.wait.background" }),
30
+ );
31
+ expect(onBackground).toHaveBeenCalledTimes(1);
32
+ });
33
+
34
+ test("manual close is available before the delay without enabling notifications", () => {
35
+ const onClose = jest.fn();
36
+ const onBackground = jest.fn();
37
+ render(
38
+ <GraphGenerationDialog
39
+ open
40
+ requestKey="a"
41
+ onClose={onClose}
42
+ onBackground={onBackground}
43
+ />,
44
+ );
45
+ fireEvent.click(
46
+ screen.getByRole("button", { name: "lineage.graph.wait.close" }),
47
+ );
48
+ expect(onClose).toHaveBeenCalledTimes(1);
49
+ expect(onBackground).not.toHaveBeenCalled();
50
+ });
51
+
52
+ test("escape uses manual close and unmount cancels its timer", () => {
53
+ const onClose = jest.fn();
54
+ const onBackground = jest.fn();
55
+ const { unmount } = render(
56
+ <GraphGenerationDialog
57
+ open
58
+ requestKey="a"
59
+ onClose={onClose}
60
+ onBackground={onBackground}
61
+ />,
62
+ );
63
+ fireEvent.keyDown(document, { key: "Escape", keyCode: 27 });
64
+ expect(onClose).toHaveBeenCalled();
65
+ expect(onBackground).not.toHaveBeenCalled();
66
+ unmount();
67
+ act(() => jest.advanceTimersByTime(3000));
68
+ });
69
+
70
+ test("progress rerenders do not restart the timer, and a new request resets the choice", () => {
71
+ const onBackground = jest.fn();
72
+ const { rerender } = render(
73
+ <GraphGenerationDialog open requestKey="a" onBackground={onBackground} />,
74
+ );
75
+ act(() => jest.advanceTimersByTime(2000));
76
+ rerender(
77
+ <GraphGenerationDialog open requestKey="a" onBackground={onBackground} />,
78
+ );
79
+ act(() => jest.advanceTimersByTime(1000));
80
+ fireEvent.click(
81
+ screen.getByRole("button", { name: "lineage.graph.wait.continue" }),
82
+ );
83
+ rerender(
84
+ <GraphGenerationDialog open requestKey="b" onBackground={onBackground} />,
85
+ );
86
+ expect(
87
+ screen.queryByRole("button", { name: "lineage.graph.wait.waiting" }),
88
+ ).not.toBeInTheDocument();
89
+ act(() => jest.advanceTimersByTime(3000));
90
+ expect(
91
+ screen.getByRole("button", { name: "lineage.graph.wait.continue" }),
92
+ ).toBeEnabled();
93
+ });
94
+
95
+ test("traps keyboard focus inside the dialog and restores it after closing", () => {
96
+ const trigger = document.createElement("button");
97
+ document.body.appendChild(trigger);
98
+ trigger.focus();
99
+ const { unmount } = render(
100
+ <GraphGenerationDialog open requestKey="a" onBackground={jest.fn()} />,
101
+ );
102
+ const close = screen.getByRole("button", {
103
+ name: "lineage.graph.wait.close",
104
+ });
105
+ expect(close).toHaveFocus();
106
+ act(() => jest.advanceTimersByTime(3000));
107
+ fireEvent.keyDown(close, { key: "Tab", shiftKey: true });
108
+ const background = screen.getByRole("button", {
109
+ name: "lineage.graph.wait.background",
110
+ });
111
+ expect(background).toHaveFocus();
112
+ fireEvent.keyDown(background, { key: "Tab" });
113
+ expect(close).toHaveFocus();
114
+ unmount();
115
+ expect(trigger).toHaveFocus();
116
+ trigger.remove();
117
+ });
@@ -0,0 +1,60 @@
1
+ import { fireEvent, screen } from "@testing-library/react";
2
+ import { render } from "@truedat/test/render";
3
+ import { GraphGenerationNotices } from "../GraphGenerationNotices";
4
+
5
+ test("completion gives an explicit link without automatic navigation", () => {
6
+ const dismiss = jest.fn();
7
+ render(
8
+ <GraphGenerationNotices
9
+ jobs={{
10
+ a: {
11
+ requestId: "a",
12
+ background: true,
13
+ notify: true,
14
+ status: "completed",
15
+ graphId: 42,
16
+ },
17
+ }}
18
+ dismissGraphGeneration={dismiss}
19
+ />,
20
+ );
21
+ expect(
22
+ screen.getByRole("link", { name: "lineage.graph.wait.open" }),
23
+ ).toHaveAttribute("href", "/graphs/42");
24
+ expect(dismiss).not.toHaveBeenCalled();
25
+ fireEvent.click(
26
+ screen.getByRole("button", { name: "lineage.graph.wait.dismiss" }),
27
+ );
28
+ expect(dismiss).toHaveBeenCalledWith("a");
29
+ });
30
+
31
+ test("a failure is announced and has no success link", () => {
32
+ render(
33
+ <GraphGenerationNotices
34
+ jobs={{ a: { requestId: "a", status: "failed" } }}
35
+ dismissGraphGeneration={jest.fn()}
36
+ />,
37
+ );
38
+ expect(screen.getByRole("alert")).toHaveTextContent(
39
+ "lineage.graph.wait.failed",
40
+ );
41
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
42
+ });
43
+
44
+ test("background work without explicit notification consent stays silent", () => {
45
+ render(
46
+ <GraphGenerationNotices
47
+ jobs={{
48
+ a: {
49
+ requestId: "a",
50
+ background: true,
51
+ status: "completed",
52
+ graphId: 42,
53
+ },
54
+ }}
55
+ dismissGraphGeneration={jest.fn()}
56
+ />,
57
+ );
58
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
59
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
60
+ });
@@ -0,0 +1,74 @@
1
+ import { act, fireEvent, render, screen } from "@testing-library/react";
2
+ import { IntlProvider } from "react-intl";
3
+ import { MemoryRouter } from "react-router";
4
+ import { GraphGenerationDialog } from "../GraphGenerationDialog";
5
+ import { GraphGenerationNotices } from "../GraphGenerationNotices";
6
+ import en from "../../messages/en";
7
+ import es from "../../messages/es";
8
+ import migratedMessages from "./fixtures/graphGenerationMessages.json";
9
+
10
+ beforeEach(() => jest.useFakeTimers());
11
+ afterEach(() => jest.useRealTimers());
12
+
13
+ test.each([
14
+ ["en", { ...en, ...migratedMessages.en }],
15
+ ["es", { ...es, ...migratedMessages.es }],
16
+ ])(
17
+ "renders translated generation controls and notices in %s",
18
+ (locale, messages) => {
19
+ const onError = jest.fn();
20
+ const view = render(
21
+ <IntlProvider locale={locale} messages={messages} onError={onError}>
22
+ <MemoryRouter>
23
+ <GraphGenerationDialog open requestKey="a" onBackground={jest.fn()} />
24
+ <GraphGenerationNotices
25
+ jobs={{
26
+ pending: {
27
+ requestId: "pending",
28
+ notify: true,
29
+ status: "pending",
30
+ },
31
+ completed: {
32
+ requestId: "completed",
33
+ notify: true,
34
+ status: "completed",
35
+ graphId: 42,
36
+ },
37
+ failed: { requestId: "failed", notify: true, status: "failed" },
38
+ }}
39
+ dismissGraphGeneration={jest.fn()}
40
+ />
41
+ </MemoryRouter>
42
+ </IntlProvider>,
43
+ );
44
+ act(() => jest.advanceTimersByTime(3000));
45
+ const text = (suffix) => messages[`lineage.graph.wait.${suffix}`];
46
+ ["explanation", "background.notice", "completed", "failed"].forEach(
47
+ (suffix) => {
48
+ expect(text(suffix)).toEqual(expect.any(String));
49
+ expect(screen.getByText(text(suffix))).toBeInTheDocument();
50
+ },
51
+ );
52
+ ["close", "continue", "background"].forEach((suffix) => {
53
+ expect(text(suffix)).toEqual(expect.any(String));
54
+ expect(
55
+ screen.getByRole("button", { name: text(suffix) }),
56
+ ).toBeInTheDocument();
57
+ });
58
+ expect(text("open")).toEqual(expect.any(String));
59
+ expect(text("dismiss")).toEqual(expect.any(String));
60
+ expect(
61
+ screen.getByRole("link", { name: text("open"), hidden: true }),
62
+ ).toHaveAttribute("href", "/graphs/42");
63
+ expect(
64
+ screen.getAllByRole("button", { name: text("dismiss"), hidden: true }),
65
+ ).toHaveLength(2);
66
+ fireEvent.click(screen.getByRole("button", { name: text("continue") }));
67
+ expect(text("waiting")).toEqual(expect.any(String));
68
+ expect(
69
+ screen.getByRole("button", { name: text("waiting") }),
70
+ ).toBeDisabled();
71
+ expect(onError).not.toHaveBeenCalled();
72
+ view.unmount();
73
+ },
74
+ );
@@ -0,0 +1,83 @@
1
+ import { fireEvent, screen } from "@testing-library/react";
2
+ import { useDispatch } from "react-redux";
3
+ import { Route, Routes, useLocation } from "react-router";
4
+ import { render } from "@truedat/test/render";
5
+ import ConnectedGraphLoader, { GraphLoader } from "../GraphLoader";
6
+ import GraphRedirector from "../GraphRedirector";
7
+ import { graph as graphReducer } from "../../reducers/graph";
8
+ import { graphRedirect as redirectReducer } from "../../reducers/graphRedirect";
9
+ import { createGraph, fetchGraph } from "../../routines";
10
+
11
+ const GenerateGraph = () => {
12
+ const dispatch = useDispatch();
13
+ const { pathname } = useLocation();
14
+ return (
15
+ <>
16
+ <button
17
+ onClick={() =>
18
+ dispatch(
19
+ createGraph.success({ data: { id: 43, isRedirected: true } }),
20
+ )
21
+ }
22
+ >
23
+ Complete generation
24
+ </button>
25
+ <output>{pathname}</output>
26
+ </>
27
+ );
28
+ };
29
+
30
+ test("redirects to the generated graph without refetching the previous graph", () => {
31
+ const requests = jest.fn();
32
+ render(
33
+ <Routes>
34
+ <Route
35
+ path="/graphs/:id"
36
+ element={
37
+ <>
38
+ <GenerateGraph />
39
+ <GraphRedirector />
40
+ <ConnectedGraphLoader />
41
+ </>
42
+ }
43
+ />
44
+ </Routes>,
45
+ {
46
+ routes: ["/graphs/42"],
47
+ state: { graph: { id: 42 }, graphRedirect: "" },
48
+ reducer: (state, action) => {
49
+ if (action.type === fetchGraph.TRIGGER) requests(action.payload);
50
+ return {
51
+ graph: graphReducer(state.graph, action),
52
+ graphRedirect: redirectReducer(state.graphRedirect, action),
53
+ };
54
+ },
55
+ },
56
+ );
57
+ fireEvent.click(screen.getByRole("button", { name: "Complete generation" }));
58
+ expect(screen.getByText("/graphs/43")).toBeInTheDocument();
59
+ expect(requests).not.toHaveBeenCalled();
60
+ });
61
+
62
+ test("does not fetch repeatedly when a response is missing its graph id", () => {
63
+ const fetchGraph = jest.fn();
64
+ const clearGraph = jest.fn();
65
+ const view = (graph) => (
66
+ <Routes>
67
+ <Route
68
+ path="/graphs/:id"
69
+ element={
70
+ <GraphLoader
71
+ graph={graph}
72
+ fetchGraph={fetchGraph}
73
+ clearGraph={clearGraph}
74
+ />
75
+ }
76
+ />
77
+ </Routes>
78
+ );
79
+ const { rerender } = render(view({}), { routes: ["/graphs/42"] });
80
+ rerender(view({ data: { id: 42 } }));
81
+ rerender(view({ data: { id: 42 } }));
82
+ expect(fetchGraph).toHaveBeenCalledTimes(1);
83
+ });
@@ -1,10 +1,11 @@
1
- import { fireEvent, screen, within } from "@testing-library/react";
1
+ import { act, fireEvent, screen, within } from "@testing-library/react";
2
2
  import { Route, Routes, useLocation } from "react-router";
3
3
  import { render } from "@truedat/test/render";
4
4
  import { LineageGraph } from "../LineageGraph";
5
5
  import { GraphLoader } from "../GraphLoader";
6
6
 
7
7
  let mockGraphProps;
8
+ let mockDeferInitialFit = false;
8
9
 
9
10
  jest.mock("@truedat/core/components/Graph", () => {
10
11
  const React = require("react");
@@ -12,10 +13,9 @@ jest.mock("@truedat/core/components/Graph", () => {
12
13
  return function Graph(props) {
13
14
  mockGraphProps = props;
14
15
  const [expanded, setExpanded] = React.useState(false);
15
- React.useEffect(
16
- () => props.onInitialFitComplete(),
17
- [props.onInitialFitComplete],
18
- );
16
+ React.useEffect(() => {
17
+ if (!mockDeferInitialFit) props.onInitialFitComplete();
18
+ }, [props.onInitialFitComplete]);
19
19
  const canvas = (name) => (
20
20
  <div className="td-graph-container" data-testid={name}>
21
21
  {props.nodes
@@ -50,7 +50,6 @@ const props = {
50
50
  graphId: "graph-1",
51
51
  loading: false,
52
52
  simplified: true,
53
- cancelPoll: jest.fn(),
54
53
  groups: [],
55
54
  resources: [
56
55
  { id: "orders", name: "Orders", x: 0, y: 0, w: 288, h: 100 },
@@ -72,6 +71,55 @@ const renderGraph = (extra = {}) =>
72
71
  { state },
73
72
  );
74
73
 
74
+ it("keeps the unanswered loading dialog through polling and until the graph is fitted", () => {
75
+ mockDeferInitialFit = true;
76
+ const backgroundGraph = jest.fn();
77
+ const onGraphReady = jest.fn();
78
+ const generationJob = { requestId: "slow-graph", status: "pending" };
79
+ const pendingProps = {
80
+ ...props,
81
+ simplified: false,
82
+ loading: true,
83
+ generationJob,
84
+ backgroundGraph,
85
+ onGraphReady,
86
+ };
87
+ const view = render(<LineageGraph {...pendingProps} />, { state });
88
+ try {
89
+ act(() => jest.advanceTimersByTime(3000));
90
+ const continueButton = screen.getByRole("button", {
91
+ name: "lineage.graph.wait.continue",
92
+ });
93
+ for (let retry = 1; retry <= 3; retry += 1) {
94
+ view.rerender(
95
+ <LineageGraph
96
+ {...pendingProps}
97
+ generationJob={{ ...generationJob, hash: "slow-hash", retry }}
98
+ />,
99
+ );
100
+ act(() => jest.advanceTimersByTime(3000));
101
+ expect(continueButton).toBeVisible();
102
+ expect(continueButton).toBeEnabled();
103
+ }
104
+ view.rerender(
105
+ <LineageGraph
106
+ {...pendingProps}
107
+ loading={false}
108
+ generationJob={{ ...generationJob, status: "completed" }}
109
+ />,
110
+ );
111
+ expect(continueButton).toBeVisible();
112
+ expect(onGraphReady).not.toHaveBeenCalled();
113
+ expect(backgroundGraph).not.toHaveBeenCalled();
114
+ act(() => mockGraphProps.onInitialFitComplete());
115
+ expect(continueButton).not.toBeInTheDocument();
116
+ expect(onGraphReady).toHaveBeenCalledTimes(1);
117
+ } finally {
118
+ mockDeferInitialFit = false;
119
+ view.unmount();
120
+ }
121
+ });
122
+
75
123
  it("selects successive nodes through a real portal and keeps dependencies active on return", () => {
76
124
  renderGraph();
77
125
  fireEvent.click(screen.getByText("Toggle expanded"));
@@ -225,7 +273,6 @@ it.each([
225
273
  graph={graph}
226
274
  fetchGraph={fetchGraph}
227
275
  clearGraph={jest.fn()}
228
- cancelPoll={jest.fn()}
229
276
  />
230
277
  }
231
278
  />
@@ -236,3 +283,27 @@ it.each([
236
283
  else expect(fetchGraph).not.toHaveBeenCalled();
237
284
  },
238
285
  );
286
+
287
+ test.each([
288
+ { simplified: true },
289
+ { simplified: false },
290
+ {
291
+ simplified: true,
292
+ generationJob: { requestId: "other", status: "pending" },
293
+ },
294
+ ])("keeps loading inline without a background action: %j", (extra) => {
295
+ const backgroundGraph = jest.fn();
296
+ const view = renderGraph({ ...extra, loading: true, backgroundGraph });
297
+ act(() => jest.advanceTimersByTime(3000));
298
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
299
+ expect(
300
+ screen.getByText("lineage.graph.loading").closest("[role=status]"),
301
+ ).toBeInTheDocument();
302
+ expect(
303
+ screen.queryByRole("button", { name: "lineage.graph.wait.background" }),
304
+ ).not.toBeInTheDocument();
305
+ fireEvent.keyDown(document, { key: "Escape", keyCode: 27 });
306
+ expect(backgroundGraph).not.toHaveBeenCalled();
307
+ expect(screen.getByText("lineage.graph.loading")).toBeInTheDocument();
308
+ view.unmount();
309
+ });
@@ -12,12 +12,14 @@ jest.mock("@truedat/ai/components/suggestions/SuggestionsWidget", () => ({
12
12
 
13
13
  let dynamicFormOnChange;
14
14
  let dynamicFormContent;
15
+ let dynamicFormOriginalContent;
15
16
 
16
17
  jest.mock("@truedat/df/components/DynamicForm", () => ({
17
18
  __esModule: true,
18
- default: ({ onChange, content }) => {
19
+ default: ({ onChange, content, originalContent }) => {
19
20
  dynamicFormOnChange = onChange;
20
21
  dynamicFormContent = content;
22
+ dynamicFormOriginalContent = originalContent;
21
23
  return <div data-testid="dynamic-form" />;
22
24
  },
23
25
  }));
@@ -27,6 +29,108 @@ describe("<StructureNotesEdit />", () => {
27
29
  jest.clearAllMocks();
28
30
  dynamicFormOnChange = undefined;
29
31
  dynamicFormContent = undefined;
32
+ dynamicFormOriginalContent = undefined;
33
+ });
34
+
35
+ describe("date restrictions when editing", () => {
36
+ const template = {
37
+ id: 2,
38
+ content: [
39
+ {
40
+ name: "dates",
41
+ fields: [
42
+ {
43
+ name: "fecha",
44
+ type: "date",
45
+ restrictions: { resolved: { min: null, max: "2026-10-15" } },
46
+ },
47
+ { name: "description", type: "string" },
48
+ ],
49
+ },
50
+ ],
51
+ };
52
+ const latestDfContent = {
53
+ fecha: { value: "2026-10-16", origin: "user" },
54
+ description: { value: "Original", origin: "user" },
55
+ };
56
+ const props = {
57
+ structure: { id: 1, domain_ids: [1] },
58
+ template,
59
+ applyTemplate: applyTemplate(template),
60
+ latestDfContent,
61
+ selectTemplate: jest.fn(),
62
+ selectDomains: jest.fn(),
63
+ doStructureNoteAction: jest.fn(),
64
+ structureNotes: { structureNotes: [] },
65
+ };
66
+
67
+ it("keeps Save enabled for an unchanged out-of-range date", async () => {
68
+ const rendered = render(<StructureNotesEdit {...props} />);
69
+ await waitForLoad(rendered);
70
+
71
+ expect(await rendered.findByText(/save/i)).toBeEnabled();
72
+ });
73
+
74
+ it("passes persisted originals to the widgets", async () => {
75
+ const rendered = render(<StructureNotesEdit {...props} />);
76
+ await waitForLoad(rendered);
77
+
78
+ expect(dynamicFormOriginalContent).toEqual(latestDfContent);
79
+ });
80
+
81
+ it("keeps Save enabled when only another field is edited", async () => {
82
+ const rendered = render(<StructureNotesEdit {...props} />);
83
+ await waitForLoad(rendered);
84
+
85
+ await act(async () => {
86
+ dynamicFormOnChange({
87
+ ...latestDfContent,
88
+ description: { value: "Updated", origin: "user" },
89
+ });
90
+ });
91
+
92
+ expect(await rendered.findByText(/save/i)).toBeEnabled();
93
+ });
94
+
95
+ it("disables Save when the date changes to another invalid value", async () => {
96
+ const rendered = render(<StructureNotesEdit {...props} />);
97
+ await waitForLoad(rendered);
98
+
99
+ await act(async () => {
100
+ dynamicFormOnChange({
101
+ ...latestDfContent,
102
+ fecha: { value: "2026-10-17", origin: "user" },
103
+ });
104
+ });
105
+
106
+ expect(await rendered.findByText(/save/i)).toBeDisabled();
107
+ });
108
+
109
+ it("restores the exemption when the original date is restored", async () => {
110
+ const rendered = render(<StructureNotesEdit {...props} />);
111
+ await waitForLoad(rendered);
112
+
113
+ await act(async () => {
114
+ dynamicFormOnChange({
115
+ ...latestDfContent,
116
+ fecha: { value: "2026-10-17", origin: "user" },
117
+ });
118
+ });
119
+ await act(async () => dynamicFormOnChange({ ...latestDfContent }));
120
+
121
+ expect(await rendered.findByText(/save/i)).toBeEnabled();
122
+ });
123
+
124
+ it("still validates dates on creation", async () => {
125
+ const rendered = render(
126
+ <StructureNotesEdit {...props} latestDfContent={{}} />,
127
+ );
128
+ await waitForLoad(rendered);
129
+
130
+ await act(async () => dynamicFormOnChange(latestDfContent));
131
+
132
+ expect(await rendered.findByText(/save/i)).toBeDisabled();
133
+ });
30
134
  });
31
135
 
32
136
  it("matches the latest snapshot", async () => {
@@ -0,0 +1,102 @@
1
+ import { act } from "@testing-library/react";
2
+ import { render, waitForLoad } from "@truedat/test/render";
3
+ import DynamicForm from "@truedat/df/components/DynamicForm";
4
+ import { SystemForm } from "../SystemForm";
5
+
6
+ jest.mock("@truedat/df/components/DynamicForm", () => jest.fn(() => null));
7
+ jest.mock("@truedat/df/templates/components/TemplateLoader", () => () => null);
8
+
9
+ const content = { date: { value: "2025-01-01", origin: "user" } };
10
+ const template = {
11
+ name: "system",
12
+ content: [
13
+ {
14
+ name: "dates",
15
+ fields: [
16
+ {
17
+ name: "date",
18
+ type: "date",
19
+ cardinality: "?",
20
+ restrictions: { max_date: { mode: "static", value: "2024-01-01" } },
21
+ },
22
+ ],
23
+ },
24
+ ],
25
+ };
26
+ const system = {
27
+ id: 1,
28
+ name: "System",
29
+ external_id: "system",
30
+ dynamic_content: content,
31
+ };
32
+ const formProps = () => DynamicForm.mock.calls.at(-1)[0];
33
+
34
+ describe("<SystemForm /> date restrictions", () => {
35
+ beforeEach(() => jest.clearAllMocks());
36
+
37
+ it("passes persisted content to the dynamic form", async () => {
38
+ const rendered = render(
39
+ <SystemForm
40
+ system={system}
41
+ template={template}
42
+ createSystem={jest.fn()}
43
+ />,
44
+ );
45
+ await waitForLoad(rendered);
46
+ expect(formProps().originalContent).toEqual(content);
47
+ });
48
+
49
+ it("allows unchanged persisted dates outside the current range", async () => {
50
+ const rendered = render(
51
+ <SystemForm
52
+ system={system}
53
+ template={template}
54
+ createSystem={jest.fn()}
55
+ />,
56
+ );
57
+ await waitForLoad(rendered);
58
+ expect(
59
+ rendered.getByRole("button", { name: "actions.update" }),
60
+ ).toBeEnabled();
61
+ });
62
+
63
+ it("rejects changed dates outside the current range", async () => {
64
+ const rendered = render(
65
+ <SystemForm
66
+ system={system}
67
+ template={template}
68
+ createSystem={jest.fn()}
69
+ />,
70
+ );
71
+ await waitForLoad(rendered);
72
+ act(() =>
73
+ formProps().onChange({ date: { value: "2025-02-01", origin: "user" } }),
74
+ );
75
+ expect(
76
+ rendered.getByRole("button", { name: "actions.update" }),
77
+ ).toBeDisabled();
78
+ });
79
+
80
+ it("keeps the edit baseline after content changes", async () => {
81
+ const rendered = render(
82
+ <SystemForm
83
+ system={system}
84
+ template={template}
85
+ createSystem={jest.fn()}
86
+ />,
87
+ );
88
+ await waitForLoad(rendered);
89
+ act(() =>
90
+ formProps().onChange({ date: { value: "2023-01-01", origin: "user" } }),
91
+ );
92
+ expect(formProps().originalContent).toEqual(content);
93
+ });
94
+
95
+ it("does not supply an original for creation", async () => {
96
+ const rendered = render(
97
+ <SystemForm template={template} createSystem={jest.fn()} />,
98
+ );
99
+ await waitForLoad(rendered);
100
+ expect(formProps().originalContent).toBeUndefined();
101
+ });
102
+ });