@eeacms/volto-cca-policy 0.3.124 → 0.3.126

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.
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { MemoryRouter } from 'react-router-dom';
3
+ import { IntlProvider } from 'react-intl';
3
4
  import '@testing-library/jest-dom/extend-expect';
4
5
  import { render, screen } from '@testing-library/react';
5
6
  import ArchivedVersionListing from './ArchivedVersionListing';
@@ -21,16 +22,21 @@ jest.mock('@eeacms/volto-cca-policy/components', () => ({
21
22
  ),
22
23
  }));
23
24
 
25
+ const renderWithIntl = (ui) =>
26
+ render(
27
+ <IntlProvider locale="en">
28
+ <MemoryRouter>{ui}</MemoryRouter>
29
+ </IntlProvider>,
30
+ );
31
+
24
32
  describe('ArchivedVersionListing', () => {
25
33
  it('returns null when there are no archived versions', () => {
26
34
  const content = {
27
35
  archived_versions: [],
28
36
  };
29
37
 
30
- const { container } = render(
31
- <MemoryRouter>
32
- <ArchivedVersionListing content={content} />
33
- </MemoryRouter>,
38
+ const { container } = renderWithIntl(
39
+ <ArchivedVersionListing content={content} />,
34
40
  );
35
41
 
36
42
  expect(container.innerHTML).toBe('');
@@ -39,10 +45,8 @@ describe('ArchivedVersionListing', () => {
39
45
  it('returns null when archived_versions is missing', () => {
40
46
  const content = {};
41
47
 
42
- const { container } = render(
43
- <MemoryRouter>
44
- <ArchivedVersionListing content={content} />
45
- </MemoryRouter>,
48
+ const { container } = renderWithIntl(
49
+ <ArchivedVersionListing content={content} />,
46
50
  );
47
51
 
48
52
  expect(container.innerHTML).toBe('');
@@ -62,11 +66,7 @@ describe('ArchivedVersionListing', () => {
62
66
  ],
63
67
  };
64
68
 
65
- render(
66
- <MemoryRouter>
67
- <ArchivedVersionListing content={content} />
68
- </MemoryRouter>,
69
- );
69
+ renderWithIntl(<ArchivedVersionListing content={content} />);
70
70
 
71
71
  expect(
72
72
  screen.getByText('Previous versions of this indicator'),
@@ -92,11 +92,7 @@ describe('ArchivedVersionListing', () => {
92
92
  ],
93
93
  };
94
94
 
95
- render(
96
- <MemoryRouter>
97
- <ArchivedVersionListing content={content} />
98
- </MemoryRouter>,
99
- );
95
+ renderWithIntl(<ArchivedVersionListing content={content} />);
100
96
 
101
97
  expect(screen.getByText('/indicator-v1')).toHaveAttribute(
102
98
  'href',
@@ -64,7 +64,10 @@ test('renders an event view component with all props', () => {
64
64
  subjects: ['Guillotina', 'Volto'],
65
65
  whole_day: false,
66
66
  agenda_files: {},
67
- background_documents: {},
67
+ background_documents: {
68
+ download: 'http://localhost:8080/Plone/my-page/background.pdf',
69
+ filename: 'background.pdf',
70
+ },
68
71
  }}
69
72
  />
70
73
  </Provider>,
@@ -85,7 +88,10 @@ test('renders an event view component with only required props', () => {
85
88
  start: '2019-06-23T15:20:00+00:00',
86
89
  subjects: [],
87
90
  agenda_files: {},
88
- background_documents: {},
91
+ background_documents: {
92
+ download: 'http://localhost:8080/Plone/my-page/background.pdf',
93
+ filename: 'background.pdf',
94
+ },
89
95
  }}
90
96
  />
91
97
  </Provider>,
@@ -106,7 +112,10 @@ test('renders an event view component without links to api in the text', () => {
106
112
  start: '2019-06-23T15:20:00+00:00',
107
113
  subjects: [],
108
114
  agenda_files: {},
109
- background_documents: {},
115
+ background_documents: {
116
+ download: 'http://localhost:8080/Plone/my-page/background.pdf',
117
+ filename: 'background.pdf',
118
+ },
110
119
  text: {
111
120
  data: `<p>Hello World!</p><p>This is an <a href="${settings.apiPath}/foo/bar">internal link</a> and a <a href="${settings.apiPath}/foo/baz">second link</a></p>`,
112
121
  },
@@ -44,11 +44,11 @@ describe('DatabaseItemView', () => {
44
44
  contributions: [
45
45
  {
46
46
  title: 'Contributor 1',
47
- url: '/',
47
+ url: '/contributor-1',
48
48
  },
49
49
  {
50
50
  title: 'Contributor 2',
51
- url: '/',
51
+ url: '/contributor-2',
52
52
  },
53
53
  ],
54
54
  };
@@ -5,7 +5,7 @@ import config from '@plone/volto/registry';
5
5
 
6
6
  import { injectIntl } from 'react-intl';
7
7
  import { FormFieldWrapper } from '@plone/volto/components';
8
- import MapContainer from './GeolocationWidgetMapContainer';
8
+ import MapContainer from '@eeacms/volto-cca-policy/components/theme/Widgets/GeolocationWidgetMapContainer';
9
9
 
10
10
  const defaultValue = {
11
11
  latitude: 55.6761,
@@ -0,0 +1,23 @@
1
+ # Workflow Component Customization
2
+
3
+ This component shadows the core Volto `Workflow` component located at `@plone/volto/components/manage/Workflow/Workflow.jsx`.
4
+
5
+ ## Why this is needed
6
+
7
+ In Climate-ADAPT, we want to prevent users from unintentionally breaking internal links when making content private. Volto's core workflow transition dropdown does not perform any link integrity checks before executing a state change.
8
+
9
+ By shadowing this component, we can intercept transitions that might hide content from public users and warn the editor if other pages are linking to the current item.
10
+
11
+ ## Modifications
12
+
13
+ 1. **Interception Logic**: The `transition` function was modified to check for "private-like" transitions (`private`, `reject`, `retract`).
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.
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.
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
+
20
+ ## Reference
21
+
22
+ See implementation details in:
23
+ `artifacts/link-integrity-workflow/understanding-link-integrity.md`
@@ -0,0 +1,359 @@
1
+ import { useEffect, useState, useCallback } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { compose } from 'redux';
4
+ import { useDispatch, useSelector, shallowEqual } from 'react-redux';
5
+ import { uniqBy } from 'lodash';
6
+ import { toast } from 'react-toastify';
7
+ import { defineMessages, useIntl } from 'react-intl';
8
+
9
+ import { FormFieldWrapper, Icon, Toast } from '@plone/volto/components';
10
+ import { Dimmer, Loader } from 'semantic-ui-react';
11
+ import {
12
+ flattenToAppURL,
13
+ getWorkflowOptions,
14
+ getCurrentStateMapping,
15
+ } from '@plone/volto/helpers';
16
+ import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable';
17
+
18
+ import {
19
+ getContent,
20
+ getWorkflow,
21
+ transitionWorkflow,
22
+ linkIntegrityCheck,
23
+ } from '@plone/volto/actions';
24
+ import WorkflowLinkIntegrityModal from '@eeacms/volto-cca-policy/components/manage/Workflow/WorkflowLinkIntegrityModal';
25
+ import downSVG from '@plone/volto/icons/down-key.svg';
26
+ import upSVG from '@plone/volto/icons/up-key.svg';
27
+ import checkSVG from '@plone/volto/icons/check.svg';
28
+
29
+ const messages = defineMessages({
30
+ messageUpdated: {
31
+ id: 'Workflow updated.',
32
+ defaultMessage: 'Workflow updated.',
33
+ },
34
+ messageNoWorkflow: {
35
+ id: 'No workflow',
36
+ defaultMessage: 'No workflow',
37
+ },
38
+ state: {
39
+ id: 'State',
40
+ defaultMessage: 'State',
41
+ },
42
+ });
43
+
44
+ const SingleValue = injectLazyLibs('reactSelect')(({ children, ...props }) => {
45
+ const stateDecorator = {
46
+ marginRight: '10px',
47
+ display: 'inline-block',
48
+ backgroundColor: props.selectProps.value.color || null,
49
+ content: ' ',
50
+ height: '10px',
51
+ width: '10px',
52
+ borderRadius: '50%',
53
+ };
54
+ const { SingleValue } = props.reactSelect.components;
55
+ return (
56
+ <SingleValue {...props}>
57
+ <span style={stateDecorator} />
58
+ {children}
59
+ </SingleValue>
60
+ );
61
+ });
62
+
63
+ const Option = injectLazyLibs('reactSelect')((props) => {
64
+ const stateDecorator = {
65
+ marginRight: '10px',
66
+ display: 'inline-block',
67
+ backgroundColor:
68
+ props.selectProps.value.value === props.data.value
69
+ ? props.selectProps.value.color
70
+ : null,
71
+ content: ' ',
72
+ height: '10px',
73
+ width: '10px',
74
+ borderRadius: '50%',
75
+ border:
76
+ props.selectProps.value.value !== props.data.value
77
+ ? `1px solid ${props.data.color}`
78
+ : null,
79
+ };
80
+
81
+ const { Option } = props['reactSelect'].components;
82
+ return (
83
+ <Option {...props}>
84
+ <span style={stateDecorator} />
85
+ <div style={{ marginRight: 'auto' }}>{props.label}</div>
86
+ {props.isFocused && !props.isSelected && (
87
+ <Icon name={checkSVG} size="18px" color="#b8c6c8" />
88
+ )}
89
+ {props.isSelected && <Icon name={checkSVG} size="18px" color="#007bc1" />}
90
+ </Option>
91
+ );
92
+ });
93
+
94
+ const DropdownIndicator = injectLazyLibs('reactSelect')((props) => {
95
+ const { DropdownIndicator } = props.reactSelect.components;
96
+ return (
97
+ <DropdownIndicator {...props} data-testid="workflow-select-dropdown">
98
+ {props.selectProps.menuIsOpen ? (
99
+ <Icon name={upSVG} size="24px" color="#007bc1" />
100
+ ) : (
101
+ <Icon name={downSVG} size="24px" color="#007bc1" />
102
+ )}
103
+ </DropdownIndicator>
104
+ );
105
+ });
106
+
107
+ const selectTheme = (theme) => ({
108
+ ...theme,
109
+ borderRadius: 0,
110
+ colors: {
111
+ ...theme.colors,
112
+ primary25: 'hotpink',
113
+ primary: '#b8c6c8',
114
+ },
115
+ });
116
+
117
+ const customSelectStyles = {
118
+ control: (styles, state) => ({
119
+ ...styles,
120
+ border: 'none',
121
+ borderBottom: '2px solid #b8c6c8',
122
+ boxShadow: 'none',
123
+ borderBottomStyle: state.menuIsOpen ? 'dotted' : 'solid',
124
+ }),
125
+ menu: (styles, state) => ({
126
+ ...styles,
127
+ top: null,
128
+ marginTop: 0,
129
+ boxShadow: 'none',
130
+ borderBottom: '2px solid #b8c6c8',
131
+ }),
132
+ menuList: (styles, state) => ({
133
+ ...styles,
134
+ maxHeight: '400px',
135
+ }),
136
+ indicatorSeparator: (styles) => ({
137
+ ...styles,
138
+ width: null,
139
+ }),
140
+ valueContainer: (styles) => ({
141
+ ...styles,
142
+ padding: 0,
143
+ }),
144
+ option: (styles, state) => ({
145
+ ...styles,
146
+ backgroundColor: null,
147
+ minHeight: '50px',
148
+ display: 'flex',
149
+ justifyContent: 'space-between',
150
+ alignItems: 'center',
151
+ padding: '12px 12px',
152
+ color: state.isSelected
153
+ ? '#007bc1'
154
+ : state.isFocused
155
+ ? '#4a4a4a'
156
+ : 'inherit',
157
+ ':active': {
158
+ backgroundColor: null,
159
+ },
160
+ span: {
161
+ flex: '0 0 auto',
162
+ },
163
+ svg: {
164
+ flex: '0 0 auto',
165
+ },
166
+ }),
167
+ };
168
+
169
+ function useWorkflow() {
170
+ const history = useSelector((state) => state.workflow.history, shallowEqual);
171
+ const transitions = useSelector(
172
+ (state) => state.workflow.transitions,
173
+ shallowEqual,
174
+ );
175
+ const loaded = useSelector((state) => state.workflow.transition.loaded);
176
+ const transitionLoading = useSelector(
177
+ (state) => state.workflow.transition.loading,
178
+ );
179
+ const currentStateValue = useSelector(
180
+ (state) => getCurrentStateMapping(state.workflow.currentState),
181
+ shallowEqual,
182
+ );
183
+ const linkintegrityInfo = useSelector((state) => state.linkIntegrity?.result);
184
+ const linkintegrityLoaded = useSelector(
185
+ (state) => state.linkIntegrity?.loaded,
186
+ );
187
+ const linkintegrityLoading = useSelector(
188
+ (state) => state.linkIntegrity?.loading,
189
+ );
190
+ const linkintegrityError = useSelector((state) => state.linkIntegrity?.error);
191
+
192
+ return {
193
+ loaded,
194
+ transitionLoading,
195
+ history,
196
+ transitions,
197
+ currentStateValue,
198
+ linkintegrityInfo,
199
+ linkintegrityLoaded,
200
+ linkintegrityLoading,
201
+ linkintegrityError,
202
+ };
203
+ }
204
+
205
+ const Workflow = (props) => {
206
+ const intl = useIntl();
207
+ const dispatch = useDispatch();
208
+ const {
209
+ loaded,
210
+ transitionLoading,
211
+ transitions,
212
+ currentStateValue,
213
+ linkintegrityInfo,
214
+ linkintegrityLoaded,
215
+ linkintegrityLoading,
216
+ linkintegrityError,
217
+ } = useWorkflow();
218
+ const content = useSelector((state) => state.content?.data, shallowEqual);
219
+ const { pathname } = props;
220
+
221
+ const [showWarningModal, setShowWarningModal] = useState(false);
222
+ const [pendingOption, setPendingOption] = useState(null);
223
+
224
+ useEffect(() => {
225
+ dispatch(getWorkflow(pathname));
226
+ dispatch(getContent(pathname));
227
+ }, [dispatch, pathname, loaded]);
228
+
229
+ const executeTransition = useCallback(
230
+ (selectedOption) => {
231
+ if (selectedOption?.url) {
232
+ dispatch(transitionWorkflow(flattenToAppURL(selectedOption.url)));
233
+ toast.success(
234
+ <Toast
235
+ success
236
+ title={intl.formatMessage(messages.messageUpdated)}
237
+ content=""
238
+ />,
239
+ );
240
+ }
241
+ },
242
+ [dispatch, intl],
243
+ );
244
+
245
+ useEffect(() => {
246
+ if (showWarningModal) {
247
+ if (linkintegrityError) {
248
+ // If the check fails, we shouldn't block the user forever. Proceed with transition.
249
+ executeTransition(pendingOption);
250
+ setShowWarningModal(false);
251
+ setPendingOption(null);
252
+ } else if (
253
+ linkintegrityLoaded &&
254
+ linkintegrityInfo &&
255
+ flattenToAppURL(linkintegrityInfo[0]?.['@id']) ===
256
+ flattenToAppURL(content?.['@id'])
257
+ ) {
258
+ const breaches = linkintegrityInfo.flatMap((result) => result.breaches);
259
+ if (breaches.length === 0) {
260
+ executeTransition(pendingOption);
261
+ setShowWarningModal(false);
262
+ setPendingOption(null);
263
+ }
264
+ }
265
+ }
266
+ }, [
267
+ linkintegrityLoaded,
268
+ linkintegrityInfo,
269
+ linkintegrityError,
270
+ showWarningModal,
271
+ pendingOption,
272
+ content,
273
+ executeTransition,
274
+ ]);
275
+
276
+ const transition = (selectedOption) => {
277
+ const isPrivateTransition =
278
+ ['private', 'reject', 'retract'].includes(selectedOption.value) ||
279
+ selectedOption.url.endsWith('/reject') ||
280
+ selectedOption.url.endsWith('/retract');
281
+
282
+ if (isPrivateTransition) {
283
+ setPendingOption(selectedOption);
284
+ dispatch(linkIntegrityCheck([content.UID]));
285
+ setShowWarningModal(true);
286
+ } else {
287
+ executeTransition(selectedOption);
288
+ }
289
+ };
290
+
291
+ const { Placeholder } = props.reactSelect.components;
292
+ const Select = props.reactSelect.default;
293
+
294
+ return (
295
+ <>
296
+ <FormFieldWrapper
297
+ id="state-select"
298
+ title={intl.formatMessage(messages.state)}
299
+ intl={intl}
300
+ {...props}
301
+ >
302
+ <Dimmer active={transitionLoading} inverted>
303
+ <Loader size="small" />
304
+ </Dimmer>
305
+ <Select
306
+ name="state-select"
307
+ className="react-select-container"
308
+ classNamePrefix="react-select"
309
+ isDisabled={
310
+ !content.review_state ||
311
+ transitions.length === 0 ||
312
+ transitionLoading ||
313
+ (showWarningModal && linkintegrityLoading)
314
+ }
315
+ options={uniqBy(
316
+ transitions.map((transition) => getWorkflowOptions(transition)),
317
+ 'label',
318
+ ).concat(currentStateValue)}
319
+ styles={customSelectStyles}
320
+ theme={selectTheme}
321
+ components={{
322
+ DropdownIndicator,
323
+ Placeholder,
324
+ Option,
325
+ SingleValue,
326
+ }}
327
+ onChange={transition}
328
+ value={
329
+ content.review_state
330
+ ? currentStateValue
331
+ : {
332
+ label: intl.formatMessage(messages.messageNoWorkflow),
333
+ value: 'noworkflow',
334
+ }
335
+ }
336
+ isSearchable={false}
337
+ />
338
+ </FormFieldWrapper>
339
+ <WorkflowLinkIntegrityModal
340
+ open={showWarningModal}
341
+ onCancel={() => {
342
+ setShowWarningModal(false);
343
+ setPendingOption(null);
344
+ }}
345
+ onOk={() => {
346
+ executeTransition(pendingOption);
347
+ setShowWarningModal(false);
348
+ setPendingOption(null);
349
+ }}
350
+ />
351
+ </>
352
+ );
353
+ };
354
+
355
+ Workflow.propTypes = {
356
+ pathname: PropTypes.string.isRequired,
357
+ };
358
+
359
+ export default compose(injectLazyLibs(['reactSelect']))(Workflow);