@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 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.126](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.3...0.3.126) - 26 May 2026
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.125...1.0.0-alpha.1) - 20 May 2026
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 custom `semantic-ui-react` `Confirm` component wrapper. Utilizes a unified and stable component template to ensure reliable event loop listener binding.
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 an active `Loader` and `Dimmer` during loading.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-cca-policy",
3
- "version": "0.3.126",
3
+ "version": "0.3.127",
4
4
  "description": "@eeacms/volto-cca-policy: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -1,10 +1,10 @@
1
- import React, { useState, useEffect } from '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 [brokenReferences, setBrokenReferences] = useState(0);
39
- const [breaches, setBreaches] = useState([]);
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 (linkintegrityInfo) {
43
- const breaches = linkintegrityInfo.flatMap((result) =>
44
- result.breaches.map((source) => ({
45
- source: source,
46
- target: result,
47
- })),
48
- );
49
- const source_by_uid = breaches.reduce(
50
- (acc, value) => acc.set(value.source.uid, value.source),
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
- }, [linkintegrityInfo]);
136
+ }, [show, handleKeyDown, handleMousedown]);
73
137
 
74
- const showModal = open && (loading || brokenReferences > 0);
138
+ if (!show) return null;
75
139
 
76
- return (
77
- showModal && (
78
- <Confirm
79
- open={showModal}
80
- confirmButton={{
81
- content: intl.formatMessage(messages.confirmAction),
82
- disabled: loading,
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
- cancelButton={intl.formatMessage(messages.cancel)}
85
- header={intl.formatMessage(messages.confirmHeader)}
86
- content={
87
- <div
88
- className="content"
89
- style={{ minHeight: loading ? '100px' : 'auto' }}
90
- >
91
- <Dimmer active={loading} inverted>
92
- <Loader indeterminate size="massive">
93
- {intl.formatMessage(messages.loading)}
94
- </Loader>
95
- </Dimmer>
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
- <FormattedMessage
99
- 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:"
100
- 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:"
101
- values={{
102
- brokenReferences: <span>{brokenReferences}</span>,
103
- variation: (
104
- <span>
105
- {brokenReferences === 1 ? (
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
- </span>
117
- ),
118
- }}
119
- />
201
+ ),
202
+ }}
203
+ />
204
+ </p>
120
205
  <BrokenLinksList intl={intl} breaches={breaches} />
121
206
  </>
122
207
  )}
123
208
  </div>
124
- }
125
- onCancel={onCancel}
126
- onConfirm={onOk}
127
- size="small"
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" style={{ marginTop: '20px' }}>
136
- <FormattedMessage
137
- id="These items will have broken links"
138
- defaultMessage="These items will have broken links"
139
- />
140
- :
141
- <Table compact>
142
- <Table.Body>
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
- <Table.Row key={breach.source['@id']} verticalAlign="top">
145
- <Table.Cell>
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
- </Table.Cell>
153
- <Table.Cell style={{ minWidth: '140px' }}>
257
+ </td>
258
+ <td className="li-breach-label">
154
259
  <FormattedMessage id="refers to" defaultMessage="refers to" />:
155
- </Table.Cell>
156
- <Table.Cell>
157
- <ul style={{ margin: 0 }}>
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
- </Table.Cell>
172
- </Table.Row>
276
+ </td>
277
+ </tr>
173
278
  ))}
174
- </Table.Body>
175
- </Table>
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('auto-proceeds when no breaches are found', () => {
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 modal with breach list when breaches are found', () => {
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);