@plone/volto 16.32.1 → 16.34.0

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 (49) hide show
  1. package/.changelog.draft +6 -2
  2. package/.readthedocs.yaml +4 -5
  3. package/.yarn/install-state.gz +0 -0
  4. package/CHANGELOG.md +31 -0
  5. package/locales/ca/LC_MESSAGES/volto.po +55 -13
  6. package/locales/ca.json +1 -1
  7. package/locales/de/LC_MESSAGES/volto.po +56 -14
  8. package/locales/de.json +1 -1
  9. package/locales/en/LC_MESSAGES/volto.po +55 -13
  10. package/locales/en.json +1 -1
  11. package/locales/es/LC_MESSAGES/volto.po +55 -13
  12. package/locales/es.json +1 -1
  13. package/locales/eu/LC_MESSAGES/volto.po +55 -13
  14. package/locales/eu.json +1 -1
  15. package/locales/fi/LC_MESSAGES/volto.po +55 -13
  16. package/locales/fi.json +1 -1
  17. package/locales/fr/LC_MESSAGES/volto.po +55 -13
  18. package/locales/fr.json +1 -1
  19. package/locales/it/LC_MESSAGES/volto.po +55 -13
  20. package/locales/it.json +1 -1
  21. package/locales/ja/LC_MESSAGES/volto.po +55 -13
  22. package/locales/ja.json +1 -1
  23. package/locales/nl/LC_MESSAGES/volto.po +55 -13
  24. package/locales/nl.json +1 -1
  25. package/locales/pt/LC_MESSAGES/volto.po +55 -13
  26. package/locales/pt.json +1 -1
  27. package/locales/pt_BR/LC_MESSAGES/volto.po +55 -13
  28. package/locales/pt_BR.json +1 -1
  29. package/locales/ro/LC_MESSAGES/volto.po +55 -13
  30. package/locales/ro.json +1 -1
  31. package/locales/volto.pot +56 -14
  32. package/locales/zh_CN/LC_MESSAGES/volto.po +55 -13
  33. package/locales/zh_CN.json +1 -1
  34. package/package.json +1 -1
  35. package/packages/volto-slate/package.json +1 -1
  36. package/src/actions/aliases/aliases.js +27 -7
  37. package/src/actions/aliases/aliases.test.js +1 -1
  38. package/src/components/manage/Controlpanels/Aliases.jsx +542 -583
  39. package/src/components/manage/Controlpanels/Aliases.test.jsx +7 -0
  40. package/src/components/manage/Form/ModalForm.jsx +3 -1
  41. package/src/components/theme/AlternateHrefLangs/AlternateHrefLangs.jsx +23 -0
  42. package/src/components/theme/AlternateHrefLangs/AlternateHrefLangs.test.jsx +135 -0
  43. package/src/components/theme/View/View.jsx +2 -0
  44. package/src/constants/ActionTypes.js +1 -0
  45. package/src/express-middleware/devproxy.js +7 -2
  46. package/src/helpers/Api/Api.js +12 -1
  47. package/src/middleware/api.js +3 -0
  48. package/.python-version +0 -1
  49. package/packages/README.md +0 -7
@@ -1,30 +1,37 @@
1
- import React, { Component } from 'react';
2
- import PropTypes from 'prop-types';
3
- import { connect } from 'react-redux';
4
- import { compose } from 'redux';
5
- import { Link } from 'react-router-dom';
6
- import { getBaseUrl, getParentUrl, Helmet } from '@plone/volto/helpers';
7
- import { removeAliases, addAliases, getAliases } from '@plone/volto/actions';
1
+ import { useState, useEffect, useCallback, useMemo } from 'react';
2
+ import { useDispatch, useSelector } from 'react-redux';
3
+ import { Link, useHistory, useLocation } from 'react-router-dom';
8
4
  import { Portal } from 'react-portal';
5
+ import { getBaseUrl, getParentUrl, Helmet } from '@plone/volto/helpers';
6
+ import {
7
+ removeAliases,
8
+ addAliases,
9
+ getAliases,
10
+ uploadAliases,
11
+ } from '@plone/volto/actions/aliases/aliases';
9
12
  import {
10
- Container,
11
13
  Button,
12
- Segment,
13
- Form,
14
14
  Checkbox,
15
+ Container,
16
+ Form,
15
17
  Header,
16
18
  Input,
19
+ Loader,
20
+ Menu,
21
+ Pagination,
17
22
  Radio,
18
- Message,
23
+ Segment,
19
24
  Table,
20
- Pagination,
21
- Menu,
22
25
  } from 'semantic-ui-react';
23
- import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
26
+ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
24
27
  import DatetimeWidget from '@plone/volto/components/manage/Widgets/DatetimeWidget';
28
+ import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper';
29
+ import ModalForm from '@plone/volto/components/manage/Form/ModalForm';
25
30
  import { Icon, Toolbar } from '@plone/volto/components';
31
+ import FormattedDate from '@plone/volto/components/theme/FormattedDate/FormattedDate';
26
32
 
27
33
  import backSVG from '@plone/volto/icons/back.svg';
34
+ import editingSVG from '@plone/volto/icons/editing.svg';
28
35
  import { map } from 'lodash';
29
36
  import { toast } from 'react-toastify';
30
37
  import { Toast } from '@plone/volto/components';
@@ -38,6 +45,14 @@ const messages = defineMessages({
38
45
  id: 'URL Management',
39
46
  defaultMessage: 'URL Management',
40
47
  },
48
+ AddUrl: {
49
+ id: 'Add Alternative URL',
50
+ defaultMessage: 'Add Alternative URL',
51
+ },
52
+ EditUrl: {
53
+ id: 'Edit Alternative URL',
54
+ defaultMessage: 'Edit Alternative URL',
55
+ },
41
56
  success: {
42
57
  id: 'Success',
43
58
  defaultMessage: 'Success',
@@ -46,6 +61,50 @@ const messages = defineMessages({
46
61
  id: 'Alias has been added',
47
62
  defaultMessage: 'Alias has been added',
48
63
  },
64
+ successUpload: {
65
+ id: 'Aliases have been uploaded.',
66
+ defaultMessage: 'Aliases have been uploaded.',
67
+ },
68
+ successRemove: {
69
+ id: 'Aliases have been removed.',
70
+ defaultMessage: 'Aliases have been removed.',
71
+ },
72
+ filterByPrefix: {
73
+ id: 'Filter by prefix',
74
+ defaultMessage: 'Filter by path',
75
+ },
76
+ manualOrAuto: {
77
+ id: 'Manually or automatically added?',
78
+ defaultMessage: 'Manually or automatically added?',
79
+ },
80
+ createdAfter: {
81
+ id: 'Created after',
82
+ defaultMessage: 'Created after',
83
+ },
84
+ createdBefore: {
85
+ id: 'Created before',
86
+ defaultMessage: 'Created before',
87
+ },
88
+ altUrlPathTitle: {
89
+ id: 'Alternative url path (Required)',
90
+ defaultMessage: 'Alternative URL path (Required)',
91
+ },
92
+ altUrlError: {
93
+ id: 'Alternative url path must start with a slash.',
94
+ defaultMessage: 'Alternative URL path must start with a slash.',
95
+ },
96
+ targetUrlPathTitle: {
97
+ id: 'Target Path (Required)',
98
+ defaultMessage: 'Target Path (Required)',
99
+ },
100
+ BulkUploadAltUrls: {
101
+ id: 'BulkUploadAltUrls',
102
+ defaultMessage: 'Bulk upload CSV',
103
+ },
104
+ CSVFile: {
105
+ id: 'CSVFile',
106
+ defaultMessage: 'CSV file',
107
+ },
49
108
  });
50
109
 
51
110
  const filterChoices = [
@@ -56,616 +115,516 @@ const filterChoices = [
56
115
 
57
116
  const itemsPerPageChoices = [10, 25, 50, 'All'];
58
117
 
59
- /**
60
- * Aliases class.
61
- * @class Aliases
62
- * @extends Component
63
- */
64
- class Aliases extends Component {
65
- /**
66
- * Property types.
67
- * @property {Object} propTypes Property types.
68
- * @static
69
- */
70
- static propTypes = {
71
- addAliases: PropTypes.func.isRequired,
72
- getAliases: PropTypes.func.isRequired,
73
- removeAliases: PropTypes.func.isRequired,
74
- };
118
+ const Aliases = (props) => {
119
+ const title = props;
120
+ const intl = useIntl();
121
+ const dispatch = useDispatch();
122
+ const { pathname } = useLocation();
123
+ const history = useHistory();
75
124
 
76
- /**
77
- * Constructor
78
- * @method constructor
79
- * @param {Object} props Component properties
80
- * @constructs Aliases
81
- */
82
- constructor(props) {
83
- super(props);
84
- this.state = {
85
- isClient: false,
86
- filterType: filterChoices[0],
87
- createdBefore: null,
88
- altUrlPath: '',
89
- isAltUrlCorrect: false,
90
- targetUrlPath: '',
91
- aliasesToRemove: [],
92
- errorMessageAdd: '',
93
- filterQuery: '',
94
- aliases: [],
95
- activePage: 1,
96
- pages: '',
97
- itemsPerPage: 10,
98
- };
99
- }
125
+ // workaround for Volto 16 backport, which doesn't have getSite action
126
+ const [site, setSite] = useState({});
127
+ useEffect(() => {
128
+ dispatch({ type: 'GET_SITE', request: { op: 'get', path: '/@site' } })
129
+ .then((resp) => setSite(resp))
130
+ .catch((err) => {});
131
+ }, [dispatch]);
132
+ const hasAdvancedFiltering = site.features?.filter_aliases_by_date;
133
+ const hasBulkUpload = hasAdvancedFiltering !== undefined;
100
134
 
101
- /**
102
- * Component did mount
103
- * @method componentDidMount
104
- * @returns {undefined}
105
- */
106
- componentDidMount() {
107
- const { filterQuery, filterType, createdBefore, itemsPerPage } = this.state;
108
- this.setState({ isClient: true });
109
- this.props.getAliases(getBaseUrl(this.props.pathname), {
135
+ const aliases = useSelector((state) => state.aliases);
136
+ const [filterType, setFilterType] = useState(filterChoices[0]);
137
+ const [createdBefore, setCreatedBefore] = useState(null);
138
+ const [createdAfter, setCreatedAfter] = useState(null);
139
+ const [aliasesToRemove, setAliasesToRemove] = useState([]);
140
+ const [filterQuery, setFilterQuery] = useState('');
141
+ const [activePage, setActivePage] = useState(1);
142
+ const [itemsPerPage, setItemsPerPage] = useState(10);
143
+ const [addModalOpen, setAddModalOpen] = useState(false);
144
+ const [addError, setAddError] = useState(null);
145
+ const [editingData, setEditingData] = useState(null);
146
+ const [uploadModalOpen, setUploadModalOpen] = useState(false);
147
+ const [uploadError, setUploadError] = useState(null);
148
+
149
+ const updateResults = useCallback(() => {
150
+ const options = {
110
151
  query: filterQuery,
111
152
  manual: filterType.value,
112
- datetime: createdBefore,
113
- batchSize: itemsPerPage,
114
- });
115
- }
153
+ batchStart: (activePage - 1) * itemsPerPage,
154
+ batchSize: itemsPerPage === 'All' ? 999999999999 : itemsPerPage,
155
+ };
156
+ if (hasAdvancedFiltering) {
157
+ options.start = createdAfter || '';
158
+ options.end = createdBefore || '';
159
+ } else {
160
+ options.datetime = createdBefore || '';
161
+ }
162
+ dispatch(getAliases(getBaseUrl(pathname), options));
163
+ }, [
164
+ activePage,
165
+ createdAfter,
166
+ createdBefore,
167
+ dispatch,
168
+ filterQuery,
169
+ filterType.value,
170
+ hasAdvancedFiltering,
171
+ itemsPerPage,
172
+ pathname,
173
+ ]);
116
174
 
117
- /**
118
- * Component did mount
119
- * @method componentDidUpdate
120
- * @returns {undefined}
121
- */
122
- componentDidUpdate(prevProps, prevState) {
123
- const { filterQuery, filterType, createdBefore, itemsPerPage } = this.state;
124
- if (
125
- prevProps.aliases.items_total !== this.props.aliases.items_total ||
126
- prevState.itemsPerPage !== this.state.itemsPerPage
127
- ) {
128
- const pages = Math.ceil(
129
- this.props.aliases.items_total / this.state.itemsPerPage,
130
- );
175
+ // Update results after changing the page.
176
+ // (We intentionally leave updateResults out of the deps.)
177
+ // eslint-disable-next-line react-hooks/exhaustive-deps
178
+ useEffect(() => updateResults(), [activePage, itemsPerPage]);
131
179
 
132
- if (pages === 0 || isNaN(pages)) {
133
- this.setState({ pages: '' });
134
- } else {
135
- this.setState({ pages });
136
- }
180
+ // Calculate page count from results
181
+ const pages = useMemo(() => {
182
+ let pages = Math.ceil(aliases.items_total / itemsPerPage);
183
+ if (pages === 0 || isNaN(pages)) {
184
+ pages = '';
137
185
  }
138
- if (
139
- prevState.activePage !== this.state.activePage ||
140
- prevState.itemsPerPage !== this.state.itemsPerPage
141
- ) {
142
- this.props.getAliases(getBaseUrl(this.props.pathname), {
143
- query: filterQuery,
144
- manual: filterType.value,
145
- datetime: createdBefore,
146
- batchSize: itemsPerPage === 'All' ? 999999999999 : itemsPerPage,
147
- batchStart: (this.state.activePage - 1) * this.state.itemsPerPage,
148
- });
149
- }
150
- if (prevState.altUrlPath !== this.state.altUrlPath) {
151
- if (this.state.altUrlPath.charAt(0) === '/') {
152
- this.setState({ isAltUrlCorrect: true });
153
- } else {
154
- this.setState({ isAltUrlCorrect: false });
155
- }
156
- }
157
- }
186
+ return pages;
187
+ }, [aliases.items_total, itemsPerPage]);
158
188
 
159
- /**
160
- * Component will receive props
161
- * @method componentWillReceiveProps
162
- * @param {Object} nextProps Next properties
163
- * @returns {undefined}
164
- */
165
- UNSAFE_componentWillReceiveProps(nextProps) {
166
- if (this.props.aliases.add.loading && !nextProps.aliases.add.loaded) {
167
- if (nextProps.aliases.add.error) {
168
- this.setState({
169
- errorMessageAdd: nextProps.aliases.add.error.response.body.message,
189
+ // Add new alias
190
+ const handleAdd = (formData) => {
191
+ const { altUrlPath, targetUrlPath } = formData;
192
+ // Validate altUrlPath starts with a slash
193
+ if (!altUrlPath || altUrlPath.charAt(0) !== '/') {
194
+ setAddError(intl.formatMessage(messages.altUrlError));
195
+ return;
196
+ }
197
+ // Remove existing alias first if we're editing it.
198
+ const precondition = editingData
199
+ ? dispatch(
200
+ removeAliases('', { items: [{ path: editingData.altUrlPath }] }),
201
+ )
202
+ : Promise.resolve();
203
+ precondition.then(() => {
204
+ dispatch(
205
+ addAliases('', {
206
+ items: [{ path: altUrlPath, 'redirect-to': targetUrlPath }],
207
+ }),
208
+ )
209
+ .then(() => {
210
+ updateResults();
211
+ setAddModalOpen(false);
212
+ setEditingData(null);
213
+ toast.success(
214
+ <Toast
215
+ success
216
+ title={intl.formatMessage(messages.success)}
217
+ content={intl.formatMessage(messages.successAdd)}
218
+ />,
219
+ );
220
+ })
221
+ .catch((error) => {
222
+ setAddError(error.response?.body?.message);
170
223
  });
171
- }
224
+ });
225
+ };
226
+
227
+ // Check/uncheck an alias
228
+ const handleCheckAlias = (alias) => {
229
+ if (aliasesToRemove.includes(alias)) {
230
+ setAliasesToRemove(aliasesToRemove.filter((x) => x !== alias));
231
+ } else {
232
+ setAliasesToRemove([...aliasesToRemove, alias]);
172
233
  }
173
- if (this.props.aliases.add.loading && nextProps.aliases.add.loaded) {
174
- const {
175
- filterQuery,
176
- filterType,
177
- createdBefore,
178
- itemsPerPage,
179
- } = this.state;
234
+ };
180
235
 
181
- this.props.getAliases(getBaseUrl(this.props.pathname), {
182
- query: filterQuery,
183
- manual: filterType.value,
184
- datetime: createdBefore,
185
- batchSize: itemsPerPage,
186
- });
236
+ // Remove selected aliases
237
+ const handleRemoveAliases = () => {
238
+ dispatch(
239
+ removeAliases('', {
240
+ items: aliasesToRemove.map((a) => ({ path: a })),
241
+ }),
242
+ ).then(() => {
243
+ updateResults();
187
244
  toast.success(
188
245
  <Toast
189
246
  success
190
- title={this.props.intl.formatMessage(messages.success)}
191
- content={this.props.intl.formatMessage(messages.successAdd)}
247
+ title={intl.formatMessage(messages.success)}
248
+ content={intl.formatMessage(messages.successRemove)}
192
249
  />,
193
250
  );
194
- if (!nextProps.aliases.add.error) {
195
- this.setState({
196
- errorMessageAdd: '',
197
- });
198
- }
199
- }
200
- if (this.props.aliases.remove.loading && nextProps.aliases.remove.loaded) {
201
- const {
202
- filterQuery,
203
- filterType,
204
- createdBefore,
205
- itemsPerPage,
206
- } = this.state;
207
-
208
- this.props.getAliases(getBaseUrl(this.props.pathname), {
209
- query: filterQuery,
210
- manual: filterType.value,
211
- datetime: createdBefore,
212
- batchSize: itemsPerPage,
213
- });
214
- }
215
- }
216
-
217
- /**
218
- * Back/Cancel handler
219
- * @method onCancel
220
- * @returns {undefined}
221
- */
222
- onCancel() {
223
- this.props.history.push(getParentUrl(this.props.pathname));
224
- }
225
-
226
- /**
227
- * Select filter type handler
228
- * @method handleSelectFilterType
229
- * @returns {undefined}
230
- */
231
- handleSelectFilterType(type) {
232
- this.setState({ filterType: type });
233
- }
234
-
235
- /**
236
- * Select filter type handler
237
- * @method handleFilterQueryChange
238
- * @returns {undefined}
239
- */
240
- handleFilterQueryChange(query) {
241
- this.setState({ filterQuery: query });
242
- }
243
-
244
- /**
245
- * Select Creation date handler
246
- * @method handleCreateDate
247
- * @returns {undefined}
248
- */
249
- handleCreateDate(date) {
250
- this.setState({ createdBefore: date });
251
- }
252
-
253
- /**
254
- * Select Creation date handler
255
- * @method handleSubmitFilter
256
- * @returns {undefined}
257
- */
258
- handleSubmitFilter() {
259
- const { filterQuery, filterType, createdBefore, itemsPerPage } = this.state;
260
- this.props.getAliases(getBaseUrl(this.props.pathname), {
261
- query: filterQuery,
262
- manual: filterType.value,
263
- datetime: createdBefore,
264
- batchSize: itemsPerPage,
265
251
  });
266
- }
267
-
268
- /**
269
- * Alternative url handler
270
- * @method handleAltUrlChange
271
- * @returns {undefined}
272
- */
273
- handleAltUrlChange(url) {
274
- this.setState({ altUrlPath: url });
275
- }
276
-
277
- /**
278
- * Target url handler
279
- * @method handleTargetUrlChange
280
- * @returns {undefined}
281
- */
282
- handleTargetUrlChange(url) {
283
- this.setState({ targetUrlPath: url });
284
- }
285
-
286
- /**
287
- * New alias submit handler
288
- * @method handleSubmitAlias
289
- * @returns {undefined}
290
- */
291
- handleSubmitAlias() {
292
- if (this.state.isAltUrlCorrect) {
293
- this.props.addAliases('', {
294
- items: [
295
- {
296
- path: this.state.altUrlPath,
297
- 'redirect-to': this.state.targetUrlPath,
298
- },
299
- ],
300
- });
301
- this.setState({ altUrlPath: '', targetUrlPath: '' });
302
- }
303
- }
252
+ setAliasesToRemove([]);
253
+ };
304
254
 
305
- /**
306
- * Check to-remove aliases handler
307
- * @method handleSubmitAlias
308
- * @returns {undefined}
309
- */
310
- handleCheckAlias(alias) {
311
- const aliases = this.state.aliasesToRemove;
312
- if (aliases.includes(alias)) {
313
- const index = aliases.indexOf(alias);
314
- if (index > -1) {
315
- let newAliasesArr = aliases;
316
- newAliasesArr.splice(index, 1);
317
- this.setState({ aliasesToRemove: newAliasesArr });
318
- }
319
- } else {
320
- this.setState({
321
- aliasesToRemove: [...this.state.aliasesToRemove, alias],
255
+ // Upload CSV
256
+ const handleBulkUpload = (formData) => {
257
+ fetch(`data:${formData.file['content-type']};base64,${formData.file.data}`)
258
+ .then((res) => res.blob())
259
+ .then((blob) => {
260
+ dispatch(uploadAliases(blob))
261
+ .then(() => {
262
+ updateResults();
263
+ setUploadError(null);
264
+ setUploadModalOpen(false);
265
+ toast.success(
266
+ <Toast
267
+ success
268
+ title={intl.formatMessage(messages.success)}
269
+ content={intl.formatMessage(messages.successUpload)}
270
+ />,
271
+ );
272
+ })
273
+ .catch((error) => {
274
+ setUploadError(error.response?.body?.message);
275
+ });
322
276
  });
323
- }
324
- }
325
-
326
- /**
327
- * Remove aliases handler
328
- * @method handleRemoveAliases
329
- * @returns {undefined}
330
- */
331
- handleRemoveAliases = () => {
332
- const items = this.state.aliasesToRemove.map((a) => {
333
- return {
334
- path: a,
335
- };
336
- });
337
- this.props.removeAliases('', {
338
- items,
339
- });
340
- this.setState({ aliasesToRemove: [] });
341
277
  };
342
278
 
343
- /**
344
- * Pagination change handler
345
- * @method handlePageChange
346
- * @returns {undefined}
347
- */
348
- handlePageChange(e, { activePage }) {
349
- this.setState({ activePage });
350
- }
351
-
352
- /**
353
- * Items per page change handler
354
- * @method handleItemsPerPage
355
- * @returns {undefined}
356
- */
357
- handleItemsPerPage(e, { value }) {
358
- this.setState({ itemsPerPage: value, activePage: 1 });
359
- }
360
-
361
- /**
362
- * Render method.
363
- * @method render
364
- * @returns {string} Markup for the component.
365
- */
366
- render() {
367
- return (
368
- <div id="page-aliases">
369
- <Helmet title={this.props.intl.formatMessage(messages.aliases)} />
370
- <Container>
371
- <article id="content">
372
- <Segment.Group raised>
373
- <Segment className="primary">
374
- <FormattedMessage
375
- id="URL Management"
376
- defaultMessage="URL Management"
377
- values={{ title: <q>{this.props.title}</q> }}
378
- />
379
- </Segment>
380
- <Form>
381
- <Segment>
382
- <Header size="medium">
383
- <FormattedMessage
384
- id="Alternative url path (Required)"
385
- defaultMessage="Alternative url path (Required)"
386
- />
387
- </Header>
388
- <p className="help">
389
- <FormattedMessage
390
- id="Enter the absolute path where the alternative url should exist. The path must start with '/'. Only urls that result in a 404 not found page will result in a redirect occurring."
391
- defaultMessage="Enter the absolute path where the alternative url should exist. The path must start with '/'. Only urls that result in a 404 not found page will result in a redirect occurring."
392
- />
393
- </p>
394
- <Form.Field>
395
- <Input
396
- id="alternative-url-input"
397
- name="alternative-url-path"
398
- placeholder="/example"
399
- value={this.state.altUrlPath}
400
- onChange={(e) => this.handleAltUrlChange(e.target.value)}
401
- />
402
- {!this.state.isAltUrlCorrect &&
403
- this.state.altUrlPath !== '' && (
404
- <p style={{ color: 'red' }}>
279
+ return (
280
+ <div id="page-aliases">
281
+ <Helmet title={intl.formatMessage(messages.aliases)} />
282
+ <Container>
283
+ <article id="content">
284
+ <Segment.Group raised>
285
+ <Segment className="primary">
286
+ <FormattedMessage
287
+ id="URL Management"
288
+ defaultMessage="URL Management"
289
+ values={{ title: <q>{title}</q> }}
290
+ />
291
+ </Segment>
292
+ <Segment>
293
+ <Button
294
+ primary
295
+ id="add-alt-url"
296
+ onClick={() => setAddModalOpen(true)}
297
+ >
298
+ {intl.formatMessage(messages.AddUrl)}&hellip;
299
+ </Button>
300
+ {addModalOpen && (
301
+ <ModalForm
302
+ open={true}
303
+ onSubmit={handleAdd}
304
+ onCancel={() => setAddModalOpen(false)}
305
+ title={
306
+ editingData
307
+ ? intl.formatMessage(messages.EditUrl)
308
+ : intl.formatMessage(messages.AddUrl)
309
+ }
310
+ submitError={addError}
311
+ schema={{
312
+ fieldsets: [
313
+ {
314
+ id: 'default',
315
+ fields: ['altUrlPath', 'targetUrlPath'],
316
+ },
317
+ ],
318
+ properties: {
319
+ altUrlPath: {
320
+ title: intl.formatMessage(messages.altUrlPathTitle),
321
+ description: (
405
322
  <FormattedMessage
406
- id="Alternative url path must start with a slash."
407
- defaultMessage="Alternative url path must start with a slash."
323
+ id="Enter the absolute path where the alternative url should exist. The path must start with '/'. Only URLs that result in a 404 not found page will result in a redirect occurring."
324
+ defaultMessage="Enter the absolute path where the alternative URL should exist. The path must start with '/'. Only URLs that result in a 404 not found page will result in a redirect occurring."
408
325
  />
409
- </p>
410
- )}
411
- </Form.Field>
412
- <Header size="medium">
413
- <FormattedMessage
414
- id="Target Path (Required)"
415
- defaultMessage="Target Path (Required)"
416
- />
417
- </Header>
418
- <p className="help">
419
- <FormattedMessage
420
- id="Enter the absolute path of the target. Target must exist or be an existing alternative url path to the target."
421
- defaultMessage="Enter the absolute path of the target. Target must exist or be an existing alternative url path to the target."
422
- />
423
- </p>
424
- <Form.Field>
425
- <Input
426
- id="target-url-input"
427
- name="target-url-path"
428
- placeholder="/example"
429
- value={this.state.targetUrlPath}
430
- onChange={(e) =>
431
- this.handleTargetUrlChange(e.target.value)
326
+ ),
327
+ placeholder: '/example',
328
+ },
329
+ targetUrlPath: {
330
+ title: intl.formatMessage(messages.targetUrlPathTitle),
331
+ description: (
332
+ <FormattedMessage
333
+ id="Enter the absolute path of the target. Target must exist or be an existing alternative url path to the target."
334
+ defaultMessage="Enter the absolute path of the target. Target must exist or be an existing alternative URL path to the target."
335
+ />
336
+ ),
337
+ placeholder: '/example',
338
+ },
339
+ },
340
+ required: ['altUrlPath', 'targetUrlPath'],
341
+ }}
342
+ formData={editingData || {}}
343
+ />
344
+ )}
345
+ {hasBulkUpload && (
346
+ <>
347
+ <Button onClick={() => setUploadModalOpen(true)}>
348
+ {intl.formatMessage(messages.BulkUploadAltUrls)}&hellip;
349
+ </Button>
350
+ {uploadModalOpen && (
351
+ <ModalForm
352
+ open={true}
353
+ onSubmit={handleBulkUpload}
354
+ onCancel={() => setUploadModalOpen(false)}
355
+ title={intl.formatMessage(messages.BulkUploadAltUrls)}
356
+ submitError={uploadError}
357
+ description={
358
+ <>
359
+ <p>
360
+ <FormattedMessage
361
+ id="bulkUploadUrlsHelp"
362
+ defaultMessage="Add many alternative URLs at once by uploading a CSV file. The first column should be the path to redirect from; the second, the path to redirect to. Both paths must be Plone-site-relative, starting with a slash (/). An optional third column can contain a date and time. An optional fourth column can contain a boolean to mark as a manual redirect (default true)."
363
+ />
364
+ </p>
365
+ <p>
366
+ Example:
367
+ <br />
368
+ <code>
369
+ /old-home-page.asp,/front-page,2019/01/27 10:42:59
370
+ GMT+1,true
371
+ <br />
372
+ /people/JoeT,/Users/joe-thurston,2018-12-31,false
373
+ </code>
374
+ </p>
375
+ </>
432
376
  }
377
+ schema={{
378
+ fieldsets: [
379
+ {
380
+ id: 'default',
381
+ fields: ['file'],
382
+ },
383
+ ],
384
+ properties: {
385
+ file: {
386
+ title: intl.formatMessage(messages.CSVFile),
387
+ type: 'object',
388
+ factory: 'File Upload',
389
+ },
390
+ },
391
+ required: ['file'],
392
+ }}
433
393
  />
434
- </Form.Field>
435
- <Button
436
- id="submit-alias"
437
- primary
438
- onClick={() => this.handleSubmitAlias()}
439
- disabled={
440
- !this.state.isAltUrlCorrect ||
441
- this.state.altUrlPath === '' ||
442
- this.state.targetUrlPath === ''
443
- }
444
- >
445
- <FormattedMessage id="Add" defaultMessage="Add" />
446
- </Button>
447
- {this.state.errorMessageAdd && (
448
- <Message color="red">
449
- <Message.Header>
450
- <FormattedMessage
451
- id="ErrorHeader"
452
- defaultMessage="Error"
453
- />
454
- </Message.Header>
455
- <p>{this.state.errorMessageAdd}</p>
456
- </Message>
457
394
  )}
458
- </Segment>
459
- </Form>
395
+ </>
396
+ )}
397
+ </Segment>
398
+ <Segment>
460
399
  <Form>
461
- <Segment className="primary">
462
- <Header size="medium">
463
- <FormattedMessage
464
- id="All existing alternative urls for this site"
465
- defaultMessage="All existing alternative urls for this site"
466
- />
467
- </Header>
468
- <Header size="small">
469
- <FormattedMessage
470
- id="Filter by prefix"
471
- defaultMessage="Filter by prefix"
472
- />
473
- </Header>
400
+ <Header size="medium">
401
+ <FormattedMessage
402
+ id="All existing alternative urls for this site"
403
+ defaultMessage="Existing alternative URLs for this site"
404
+ />
405
+ </Header>
406
+ <Segment>
474
407
  <Form.Field>
475
- <Input
476
- name="filter"
477
- placeholder="/example"
478
- value={this.state.filterQuery}
479
- onChange={(e) =>
480
- this.handleFilterQueryChange(e.target.value)
481
- }
482
- />
483
- </Form.Field>
484
- <Header size="small">
485
- <FormattedMessage
486
- id="Manually or automatically added?"
487
- defaultMessage="Manually or automatically added?"
488
- />
489
- </Header>
490
- {filterChoices.map((o, i) => (
491
- <Form.Field key={i}>
492
- <Radio
493
- label={o.label}
494
- name="radioGroup"
495
- value={o.value}
496
- checked={this.state.filterType === o}
497
- onChange={() => this.handleSelectFilterType(o)}
408
+ <FormFieldWrapper
409
+ id="filterQuery"
410
+ title={intl.formatMessage(messages.filterByPrefix)}
411
+ >
412
+ <Input
413
+ name="filter"
414
+ placeholder="/example"
415
+ value={filterQuery}
416
+ onChange={(e) => setFilterQuery(e.target.value)}
498
417
  />
499
- </Form.Field>
500
- ))}
418
+ </FormFieldWrapper>
419
+ </Form.Field>
420
+ <Form.Field>
421
+ <FormFieldWrapper
422
+ id="filterType"
423
+ title={intl.formatMessage(messages.manualOrAuto)}
424
+ >
425
+ <Form.Group inline>
426
+ {filterChoices.map((o, i) => (
427
+ <Form.Field key={i}>
428
+ <Radio
429
+ label={o.label}
430
+ name="radioGroup"
431
+ value={o.value}
432
+ checked={filterType === o}
433
+ onChange={() => setFilterType(o)}
434
+ />
435
+ </Form.Field>
436
+ ))}
437
+ </Form.Group>
438
+ </FormFieldWrapper>
439
+ </Form.Field>
501
440
  <Form.Field>
502
441
  <DatetimeWidget
503
442
  id="created-before-date"
504
- title={'Created before'}
443
+ title={intl.formatMessage(messages.createdBefore)}
505
444
  dateOnly={true}
506
- value={this.state.createdBefore}
445
+ value={createdBefore}
507
446
  onChange={(id, value) => {
508
- this.handleCreateDate(value);
447
+ setCreatedBefore(value);
509
448
  }}
510
449
  />
511
450
  </Form.Field>
512
- <Button onClick={() => this.handleSubmitFilter()} primary>
451
+ {hasAdvancedFiltering && (
452
+ <Form.Field>
453
+ <DatetimeWidget
454
+ id="created-after-date"
455
+ title={intl.formatMessage(messages.createdAfter)}
456
+ dateOnly={true}
457
+ value={createdAfter}
458
+ onChange={(id, value) => {
459
+ setCreatedAfter(value);
460
+ }}
461
+ />
462
+ </Form.Field>
463
+ )}
464
+ <Button onClick={() => updateResults()} primary>
513
465
  Filter
514
466
  </Button>
515
- <Header size="small">
516
- <FormattedMessage
517
- id="Alternative url path → target url path (date and time of creation, manually created yes/no)"
518
- defaultMessage="Alternative url path → target url path (date and time of creation, manually created yes/no)"
519
- />
520
- </Header>
467
+ </Segment>
468
+ </Form>
469
+ </Segment>
470
+ <Segment>
471
+ <Header size="small">
472
+ <FormattedMessage
473
+ id="Alternative url path → target url path (date and time of creation, manually created yes/no)"
474
+ defaultMessage="Alternative URL path → target URL path (date and time of creation, manually created yes/no)"
475
+ />
476
+ </Header>
521
477
 
522
- <Table>
523
- <Table.Body>
524
- <Table.Row>
525
- <Table.HeaderCell>
526
- <FormattedMessage
527
- id="Select"
528
- defaultMessage="Select"
529
- />
530
- </Table.HeaderCell>
531
- <Table.HeaderCell>
532
- <FormattedMessage id="Alias" defaultMessage="Alias" />
533
- </Table.HeaderCell>
534
- <Table.HeaderCell>
535
- <FormattedMessage
536
- id="Target"
537
- defaultMessage="Target"
538
- />
539
- </Table.HeaderCell>
540
- <Table.HeaderCell>
541
- <FormattedMessage id="Date" defaultMessage="Date" />
542
- </Table.HeaderCell>
543
- <Table.HeaderCell>
544
- <FormattedMessage
545
- id="Manual"
546
- defaultMessage="Manual"
478
+ <Table celled compact>
479
+ <Table.Header>
480
+ <Table.Row>
481
+ <Table.HeaderCell width="1">
482
+ <FormattedMessage id="Select" defaultMessage="Select" />
483
+ </Table.HeaderCell>
484
+ <Table.HeaderCell width="10">
485
+ <FormattedMessage id="Alias" defaultMessage="Alias" />
486
+ </Table.HeaderCell>
487
+ <Table.HeaderCell width="1">
488
+ <FormattedMessage id="Date" defaultMessage="Date" />
489
+ </Table.HeaderCell>
490
+ <Table.HeaderCell width="1">
491
+ <FormattedMessage id="Manual" defaultMessage="Manual" />
492
+ </Table.HeaderCell>
493
+ </Table.Row>
494
+ </Table.Header>
495
+ <Table.Body>
496
+ {aliases.get.loading && (
497
+ <Table.Row>
498
+ <Table.Cell colSpan="4">
499
+ <Loader active inline="centered" />
500
+ </Table.Cell>
501
+ </Table.Row>
502
+ )}
503
+ {aliases.items.length > 0 &&
504
+ aliases.items.map((alias, i) => (
505
+ <Table.Row key={i} verticalAlign="top">
506
+ <Table.Cell>
507
+ <Checkbox
508
+ onChange={(e, { value }) => handleCheckAlias(value)}
509
+ checked={aliasesToRemove.includes(alias.path)}
510
+ value={alias.path}
547
511
  />
548
- </Table.HeaderCell>
512
+ </Table.Cell>
513
+ <Table.Cell>
514
+ {alias.path}
515
+ <br />
516
+ &nbsp;&nbsp;&rarr; {alias['redirect-to']}{' '}
517
+ <Button
518
+ basic
519
+ style={{ verticalAlign: 'middle' }}
520
+ aria-label={intl.formatMessage(messages.EditUrl)}
521
+ onClick={() => {
522
+ setEditingData({
523
+ altUrlPath: alias.path,
524
+ targetUrlPath: alias['redirect-to'],
525
+ });
526
+ setAddModalOpen(true);
527
+ }}
528
+ >
529
+ <Icon name={editingSVG} size="18px" />
530
+ </Button>
531
+ </Table.Cell>
532
+ <Table.Cell>
533
+ <FormattedDate date={alias.datetime} />
534
+ </Table.Cell>
535
+ <Table.Cell>{`${alias.manual}`}</Table.Cell>
549
536
  </Table.Row>
550
- {this.props.aliases.items.length > 0 &&
551
- this.props.aliases.items.map((alias, i) => (
552
- <Table.Row key={i}>
553
- <Table.Cell>
554
- <Checkbox
555
- onChange={(e, { value }) =>
556
- this.handleCheckAlias(value)
557
- }
558
- checked={this.state.aliasesToRemove.includes(
559
- alias.path,
560
- )}
561
- value={alias.path}
562
- />
563
- </Table.Cell>
564
- <Table.Cell>
565
- <p>{alias.path}</p>
566
- </Table.Cell>
567
- <Table.Cell>
568
- <p>{alias['redirect-to']}</p>
569
- </Table.Cell>
570
- <Table.Cell>
571
- <p>{alias.datetime}</p>
572
- </Table.Cell>
573
- <Table.Cell>
574
- <p>{`${alias.manual}`}</p>
575
- </Table.Cell>
576
- </Table.Row>
577
- ))}
578
- </Table.Body>
579
- </Table>
580
- <div
581
- style={{
582
- display: 'flex',
583
- flexWrap: 'wrap',
584
- alignItems: 'center',
585
- marginBottom: '20px',
586
- }}
587
- >
588
- {this.state.pages && (
589
- <Pagination
590
- boundaryRange={0}
591
- activePage={this.state.activePage}
592
- ellipsisItem={null}
593
- firstItem={null}
594
- lastItem={null}
595
- siblingRange={1}
596
- totalPages={this.state.pages}
597
- onPageChange={(e, o) => this.handlePageChange(e, o)}
598
- />
599
- )}
600
- <Menu.Menu
601
- position="right"
602
- style={{ display: 'flex', marginLeft: 'auto' }}
603
- >
604
- <Menu.Item style={{ color: 'grey' }}>
605
- <FormattedMessage id="Show" defaultMessage="Show" />:
606
- </Menu.Item>
607
- {map(itemsPerPageChoices, (size) => (
608
- <Menu.Item
609
- style={{
610
- padding: '0 0.4em',
611
- margin: '0em 0.357em',
612
- cursor: 'pointer',
613
- }}
614
- key={size}
615
- value={size}
616
- active={size === this.state.itemsPerPage}
617
- onClick={(e, o) => this.handleItemsPerPage(e, o)}
618
- >
619
- {size}
620
- </Menu.Item>
621
- ))}
622
- </Menu.Menu>
623
- </div>
624
- <Button
625
- disabled={this.state.aliasesToRemove.length === 0}
626
- onClick={this.handleRemoveAliases}
627
- primary
628
- >
629
- <FormattedMessage
630
- id="Remove selected"
631
- defaultMessage="Remove selected"
632
- />
633
- </Button>
634
- </Segment>
635
- </Form>
636
- </Segment.Group>
637
- </article>
638
- </Container>
639
- {this.state.isClient && (
640
- <Portal node={document.getElementById('toolbar')}>
641
- <Toolbar
642
- pathname={this.props.pathname}
643
- hideDefaultViewButtons
644
- inner={
645
- <Link className="item" to="#" onClick={() => this.onCancel()}>
646
- <Icon
647
- name={backSVG}
648
- className="contents circled"
649
- size="30px"
650
- title={this.props.intl.formatMessage(messages.back)}
537
+ ))}
538
+ </Table.Body>
539
+ </Table>
540
+ <div
541
+ style={{
542
+ display: 'flex',
543
+ flexWrap: 'wrap',
544
+ alignItems: 'center',
545
+ marginBottom: '20px',
546
+ }}
547
+ >
548
+ {pages && (
549
+ <Pagination
550
+ boundaryRange={0}
551
+ activePage={activePage}
552
+ ellipsisItem={null}
553
+ firstItem={null}
554
+ lastItem={null}
555
+ siblingRange={1}
556
+ totalPages={pages}
557
+ onPageChange={(e, { activePage }) =>
558
+ setActivePage(activePage)
559
+ }
651
560
  />
652
- </Link>
653
- }
654
- />
655
- </Portal>
656
- )}
657
- </div>
658
- );
659
- }
660
- }
561
+ )}
562
+ <Menu.Menu
563
+ position="right"
564
+ style={{ display: 'flex', marginLeft: 'auto' }}
565
+ >
566
+ <Menu.Item style={{ color: 'grey' }}>
567
+ <FormattedMessage id="Show" defaultMessage="Show" />:
568
+ </Menu.Item>
569
+ {map(itemsPerPageChoices, (size) => (
570
+ <Menu.Item
571
+ style={{
572
+ padding: '0 0.4em',
573
+ margin: '0em 0.357em',
574
+ cursor: 'pointer',
575
+ }}
576
+ key={size}
577
+ value={size}
578
+ active={size === itemsPerPage}
579
+ onClick={(e, { value }) => {
580
+ setItemsPerPage(value);
581
+ setActivePage(1);
582
+ }}
583
+ >
584
+ {size}
585
+ </Menu.Item>
586
+ ))}
587
+ </Menu.Menu>
588
+ </div>
589
+ <Button
590
+ id="remove-alt-urls"
591
+ disabled={aliasesToRemove.length === 0}
592
+ onClick={handleRemoveAliases}
593
+ primary
594
+ >
595
+ <FormattedMessage
596
+ id="Remove selected"
597
+ defaultMessage="Remove selected"
598
+ />
599
+ </Button>
600
+ </Segment>
601
+ </Segment.Group>
602
+ </article>
603
+ </Container>
604
+ {__CLIENT__ && (
605
+ <Portal node={document.getElementById('toolbar')}>
606
+ <Toolbar
607
+ pathname={pathname}
608
+ hideDefaultViewButtons
609
+ inner={
610
+ <Link
611
+ className="item"
612
+ to="#"
613
+ onClick={() => history.push(getParentUrl(pathname))}
614
+ >
615
+ <Icon
616
+ name={backSVG}
617
+ className="contents circled"
618
+ size="30px"
619
+ title={intl.formatMessage(messages.back)}
620
+ />
621
+ </Link>
622
+ }
623
+ />
624
+ </Portal>
625
+ )}
626
+ </div>
627
+ );
628
+ };
661
629
 
662
- export default compose(
663
- injectIntl,
664
- connect(
665
- (state, props) => ({
666
- aliases: state.aliases,
667
- pathname: props.location.pathname,
668
- }),
669
- { addAliases, getAliases, removeAliases },
670
- ),
671
- )(Aliases);
630
+ export default Aliases;