@eeacms/volto-cca-policy 0.3.126 → 0.3.127
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/CHANGELOG.md +23 -2
- package/artifacts/link-integrity-workflow/README.md +10 -3
- package/package.json +1 -1
- package/src/components/manage/Workflow/WorkflowLinkIntegrityModal.jsx +349 -92
- package/src/components/manage/Workflow/WorkflowLinkIntegrityModal.test.jsx +2 -4
- package/src/customizations/volto/components/manage/Workflow/README.md +24 -2
- package/src/customizations/volto/components/manage/Workflow/Workflow.jsx +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,19 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
-
### [0.3.
|
|
7
|
+
### [0.3.127](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.3...0.3.127) - 27 May 2026
|
|
8
8
|
|
|
9
9
|
#### :bug: Bug Fixes
|
|
10
10
|
|
|
11
|
+
- fix: resolve remaining lint warnings in WorkflowLinkIntegrityModal [Tiberiu Ichim - [`f1add88`](https://github.com/eea/volto-cca-policy/commit/f1add881c7240ba12dd223bfccb0c4ac86f83056)]
|
|
12
|
+
- fix: resolve lint warnings and a11y errors in WorkflowLinkIntegrityModal [Tiberiu Ichim - [`bc60ca6`](https://github.com/eea/volto-cca-policy/commit/bc60ca604f30f62d6fb225bb319f76bab700ff04)]
|
|
11
13
|
- fix: update warning message in link integrity workflow state change modal [Tiberiu Ichim - [`1244b2e`](https://github.com/eea/volto-cca-policy/commit/1244b2e826590050c6b10506651482cd615b8c92)]
|
|
12
14
|
- fix: resolve workflow state transition failure when confirming via link integrity warning modal [Tiberiu Ichim - [`2fd0d89`](https://github.com/eea/volto-cca-policy/commit/2fd0d899fc421b4944de5488902f4bc35f5102d9)]
|
|
13
15
|
|
|
14
16
|
#### :house: Internal changes
|
|
15
17
|
|
|
18
|
+
- style: Automated code fix [eea-jenkins - [`8ca0234`](https://github.com/eea/volto-cca-policy/commit/8ca0234d1d429ed5964061989dad13cd6b44b027)]
|
|
16
19
|
- style: Automated code fix [eea-jenkins - [`1e4caaf`](https://github.com/eea/volto-cca-policy/commit/1e4caafffd7b026c565887d6ace3c7ec87866bfe)]
|
|
17
20
|
|
|
18
21
|
#### :house: Documentation changes
|
|
19
22
|
|
|
23
|
+
- docs: update link-integrity artifact spec to reflect Portal-based modal [Tiberiu Ichim - [`f697e7e`](https://github.com/eea/volto-cca-policy/commit/f697e7e5b453a31032feeb862526d97a1c995483)]
|
|
20
24
|
- docs: convert index.md absolute links to clean relative links [Tiberiu Ichim - [`bfb2d6a`](https://github.com/eea/volto-cca-policy/commit/bfb2d6a6c592991c4f65875c8db5f9b55205bf4d)]
|
|
21
25
|
- docs: reorganize link integrity workflow artifacts, adding README specification and index overview [Tiberiu Ichim - [`1208c96`](https://github.com/eea/volto-cca-policy/commit/1208c9619ff4097a4d954e355895382978b326dc)]
|
|
22
26
|
- docs: add specification for link integrity check during workflow transitions [Tiberiu Ichim - [`842da3d`](https://github.com/eea/volto-cca-policy/commit/842da3d371f0c5c38a538e3d519bc5eb79705e34)]
|
|
@@ -69,7 +73,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
|
69
73
|
- Refs #296263 - styles and messages [iugin - [`359f2fc`](https://github.com/eea/volto-cca-policy/commit/359f2fcd207a6e58bc9e32d514ca23183ea6ca8e)]
|
|
70
74
|
- Refs #296263 - jenkins update [iugin - [`b424df5`](https://github.com/eea/volto-cca-policy/commit/b424df51271e975a9721e4c0712c3009b68e53fa)]
|
|
71
75
|
- Refs #296263 - init [iugin - [`01bff47`](https://github.com/eea/volto-cca-policy/commit/01bff4757de9d2b01b9476f19b61715a7983999b)]
|
|
72
|
-
### [1.0.0-alpha.1](https://github.com/eea/volto-cca-policy/compare/0.3.
|
|
76
|
+
### [1.0.0-alpha.1](https://github.com/eea/volto-cca-policy/compare/0.3.126...1.0.0-alpha.1) - 20 May 2026
|
|
73
77
|
|
|
74
78
|
#### :rocket: New Features
|
|
75
79
|
|
|
@@ -116,6 +120,23 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
|
116
120
|
- test: update snapshots [kreafox - [`420d51d`](https://github.com/eea/volto-cca-policy/commit/420d51d1e88323db7295752dc1a184888d3d504e)]
|
|
117
121
|
- test: mock withScrollToTarget in Spotlight.test.jsx [kreafox - [`5fe54fb`](https://github.com/eea/volto-cca-policy/commit/5fe54fb44960bacc9ff0fe1d91bbbeec2bc58e83)]
|
|
118
122
|
- update language import path [iugin - [`1217dcc`](https://github.com/eea/volto-cca-policy/commit/1217dcc9e6aa3971bf30c07bb93203cb73949c23)]
|
|
123
|
+
### [0.3.126](https://github.com/eea/volto-cca-policy/compare/0.3.125...0.3.126) - 26 May 2026
|
|
124
|
+
|
|
125
|
+
#### :bug: Bug Fixes
|
|
126
|
+
|
|
127
|
+
- fix: update warning message in link integrity workflow state change modal [Tiberiu Ichim - [`1244b2e`](https://github.com/eea/volto-cca-policy/commit/1244b2e826590050c6b10506651482cd615b8c92)]
|
|
128
|
+
- fix: resolve workflow state transition failure when confirming via link integrity warning modal [Tiberiu Ichim - [`2fd0d89`](https://github.com/eea/volto-cca-policy/commit/2fd0d899fc421b4944de5488902f4bc35f5102d9)]
|
|
129
|
+
|
|
130
|
+
#### :house: Internal changes
|
|
131
|
+
|
|
132
|
+
- style: Automated code fix [eea-jenkins - [`1e4caaf`](https://github.com/eea/volto-cca-policy/commit/1e4caafffd7b026c565887d6ace3c7ec87866bfe)]
|
|
133
|
+
|
|
134
|
+
#### :house: Documentation changes
|
|
135
|
+
|
|
136
|
+
- docs: convert index.md absolute links to clean relative links [Tiberiu Ichim - [`bfb2d6a`](https://github.com/eea/volto-cca-policy/commit/bfb2d6a6c592991c4f65875c8db5f9b55205bf4d)]
|
|
137
|
+
- docs: reorganize link integrity workflow artifacts, adding README specification and index overview [Tiberiu Ichim - [`1208c96`](https://github.com/eea/volto-cca-policy/commit/1208c9619ff4097a4d954e355895382978b326dc)]
|
|
138
|
+
- docs: add specification for link integrity check during workflow transitions [Tiberiu Ichim - [`842da3d`](https://github.com/eea/volto-cca-policy/commit/842da3d371f0c5c38a538e3d519bc5eb79705e34)]
|
|
139
|
+
|
|
119
140
|
### [0.3.125](https://github.com/eea/volto-cca-policy/compare/0.3.124...0.3.125) - 18 May 2026
|
|
120
141
|
|
|
121
142
|
#### :rocket: New Features
|
|
@@ -56,13 +56,20 @@ sequenceDiagram
|
|
|
56
56
|
|
|
57
57
|
### 2. Warning Modal Component
|
|
58
58
|
* **Path**: [WorkflowLinkIntegrityModal.jsx](file:///home/tibi/work/eea.docker.plone-climateadapt/cca/frontend/src/addons/volto-cca-policy/src/components/manage/Workflow/WorkflowLinkIntegrityModal.jsx)
|
|
59
|
-
* **Role**: A
|
|
59
|
+
* **Role**: A plain HTML overlay dialog rendered via `ReactDOM.createPortal` into a `<div>` appended to `document.body`. Zero `semantic-ui-react` dependencies — no `Confirm`, `Modal`, or `Portal`.
|
|
60
|
+
* **Why not `semantic-ui-react`?** The `Confirm` and `Modal` components use internal Portals, auto-controlled state, and shorthand factory systems that made click handlers unreliable in the toolbar dropdown context. The Toolbar's global `document mousedown` handler (`handleClickOutside`) would intercept clicks, close the menu, and unmount the modal before `onConfirm` could fire.
|
|
61
|
+
* **Key implementation details**:
|
|
62
|
+
* **Portal rendering** — renders at `document.body` level with `z-index: 10000`, placing it outside the toolbar dropdown's DOM subtree and CSS stacking context so it appears as a proper full-page overlay.
|
|
63
|
+
* **Capture-phase `mousedown` guard** — a `capture=true` listener on the portal root calls `e.stopPropagation()`, preventing the Toolbar's `handleClickOutside` from firing while the modal is open.
|
|
64
|
+
* **Synchronous breach derivation** — `computeBreaches()` computes `brokenReferences` and `breaches` during render (no `useEffect` + `useState`), so the data is always in sync with `loading` in the same render cycle. This prevents a race condition where `loading` becomes `false` but `brokenReferences` is still `0`, causing premature modal closure.
|
|
65
|
+
* **Inline styles** — all CSS is embedded via a `<style>` tag, no external stylesheet dependency.
|
|
60
66
|
* **Key UX Features**:
|
|
61
67
|
* Maintains button state `disabled: loading` during ongoing checks to prevent premature actions.
|
|
62
|
-
* Renders
|
|
68
|
+
* Renders a CSS spinner and loading text during the check.
|
|
63
69
|
* Displays the exact list of referencing (source) items and target sub-items.
|
|
64
|
-
* Displays the warning copy:
|
|
70
|
+
* Displays the warning copy:
|
|
65
71
|
> *"By changing the state, we're not breaking references, but may break user experience for final Anonymous users. There are {brokenReferences} {variation} to this item:"*
|
|
72
|
+
* Closes on backdrop click, button click, or `Escape` key.
|
|
66
73
|
|
|
67
74
|
### 3. Unit Verification Suite
|
|
68
75
|
* **Path**: [WorkflowLinkIntegrityModal.test.jsx](file:///home/tibi/work/eea.docker.plone-climateadapt/cca/frontend/src/addons/volto-cca-policy/src/components/manage/Workflow/WorkflowLinkIntegrityModal.test.jsx)
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
2
3
|
import PropTypes from 'prop-types';
|
|
3
4
|
import { useSelector } from 'react-redux';
|
|
4
5
|
import { Link } from 'react-router-dom';
|
|
5
6
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|
6
7
|
import { flattenToAppURL } from '@plone/volto/helpers';
|
|
7
|
-
import { Confirm, Dimmer, Loader, Table } from 'semantic-ui-react';
|
|
8
8
|
|
|
9
9
|
const messages = defineMessages({
|
|
10
10
|
confirmHeader: {
|
|
@@ -29,80 +29,166 @@ const messages = defineMessages({
|
|
|
29
29
|
},
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Derive breach data synchronously from linkintegrityInfo.
|
|
34
|
+
* No useEffect + useState — computed during render so it is always
|
|
35
|
+
* in sync with the loading flag in the same render cycle.
|
|
36
|
+
*/
|
|
37
|
+
function computeBreaches(linkintegrityInfo) {
|
|
38
|
+
if (!linkintegrityInfo) {
|
|
39
|
+
return { brokenReferences: 0, breaches: [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const all = linkintegrityInfo.flatMap((result) =>
|
|
43
|
+
result.breaches.map((source) => ({ source, target: result })),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const sourceByUid = new Map();
|
|
47
|
+
const bySource = new Map();
|
|
48
|
+
|
|
49
|
+
for (const entry of all) {
|
|
50
|
+
sourceByUid.set(entry.source.uid, entry.source);
|
|
51
|
+
if (!bySource.has(entry.source.uid)) {
|
|
52
|
+
bySource.set(entry.source.uid, new Set());
|
|
53
|
+
}
|
|
54
|
+
bySource.get(entry.source.uid).add(entry.target);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
brokenReferences: bySource.size,
|
|
59
|
+
breaches: Array.from(bySource, ([uid, targets]) => ({
|
|
60
|
+
source: sourceByUid.get(uid),
|
|
61
|
+
targets: Array.from(targets),
|
|
62
|
+
})),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Plain HTML overlay dialog — no semantic-ui-react, no Portal,
|
|
68
|
+
* no Confirm, no Modal. Just a fixed-position div with inline styles.
|
|
69
|
+
*/
|
|
32
70
|
const WorkflowLinkIntegrityModal = (props) => {
|
|
33
71
|
const { open, onCancel, onOk } = props;
|
|
34
72
|
const intl = useIntl();
|
|
35
73
|
const linkintegrityInfo = useSelector((state) => state.linkIntegrity?.result);
|
|
36
74
|
const loading = useSelector((state) => state.linkIntegrity?.loading);
|
|
37
75
|
|
|
38
|
-
const
|
|
39
|
-
|
|
76
|
+
const { brokenReferences, breaches } = computeBreaches(linkintegrityInfo);
|
|
77
|
+
|
|
78
|
+
// Keep visible while loading OR while breaches exist.
|
|
79
|
+
// Because brokenReferences is derived synchronously, it is always
|
|
80
|
+
// consistent with `loading` in the same render.
|
|
81
|
+
const show = open && (loading || brokenReferences > 0);
|
|
82
|
+
|
|
83
|
+
// Create a dedicated mount container attached to document.body so the
|
|
84
|
+
// modal renders outside the toolbar dropdown's stacking context.
|
|
85
|
+
// Initialized synchronously (not in useEffect) so it is available on
|
|
86
|
+
// the first render — important for tests and to avoid a flicker.
|
|
87
|
+
const containerRef = useRef(
|
|
88
|
+
(() => {
|
|
89
|
+
const el = document.createElement('div');
|
|
90
|
+
document.body.appendChild(el);
|
|
91
|
+
return el;
|
|
92
|
+
})(),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const container = containerRef.current;
|
|
97
|
+
return () => {
|
|
98
|
+
if (container && container.parentNode) {
|
|
99
|
+
container.parentNode.removeChild(container);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
// Close on Escape key
|
|
105
|
+
const handleKeyDown = useCallback(
|
|
106
|
+
(e) => {
|
|
107
|
+
if (e.key === 'Escape') {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
e.stopPropagation();
|
|
110
|
+
onCancel();
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
[onCancel],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Prevent the Toolbar's global `document mousedown` handler from closing
|
|
117
|
+
// the toolbar menu while our modal is open. The Toolbar listens on
|
|
118
|
+
// `document` for `mousedown` and calls `closeMenu()` if the click is
|
|
119
|
+
// outside the toolbar pusher. By adding a capture-phase listener on our
|
|
120
|
+
// portal root, we intercept the event before it bubbles to `document`.
|
|
121
|
+
const handleMousedown = useCallback((e) => {
|
|
122
|
+
e.stopPropagation();
|
|
123
|
+
}, []);
|
|
40
124
|
|
|
41
125
|
useEffect(() => {
|
|
42
|
-
if (
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
new Map(),
|
|
52
|
-
);
|
|
53
|
-
const by_source = breaches.reduce((acc, value) => {
|
|
54
|
-
if (acc.get(value.source.uid) === undefined) {
|
|
55
|
-
acc.set(value.source.uid, new Set());
|
|
56
|
-
}
|
|
57
|
-
acc.get(value.source.uid).add(value.target);
|
|
58
|
-
return acc;
|
|
59
|
-
}, new Map());
|
|
60
|
-
|
|
61
|
-
setBrokenReferences(by_source.size);
|
|
62
|
-
setBreaches(
|
|
63
|
-
Array.from(by_source, (entry) => ({
|
|
64
|
-
source: source_by_uid.get(entry[0]),
|
|
65
|
-
targets: Array.from(entry[1]),
|
|
66
|
-
})),
|
|
67
|
-
);
|
|
68
|
-
} else {
|
|
69
|
-
setBrokenReferences(0);
|
|
70
|
-
setBreaches([]);
|
|
126
|
+
if (show) {
|
|
127
|
+
const container = containerRef.current;
|
|
128
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
129
|
+
// capture=true: fires before any bubbling listeners on ancestors
|
|
130
|
+
container.addEventListener('mousedown', handleMousedown, true);
|
|
131
|
+
return () => {
|
|
132
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
133
|
+
container.removeEventListener('mousedown', handleMousedown, true);
|
|
134
|
+
};
|
|
71
135
|
}
|
|
72
|
-
}, [
|
|
136
|
+
}, [show, handleKeyDown, handleMousedown]);
|
|
73
137
|
|
|
74
|
-
|
|
138
|
+
if (!show) return null;
|
|
75
139
|
|
|
76
|
-
return (
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
140
|
+
return createPortal(
|
|
141
|
+
<>
|
|
142
|
+
<div
|
|
143
|
+
className="li-modal-backdrop"
|
|
144
|
+
role="presentation"
|
|
145
|
+
tabIndex={-1}
|
|
146
|
+
onClick={(e) => {
|
|
147
|
+
// Only cancel when clicking the backdrop itself, not children
|
|
148
|
+
if (e.target === e.currentTarget) {
|
|
149
|
+
onCancel();
|
|
150
|
+
}
|
|
151
|
+
}}
|
|
152
|
+
onKeyDown={(e) => {
|
|
153
|
+
if (e.key === 'Escape') {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
e.stopPropagation();
|
|
156
|
+
onCancel();
|
|
157
|
+
}
|
|
83
158
|
}}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
159
|
+
data-testid="li-modal-backdrop"
|
|
160
|
+
>
|
|
161
|
+
<div
|
|
162
|
+
className="li-modal-dialog"
|
|
163
|
+
role="dialog"
|
|
164
|
+
aria-modal="true"
|
|
165
|
+
aria-labelledby="li-modal-title"
|
|
166
|
+
data-testid="li-modal-dialog"
|
|
167
|
+
>
|
|
168
|
+
<div className="li-modal-header">
|
|
169
|
+
<span className="li-modal-title" id="li-modal-title">
|
|
170
|
+
{intl.formatMessage(messages.confirmHeader)}
|
|
171
|
+
</span>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div className="li-modal-content">
|
|
175
|
+
{loading && (
|
|
176
|
+
<div className="li-modal-loading">
|
|
177
|
+
<div className="li-spinner" />
|
|
178
|
+
<span>{intl.formatMessage(messages.loading)}</span>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
96
182
|
{!loading && brokenReferences > 0 && (
|
|
97
183
|
<>
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
184
|
+
<p>
|
|
185
|
+
<FormattedMessage
|
|
186
|
+
id="By changing the state, we're not breaking references, but may break user experience for final Anonymous users. There are {brokenReferences} {variation} to this item:"
|
|
187
|
+
defaultMessage="By changing the state, we're not breaking references, but may break user experience for final Anonymous users. There are {brokenReferences} {variation} to this item:"
|
|
188
|
+
values={{
|
|
189
|
+
brokenReferences: <strong>{brokenReferences}</strong>,
|
|
190
|
+
variation:
|
|
191
|
+
brokenReferences === 1 ? (
|
|
106
192
|
<FormattedMessage
|
|
107
193
|
id="reference"
|
|
108
194
|
defaultMessage="reference"
|
|
@@ -112,49 +198,68 @@ const WorkflowLinkIntegrityModal = (props) => {
|
|
|
112
198
|
id="references"
|
|
113
199
|
defaultMessage="references"
|
|
114
200
|
/>
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
/>
|
|
201
|
+
),
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
</p>
|
|
120
205
|
<BrokenLinksList intl={intl} breaches={breaches} />
|
|
121
206
|
</>
|
|
122
207
|
)}
|
|
123
208
|
</div>
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
209
|
+
|
|
210
|
+
<div className="li-modal-actions">
|
|
211
|
+
<button
|
|
212
|
+
className="li-btn li-btn-secondary"
|
|
213
|
+
onClick={onCancel}
|
|
214
|
+
disabled={loading}
|
|
215
|
+
data-testid="li-btn-cancel"
|
|
216
|
+
>
|
|
217
|
+
{intl.formatMessage(messages.cancel)}
|
|
218
|
+
</button>
|
|
219
|
+
<button
|
|
220
|
+
className="li-btn li-btn-primary"
|
|
221
|
+
onClick={onOk}
|
|
222
|
+
disabled={loading}
|
|
223
|
+
data-testid="li-btn-confirm"
|
|
224
|
+
>
|
|
225
|
+
{intl.formatMessage(messages.confirmAction)}
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<style>{modalStyles}</style>
|
|
231
|
+
</>,
|
|
232
|
+
containerRef.current,
|
|
130
233
|
);
|
|
131
234
|
};
|
|
132
235
|
|
|
133
236
|
const BrokenLinksList = ({ intl, breaches }) => {
|
|
134
237
|
return (
|
|
135
|
-
<div className="broken-links-list"
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
238
|
+
<div className="li-broken-links-list">
|
|
239
|
+
<p>
|
|
240
|
+
<FormattedMessage
|
|
241
|
+
id="These items will have broken links"
|
|
242
|
+
defaultMessage="These items will have broken links"
|
|
243
|
+
/>
|
|
244
|
+
:
|
|
245
|
+
</p>
|
|
246
|
+
<table className="li-breach-table">
|
|
247
|
+
<tbody>
|
|
143
248
|
{breaches.map((breach) => (
|
|
144
|
-
<
|
|
145
|
-
<
|
|
249
|
+
<tr key={breach.source['@id']}>
|
|
250
|
+
<td className="li-breach-source">
|
|
146
251
|
<Link
|
|
147
252
|
to={flattenToAppURL(breach.source['@id'])}
|
|
148
253
|
title={intl.formatMessage(messages.navigate_to_this_item)}
|
|
149
254
|
>
|
|
150
255
|
{breach.source.title}
|
|
151
256
|
</Link>
|
|
152
|
-
</
|
|
153
|
-
<
|
|
257
|
+
</td>
|
|
258
|
+
<td className="li-breach-label">
|
|
154
259
|
<FormattedMessage id="refers to" defaultMessage="refers to" />:
|
|
155
|
-
</
|
|
156
|
-
<
|
|
157
|
-
<ul
|
|
260
|
+
</td>
|
|
261
|
+
<td className="li-breach-targets">
|
|
262
|
+
<ul>
|
|
158
263
|
{breach.targets.map((target) => (
|
|
159
264
|
<li key={target['@id']}>
|
|
160
265
|
<Link
|
|
@@ -168,11 +273,11 @@ const BrokenLinksList = ({ intl, breaches }) => {
|
|
|
168
273
|
</li>
|
|
169
274
|
))}
|
|
170
275
|
</ul>
|
|
171
|
-
</
|
|
172
|
-
</
|
|
276
|
+
</td>
|
|
277
|
+
</tr>
|
|
173
278
|
))}
|
|
174
|
-
</
|
|
175
|
-
</
|
|
279
|
+
</tbody>
|
|
280
|
+
</table>
|
|
176
281
|
</div>
|
|
177
282
|
);
|
|
178
283
|
};
|
|
@@ -184,3 +289,155 @@ WorkflowLinkIntegrityModal.propTypes = {
|
|
|
184
289
|
};
|
|
185
290
|
|
|
186
291
|
export default WorkflowLinkIntegrityModal;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Minimal inline styles — no external CSS dependency.
|
|
295
|
+
* Matches Volto's general look (white dialog, centred, dark backdrop).
|
|
296
|
+
*/
|
|
297
|
+
const modalStyles = `
|
|
298
|
+
.li-modal-backdrop {
|
|
299
|
+
position: fixed;
|
|
300
|
+
inset: 0;
|
|
301
|
+
z-index: 10000;
|
|
302
|
+
display: flex;
|
|
303
|
+
align-items: center;
|
|
304
|
+
justify-content: center;
|
|
305
|
+
background: rgba(0, 0, 0, 0.6);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.li-modal-dialog {
|
|
309
|
+
background: #fff;
|
|
310
|
+
border-radius: 8px;
|
|
311
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
312
|
+
max-width: 600px;
|
|
313
|
+
width: 90%;
|
|
314
|
+
max-height: 80vh;
|
|
315
|
+
overflow-y: auto;
|
|
316
|
+
display: flex;
|
|
317
|
+
flex-direction: column;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.li-modal-header {
|
|
321
|
+
padding: 16px 20px;
|
|
322
|
+
border-bottom: 1px solid #e0e0e0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.li-modal-title {
|
|
326
|
+
font-size: 18px;
|
|
327
|
+
font-weight: 600;
|
|
328
|
+
color: #333;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.li-modal-content {
|
|
332
|
+
padding: 20px;
|
|
333
|
+
min-height: 80px;
|
|
334
|
+
color: #4a4a4a;
|
|
335
|
+
font-size: 14px;
|
|
336
|
+
line-height: 1.5;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.li-modal-loading {
|
|
340
|
+
display: flex;
|
|
341
|
+
align-items: center;
|
|
342
|
+
gap: 12px;
|
|
343
|
+
justify-content: center;
|
|
344
|
+
padding: 20px 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.li-spinner {
|
|
348
|
+
width: 24px;
|
|
349
|
+
height: 24px;
|
|
350
|
+
border: 3px solid #e0e0e0;
|
|
351
|
+
border-top-color: #007bc1;
|
|
352
|
+
border-radius: 50%;
|
|
353
|
+
animation: li-spin 0.7s linear infinite;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
@keyframes li-spin {
|
|
357
|
+
to { transform: rotate(360deg); }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.li-modal-actions {
|
|
361
|
+
display: flex;
|
|
362
|
+
justify-content: flex-end;
|
|
363
|
+
gap: 10px;
|
|
364
|
+
padding: 16px 20px;
|
|
365
|
+
border-top: 1px solid #e0e0e0;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.li-btn {
|
|
369
|
+
padding: 8px 18px;
|
|
370
|
+
border-radius: 4px;
|
|
371
|
+
font-size: 14px;
|
|
372
|
+
cursor: pointer;
|
|
373
|
+
border: 1px solid transparent;
|
|
374
|
+
transition: background 0.15s, border-color 0.15s;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.li-btn:disabled {
|
|
378
|
+
opacity: 0.5;
|
|
379
|
+
cursor: not-allowed;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.li-btn-secondary {
|
|
383
|
+
background: #f5f5f5;
|
|
384
|
+
border-color: #d0d0d0;
|
|
385
|
+
color: #333;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.li-btn-secondary:hover:not(:disabled) {
|
|
389
|
+
background: #ebebeb;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.li-btn-primary {
|
|
393
|
+
background: #007bc1;
|
|
394
|
+
color: #fff;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.li-btn-primary:hover:not(:disabled) {
|
|
398
|
+
background: #005a89;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.li-broken-links-list {
|
|
402
|
+
margin-top: 16px;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.li-breach-table {
|
|
406
|
+
width: 100%;
|
|
407
|
+
border-collapse: collapse;
|
|
408
|
+
font-size: 13px;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.li-breach-table td {
|
|
412
|
+
padding: 6px 8px;
|
|
413
|
+
vertical-align: top;
|
|
414
|
+
border-bottom: 1px solid #eee;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.li-breach-label {
|
|
418
|
+
white-space: nowrap;
|
|
419
|
+
padding-left: 12px;
|
|
420
|
+
color: #888;
|
|
421
|
+
width: 1px;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.li-breach-targets ul {
|
|
425
|
+
margin: 0;
|
|
426
|
+
padding-left: 18px;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.li-breach-targets li {
|
|
430
|
+
margin-bottom: 2px;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.li-breach-source a,
|
|
434
|
+
.li-breach-targets a {
|
|
435
|
+
color: #007bc1;
|
|
436
|
+
text-decoration: none;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.li-breach-source a:hover,
|
|
440
|
+
.li-breach-targets a:hover {
|
|
441
|
+
text-decoration: underline;
|
|
442
|
+
}
|
|
443
|
+
`;
|
|
@@ -55,7 +55,7 @@ describe('WorkflowLinkIntegrityModal', () => {
|
|
|
55
55
|
expect(screen.getByText('Checking references...')).toBeInTheDocument();
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it('
|
|
58
|
+
it('renders nothing when no breaches are found (auto-proceed handled by parent)', () => {
|
|
59
59
|
const store = makeStore({
|
|
60
60
|
result: [{ breaches: [], '@id': 'http://localhost:8080/Plone/target' }],
|
|
61
61
|
loaded: true,
|
|
@@ -68,11 +68,10 @@ describe('WorkflowLinkIntegrityModal', () => {
|
|
|
68
68
|
/>,
|
|
69
69
|
store,
|
|
70
70
|
);
|
|
71
|
-
// brokenReferences === 0 so the Confirm dialog is not rendered
|
|
72
71
|
expect(container.innerHTML).toBe('');
|
|
73
72
|
});
|
|
74
73
|
|
|
75
|
-
it('shows warning
|
|
74
|
+
it('shows warning dialog with breach list when breaches are found', () => {
|
|
76
75
|
const store = makeStore({
|
|
77
76
|
result: [
|
|
78
77
|
{
|
|
@@ -141,7 +140,6 @@ describe('WorkflowLinkIntegrityModal', () => {
|
|
|
141
140
|
/>,
|
|
142
141
|
store,
|
|
143
142
|
);
|
|
144
|
-
// Both breaches come from the same source, so brokenReferences === 1
|
|
145
143
|
expect(screen.getByText('Source Page')).toBeInTheDocument();
|
|
146
144
|
expect(screen.getByText('Target 1')).toBeInTheDocument();
|
|
147
145
|
expect(screen.getByText('Target 2')).toBeInTheDocument();
|
|
@@ -12,11 +12,33 @@ By shadowing this component, we can intercept transitions that might hide conten
|
|
|
12
12
|
|
|
13
13
|
1. **Interception Logic**: The `transition` function was modified to check for "private-like" transitions (`private`, `reject`, `retract`).
|
|
14
14
|
2. **Link Integrity Check**: When a sensitive transition is selected, the `linkIntegrityCheck` action is dispatched to the backend.
|
|
15
|
-
3. **State Management**: Added local state (`showWarningModal`, `pendingOption`) to handle the asynchronous check and the confirmation flow.
|
|
15
|
+
3. **State Management**: Added local state (`showWarningModal`, `pendingOption`, `transitionTriggered`) to handle the asynchronous check and the confirmation flow.
|
|
16
16
|
4. **Confirmation Modal**: Integrated `WorkflowLinkIntegrityModal` which displays the list of pages that would have broken links.
|
|
17
|
-
5. **Auto-proceed**: Added an `useEffect` that automatically executes the transition if the link integrity check returns zero breaches.
|
|
17
|
+
5. **Auto-proceed**: Added an `useEffect` that automatically executes the transition if the link integrity check returns zero breaches. It is guarded by `transitionTriggered` to prevent double-execution if the user clicks "Change state anyway" at the same time.
|
|
18
18
|
6. **Activity Indicators**: Added `Dimmer` and `Loader` components from `semantic-ui-react` to provide visual feedback while the link integrity check is loading and during the workflow transition execution.
|
|
19
19
|
|
|
20
|
+
## Design Decisions
|
|
21
|
+
|
|
22
|
+
### Plain HTML dialog via React Portal — no semantic-ui-react
|
|
23
|
+
|
|
24
|
+
The `WorkflowLinkIntegrityModal` renders via `ReactDOM.createPortal` into a `<div>` appended to `document.body`. This places it outside the toolbar dropdown's DOM subtree and CSS stacking context, so it appears as a proper full-page overlay at `z-index: 10000`.
|
|
25
|
+
|
|
26
|
+
No `semantic-ui-react` `Confirm`, `Modal`, or `Portal` is used. Those components rely on Portals, auto-controlled state, and shorthand factory systems that make click handlers unreliable in our use case. The plain HTML approach gives us full, predictable control: `onClick` on a `<button>` always fires.
|
|
27
|
+
|
|
28
|
+
### Toolbar `handleClickOutside` interference
|
|
29
|
+
|
|
30
|
+
Volto's `Toolbar` component registers a global `document.addEventListener('mousedown', ...)` handler that closes the toolbar menu when clicking outside it. Since our modal renders on top of the toolbar menu, any `mousedown` would bubble up to `document` and trigger the menu close — which unmounts the `Workflow` component and our modal before the button `onClick` can fire.
|
|
31
|
+
|
|
32
|
+
The fix: a **capture-phase** `mousedown` listener on the portal root calls `e.stopPropagation()`, preventing the event from ever reaching `document`. This keeps the toolbar menu open while our modal is visible, without interfering with normal `click` event handling on the buttons.
|
|
33
|
+
|
|
34
|
+
### Synchronous breach derivation (no useEffect + local state)
|
|
35
|
+
|
|
36
|
+
The breach data (`brokenReferences`, `breaches`) is computed synchronously inside a `computeBreaches()` helper function rather than via `useEffect` + `useState`. This is critical: the `linkIntegrity` reducer sets `loading: false` and `result: data` in the same action (`_SUCCESS`). If breach data were derived via `useEffect` + local state, there would be a render cycle where `loading` is already `false` but `brokenReferences` is still `0` (stale local state), causing the modal to close prematurely before the breach data is processed.
|
|
37
|
+
|
|
38
|
+
### `transitionTriggered` guard
|
|
39
|
+
|
|
40
|
+
A `transitionTriggered` state flag prevents the auto-proceed `useEffect` from firing after the user has already clicked "Change state anyway", avoiding duplicate workflow transition API calls.
|
|
41
|
+
|
|
20
42
|
## Reference
|
|
21
43
|
|
|
22
44
|
See implementation details in:
|
|
@@ -220,6 +220,7 @@ const Workflow = (props) => {
|
|
|
220
220
|
|
|
221
221
|
const [showWarningModal, setShowWarningModal] = useState(false);
|
|
222
222
|
const [pendingOption, setPendingOption] = useState(null);
|
|
223
|
+
const [transitionTriggered, setTransitionTriggered] = useState(false);
|
|
223
224
|
|
|
224
225
|
useEffect(() => {
|
|
225
226
|
dispatch(getWorkflow(pathname));
|
|
@@ -229,6 +230,7 @@ const Workflow = (props) => {
|
|
|
229
230
|
const executeTransition = useCallback(
|
|
230
231
|
(selectedOption) => {
|
|
231
232
|
if (selectedOption?.url) {
|
|
233
|
+
setTransitionTriggered(true);
|
|
232
234
|
dispatch(transitionWorkflow(flattenToAppURL(selectedOption.url)));
|
|
233
235
|
toast.success(
|
|
234
236
|
<Toast
|
|
@@ -243,7 +245,7 @@ const Workflow = (props) => {
|
|
|
243
245
|
);
|
|
244
246
|
|
|
245
247
|
useEffect(() => {
|
|
246
|
-
if (showWarningModal) {
|
|
248
|
+
if (showWarningModal && !transitionTriggered) {
|
|
247
249
|
if (linkintegrityError) {
|
|
248
250
|
// If the check fails, we shouldn't block the user forever. Proceed with transition.
|
|
249
251
|
executeTransition(pendingOption);
|
|
@@ -269,6 +271,7 @@ const Workflow = (props) => {
|
|
|
269
271
|
linkintegrityError,
|
|
270
272
|
showWarningModal,
|
|
271
273
|
pendingOption,
|
|
274
|
+
transitionTriggered,
|
|
272
275
|
content,
|
|
273
276
|
executeTransition,
|
|
274
277
|
]);
|
|
@@ -281,6 +284,7 @@ const Workflow = (props) => {
|
|
|
281
284
|
|
|
282
285
|
if (isPrivateTransition) {
|
|
283
286
|
setPendingOption(selectedOption);
|
|
287
|
+
setTransitionTriggered(false);
|
|
284
288
|
dispatch(linkIntegrityCheck([content.UID]));
|
|
285
289
|
setShowWarningModal(true);
|
|
286
290
|
} else {
|
|
@@ -341,6 +345,7 @@ const Workflow = (props) => {
|
|
|
341
345
|
onCancel={() => {
|
|
342
346
|
setShowWarningModal(false);
|
|
343
347
|
setPendingOption(null);
|
|
348
|
+
setTransitionTriggered(false);
|
|
344
349
|
}}
|
|
345
350
|
onOk={() => {
|
|
346
351
|
executeTransition(pendingOption);
|