@plone/volto
Version:
Volto
666 lines (649 loc) • 24.1 kB
JSX
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useHistory, useLocation } from 'react-router-dom';
import { getBaseUrl, getParentUrl } from '@plone/volto/helpers/Url/Url';
import Helmet from '@plone/volto/helpers/Helmet/Helmet';
import {
removeAliases,
addAliases,
getAliases,
uploadAliases,
} from '@plone/volto/actions/aliases/aliases';
import { createPortal } from 'react-dom';
import {
Button,
Checkbox,
Container,
Form,
Header,
Input,
Loader,
Menu,
Pagination,
Radio,
Segment,
Table,
} from 'semantic-ui-react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import DatetimeWidget from '@plone/volto/components/manage/Widgets/DatetimeWidget';
import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper';
import { ModalForm } from '@plone/volto/components/manage/Form';
import Icon from '@plone/volto/components/theme/Icon/Icon';
import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
import Toast from '@plone/volto/components/manage/Toast/Toast';
import FormattedDate from '@plone/volto/components/theme/FormattedDate/FormattedDate';
import { useClient } from '@plone/volto/hooks/client/useClient';
import backSVG from '@plone/volto/icons/back.svg';
import editingSVG from '@plone/volto/icons/editing.svg';
import map from 'lodash/map';
import { toast } from 'react-toastify';
const messages = defineMessages({
back: {
id: 'Back',
defaultMessage: 'Back',
},
aliases: {
id: 'URL Management',
defaultMessage: 'URL Management',
},
AddUrl: {
id: 'Add Alternative URL',
defaultMessage: 'Add Alternative URL',
},
EditUrl: {
id: 'Edit Alternative URL',
defaultMessage: 'Edit Alternative URL',
},
success: {
id: 'Success',
defaultMessage: 'Success',
},
successAdd: {
id: 'Alias has been added',
defaultMessage: 'Alias has been added',
},
successUpload: {
id: 'Aliases have been uploaded.',
defaultMessage: 'Aliases have been uploaded.',
},
successRemove: {
id: 'Aliases have been removed.',
defaultMessage: 'Aliases have been removed.',
},
filterByPrefix: {
id: 'Filter by prefix',
defaultMessage: 'Filter by path',
},
manualOrAuto: {
id: 'Manually or automatically added?',
defaultMessage: 'Manually or automatically added?',
},
createdAfter: {
id: 'Created after',
defaultMessage: 'Created after',
},
createdBefore: {
id: 'Created before',
defaultMessage: 'Created before',
},
altUrlPathTitle: {
id: 'Alternative url path (Required)',
defaultMessage: 'Alternative URL path (Required)',
},
altUrlError: {
id: 'Alternative url path must start with a slash.',
defaultMessage: 'Alternative URL path must start with a slash.',
},
targetUrlPathTitle: {
id: 'Target Path (Required)',
defaultMessage: 'Target Path (Required)',
},
BulkUploadAltUrls: {
id: 'BulkUploadAltUrls',
defaultMessage: 'Bulk upload CSV',
},
CSVFile: {
id: 'CSVFile',
defaultMessage: 'CSV file',
},
Both: {
id: 'Both',
defaultMessage: 'Both',
},
Automatically: {
id: 'Automatically',
defaultMessage: 'Automatically',
},
Manually: {
id: 'Manually',
defaultMessage: 'Manually',
},
examplePath: {
id: 'examplePath',
defaultMessage: '/example',
},
});
const filterChoices = [
{ label: 'Both', value: '' },
{ label: 'Automatically', value: 'no' },
{ label: 'Manually', value: 'yes' },
];
const itemsPerPageChoices = [10, 25, 50, 'All'];
const Aliases = (props) => {
const title = props;
const intl = useIntl();
const dispatch = useDispatch();
const { pathname } = useLocation();
const history = useHistory();
const hasAdvancedFiltering = useSelector(
(state) => state.site.data?.features?.filter_aliases_by_date,
);
const hasBulkUpload = hasAdvancedFiltering !== undefined;
const aliases = useSelector((state) => state.aliases);
const [filterType, setFilterType] = useState(filterChoices[0]);
const [createdBefore, setCreatedBefore] = useState(null);
const [createdAfter, setCreatedAfter] = useState(null);
const [aliasesToRemove, setAliasesToRemove] = useState([]);
const [filterQuery, setFilterQuery] = useState('');
const [activePage, setActivePage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [addModalOpen, setAddModalOpen] = useState(false);
const [addError, setAddError] = useState(null);
const [editingData, setEditingData] = useState(null);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadError, setUploadError] = useState(null);
const [csvErrors, setCSVErrors] = useState([]);
const isClient = useClient();
const updateResults = useCallback(() => {
const options = {
query: filterQuery,
manual: filterType.value,
batchStart: (activePage - 1) * itemsPerPage,
batchSize: itemsPerPage === 'All' ? 999999999999 : itemsPerPage,
};
if (hasAdvancedFiltering) {
options.start = createdAfter || '';
options.end = createdBefore || '';
} else {
options.datetime = createdBefore || '';
}
dispatch(getAliases(getBaseUrl(pathname), options));
}, [
activePage,
createdAfter,
createdBefore,
dispatch,
filterQuery,
filterType.value,
hasAdvancedFiltering,
itemsPerPage,
pathname,
]);
// Update results after changing the page.
// (We intentionally leave updateResults out of the deps.)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => updateResults(), [activePage, itemsPerPage]);
// Calculate page count from results
const pages = useMemo(() => {
let pages = Math.ceil(aliases.items_total / itemsPerPage);
if (pages === 0 || isNaN(pages)) {
pages = '';
}
return pages;
}, [aliases.items_total, itemsPerPage]);
// Add new alias
const handleAdd = (formData) => {
const { altUrlPath, targetUrlPath } = formData;
// Validate altUrlPath starts with a slash
if (!altUrlPath || altUrlPath.charAt(0) !== '/') {
setAddError(intl.formatMessage(messages.altUrlError));
return;
}
// Remove existing alias first if we're editing it.
const precondition = editingData
? dispatch(
removeAliases('', { items: [{ path: editingData.altUrlPath }] }),
)
: Promise.resolve();
precondition.then(() => {
dispatch(
addAliases('', {
items: [{ path: altUrlPath, 'redirect-to': targetUrlPath }],
}),
)
.then(() => {
updateResults();
setAddModalOpen(false);
setEditingData(null);
toast.success(
<Toast
success
title={intl.formatMessage(messages.success)}
content={intl.formatMessage(messages.successAdd)}
/>,
);
})
.catch((error) => {
setAddError(error.response?.body?.message);
});
});
};
// Check/uncheck an alias
const handleCheckAlias = (alias) => {
if (aliasesToRemove.includes(alias)) {
setAliasesToRemove(aliasesToRemove.filter((x) => x !== alias));
} else {
setAliasesToRemove([...aliasesToRemove, alias]);
}
};
// Remove selected aliases
const handleRemoveAliases = () => {
dispatch(
removeAliases('', {
items: aliasesToRemove.map((a) => ({ path: a })),
}),
).then(() => {
updateResults();
toast.success(
<Toast
success
title={intl.formatMessage(messages.success)}
content={intl.formatMessage(messages.successRemove)}
/>,
);
});
setAliasesToRemove([]);
};
// Upload CSV
const handleBulkUpload = (formData) => {
fetch(`data:${formData.file['content-type']};base64,${formData.file.data}`)
.then((res) => res.blob())
.then((blob) => {
dispatch(uploadAliases(blob))
.then(() => {
updateResults();
setUploadError(null);
setCSVErrors([]);
setUploadModalOpen(false);
toast.success(
<Toast
success
title={intl.formatMessage(messages.success)}
content={intl.formatMessage(messages.successUpload)}
/>,
);
})
.catch((error) => {
setUploadError(error.response?.body?.message);
setCSVErrors(error.response?.body?.csv_errors ?? []);
});
});
};
return (
<div id="page-aliases">
<Helmet title={intl.formatMessage(messages.aliases)} />
<Container>
<article id="content">
<Segment.Group raised>
<Segment className="primary">
<FormattedMessage
id="URL Management"
defaultMessage="URL Management"
values={{ title: <q>{title}</q> }}
/>
</Segment>
<Segment>
<Button
primary
id="add-alt-url"
onClick={() => setAddModalOpen(true)}
>
{intl.formatMessage(messages.AddUrl)}…
</Button>
{addModalOpen && (
<ModalForm
open={true}
onSubmit={handleAdd}
onCancel={() => setAddModalOpen(false)}
title={
editingData
? intl.formatMessage(messages.EditUrl)
: intl.formatMessage(messages.AddUrl)
}
submitError={addError}
schema={{
fieldsets: [
{
id: 'default',
fields: ['altUrlPath', 'targetUrlPath'],
},
],
properties: {
altUrlPath: {
title: intl.formatMessage(messages.altUrlPathTitle),
description: (
<FormattedMessage
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."
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."
/>
),
placeholder: intl.formatMessage(messages.examplePath),
},
targetUrlPath: {
title: intl.formatMessage(messages.targetUrlPathTitle),
description: (
<FormattedMessage
id="Enter the absolute path of the target. Target must exist or be an existing alternative url path to the target."
defaultMessage="Enter the absolute path of the target. Target must exist or be an existing alternative URL path to the target."
/>
),
placeholder: intl.formatMessage(messages.examplePath),
},
},
required: ['altUrlPath', 'targetUrlPath'],
}}
formData={editingData || {}}
/>
)}
{hasBulkUpload && (
<>
<Button onClick={() => setUploadModalOpen(true)}>
{intl.formatMessage(messages.BulkUploadAltUrls)}…
</Button>
{uploadModalOpen && (
<ModalForm
open={true}
onSubmit={handleBulkUpload}
onCancel={() => setUploadModalOpen(false)}
title={intl.formatMessage(messages.BulkUploadAltUrls)}
submitError={uploadError}
description={
<>
<p>
<FormattedMessage
id="bulkUploadUrlsHelp"
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)."
/>
</p>
<p>
<FormattedMessage
id="Example"
defaultMessage="Example"
/>
:
<br />
<code>
/old-home-page.asp,/front-page,2019/01/27 10:42:59
GMT+1,true
<br />
/people/JoeT,/Users/joe-thurston,2018-12-31,false
</code>
</p>
{csvErrors.length ? (
<div
className="ui error message"
style={{ 'overflow-x': 'auto' }}
>
<pre>
Errors:{'\n'}
{csvErrors.map(
(err) =>
`${err.line_number}: ${err.line} - ${err.message}\n`,
)}
</pre>
</div>
) : null}
</>
}
schema={{
fieldsets: [
{
id: 'default',
fields: ['file'],
},
],
properties: {
file: {
title: intl.formatMessage(messages.CSVFile),
type: 'object',
factory: 'File Upload',
},
},
required: ['file'],
}}
/>
)}
</>
)}
</Segment>
<Segment>
<Form>
<Header size="medium">
<FormattedMessage
id="All existing alternative urls for this site"
defaultMessage="Existing alternative URLs for this site"
/>
</Header>
<Segment>
<Form.Field>
<FormFieldWrapper
id="filterQuery"
title={intl.formatMessage(messages.filterByPrefix)}
>
<Input
name="filter"
placeholder={intl.formatMessage(messages.examplePath)}
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
/>
</FormFieldWrapper>
</Form.Field>
<Form.Field>
<FormFieldWrapper
id="filterType"
title={intl.formatMessage(messages.manualOrAuto)}
>
<Form.Group inline>
{filterChoices.map((o, i) => (
<Form.Field key={i}>
<Radio
label={intl.formatMessage({ id: o.label })}
name="radioGroup"
value={o.value}
checked={filterType === o}
onChange={() => setFilterType(o)}
/>
</Form.Field>
))}
</Form.Group>
</FormFieldWrapper>
</Form.Field>
<Form.Field>
<DatetimeWidget
id="created-before-date"
title={intl.formatMessage(messages.createdBefore)}
dateOnly={true}
value={createdBefore}
onChange={(id, value) => {
setCreatedBefore(value);
}}
/>
</Form.Field>
{hasAdvancedFiltering && (
<Form.Field>
<DatetimeWidget
id="created-after-date"
title={intl.formatMessage(messages.createdAfter)}
dateOnly={true}
value={createdAfter}
onChange={(id, value) => {
setCreatedAfter(value);
}}
/>
</Form.Field>
)}
<Button onClick={() => updateResults()} primary>
<FormattedMessage id="Filter" defaultMessage="Filter" />
</Button>
</Segment>
</Form>
</Segment>
<Segment>
<Header size="small">
<FormattedMessage
id="Alternative url path → target url path (date and time of creation, manually created yes/no)"
defaultMessage="Alternative URL path → target URL path (date and time of creation, manually created yes/no)"
/>
</Header>
<Table celled compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell width="1">
<FormattedMessage id="Select" defaultMessage="Select" />
</Table.HeaderCell>
<Table.HeaderCell width="10">
<FormattedMessage id="Alias" defaultMessage="Alias" />
</Table.HeaderCell>
<Table.HeaderCell width="1">
<FormattedMessage id="Date" defaultMessage="Date" />
</Table.HeaderCell>
<Table.HeaderCell width="1">
<FormattedMessage id="Manual" defaultMessage="Manual" />
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{aliases.get.loading && (
<Table.Row>
<Table.Cell colSpan="4">
<Loader active inline="centered" />
</Table.Cell>
</Table.Row>
)}
{aliases.items.length > 0 &&
aliases.items.map((alias, i) => (
<Table.Row key={i} verticalAlign="top">
<Table.Cell>
<Checkbox
onChange={(e, { value }) => handleCheckAlias(value)}
checked={aliasesToRemove.includes(alias.path)}
value={alias.path}
/>
</Table.Cell>
<Table.Cell>
{alias.path}
<br />
→ {alias['redirect-to']}{' '}
<Button
basic
style={{ verticalAlign: 'middle' }}
aria-label={intl.formatMessage(messages.EditUrl)}
onClick={() => {
setEditingData({
altUrlPath: alias.path,
targetUrlPath: alias['redirect-to'],
});
setAddModalOpen(true);
}}
>
<Icon name={editingSVG} size="18px" />
</Button>
</Table.Cell>
<Table.Cell>
<FormattedDate date={alias.datetime} />
</Table.Cell>
<Table.Cell>{`${alias.manual}`}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
marginBottom: '20px',
}}
>
{pages && (
<Pagination
boundaryRange={0}
activePage={activePage}
ellipsisItem={null}
firstItem={null}
lastItem={null}
siblingRange={1}
totalPages={pages}
onPageChange={(e, { activePage }) =>
setActivePage(activePage)
}
/>
)}
<Menu.Menu
position="right"
style={{ display: 'flex', marginLeft: 'auto' }}
>
<Menu.Item style={{ color: 'grey' }}>
<FormattedMessage id="Show" defaultMessage="Show" />:
</Menu.Item>
{map(itemsPerPageChoices, (size) => (
<Menu.Item
style={{
padding: '0 0.4em',
margin: '0em 0.357em',
cursor: 'pointer',
}}
key={size}
value={size}
active={size === itemsPerPage}
onClick={(e, { value }) => {
setItemsPerPage(value);
setActivePage(1);
}}
>
{size}
</Menu.Item>
))}
</Menu.Menu>
</div>
<Button
id="remove-alt-urls"
disabled={aliasesToRemove.length === 0}
onClick={handleRemoveAliases}
primary
>
<FormattedMessage
id="Remove selected"
defaultMessage="Remove selected"
/>
</Button>
</Segment>
</Segment.Group>
</article>
</Container>
{isClient &&
createPortal(
<Toolbar
pathname={pathname}
hideDefaultViewButtons
inner={
<Link
className="item"
to="#"
onClick={() => history.push(getParentUrl(pathname))}
>
<Icon
name={backSVG}
className="contents circled"
size="30px"
title={intl.formatMessage(messages.back)}
/>
</Link>
}
/>,
document.getElementById('toolbar'),
)}
</div>
);
};
export default Aliases;