strapi-plugin-content-manager
Version:
A powerful UI to easily manage your data.
574 lines (516 loc) • 18 kB
JavaScript
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { bindActionCreators, compose } from 'redux';
import { get, isEmpty } from 'lodash';
import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory, useLocation } from 'react-router-dom';
import { Header } from '@buffetjs/custom';
import { Flex, Padded } from '@buffetjs/core';
import isEqual from 'react-fast-compare';
import { stringify } from 'qs';
import {
CheckPermissions,
InjectionZone,
InjectionZoneList,
PopUpWarning,
useGlobalContext,
useQueryParams,
useUser,
request,
} from 'strapi-helper-plugin';
import pluginId from '../../pluginId';
import pluginPermissions from '../../permissions';
import { formatFiltersFromQuery, getRequestUrl, getTrad } from '../../utils';
import Container from '../../components/Container';
import CustomTable from '../../components/CustomTable';
import FilterPicker from '../../components/FilterPicker';
import Search from '../../components/Search';
import ListViewProvider from '../ListViewProvider';
import { AddFilterCta, FilterIcon, Wrapper } from './components';
import FieldPicker from './FieldPicker';
import Filter from './Filter';
import Footer from './Footer';
import {
getData,
getDataSucceeded,
onChangeBulk,
onChangeBulkSelectall,
onDeleteDataError,
onDeleteDataSucceeded,
onDeleteSeveralDataSucceeded,
setModalLoadingState,
toggleModalDelete,
toggleModalDeleteAll,
setLayout,
onChangeListHeaders,
onResetListHeaders,
} from './actions';
import makeSelectListView from './selectors';
import { getAllAllowedHeaders, getFirstSortableHeader, buildQueryString } from './utils';
/* eslint-disable react/no-array-index-key */
function ListView({
canCreate,
canDelete,
canRead,
canUpdate,
didDeleteData,
entriesToDelete,
onChangeBulk,
onChangeBulkSelectall,
onDeleteDataError,
onDeleteDataSucceeded,
onDeleteSeveralDataSucceeded,
setModalLoadingState,
showWarningDelete,
showModalConfirmButtonLoading,
showWarningDeleteAll,
toggleModalDelete,
toggleModalDeleteAll,
data,
displayedHeaders,
getData,
getDataSucceeded,
isLoading,
layout,
onChangeListHeaders,
onResetListHeaders,
pagination: { total },
slug,
}) {
const {
contentType: {
attributes,
metadatas,
settings: { bulkable: isBulkable, filterable: isFilterable, searchable: isSearchable },
},
} = layout;
const { emitEvent } = useGlobalContext();
const { fetchUserPermissions } = useUser();
const emitEventRef = useRef(emitEvent);
const fetchPermissionsRef = useRef(fetchUserPermissions);
const [{ query }, setQuery] = useQueryParams();
const params = buildQueryString(query);
const { pathname } = useLocation();
const { push } = useHistory();
const { formatMessage } = useIntl();
const [isFilterPickerOpen, setFilterPickerState] = useState(false);
const [idToDelete, setIdToDelete] = useState(null);
const contentType = layout.contentType;
const hasDraftAndPublish = get(contentType, 'options.draftAndPublish', false);
const allAllowedHeaders = useMemo(() => getAllAllowedHeaders(attributes), [attributes]);
const filters = useMemo(() => {
return formatFiltersFromQuery(query);
}, [query]);
const _sort = query._sort;
const _q = query._q || '';
const label = contentType.info.label;
const firstSortableHeader = useMemo(() => getFirstSortableHeader(displayedHeaders), [
displayedHeaders,
]);
useEffect(() => {
setFilterPickerState(false);
}, []);
// Using a ref to avoid requests being fired multiple times on slug on change
// We need it because the hook as mulitple dependencies so it may run before the permissions have checked
const requestUrlRef = useRef('');
const fetchData = useCallback(
async (endPoint, abortSignal = false) => {
getData();
const signal = abortSignal || new AbortController().signal;
try {
const { results, pagination } = await request(endPoint, { method: 'GET', signal });
getDataSucceeded(pagination, results);
} catch (err) {
const resStatus = get(err, 'response.status', null);
console.log(err);
if (resStatus === 403) {
await fetchPermissionsRef.current();
strapi.notification.info(getTrad('permissions.not-allowed.update'));
push('/');
return;
}
if (err.name !== 'AbortError') {
console.error(err);
strapi.notification.error(getTrad('error.model.fetch'));
}
}
},
[getData, getDataSucceeded, push]
);
const handleChangeListLabels = useCallback(
({ name, value }) => {
// Display a notification if trying to remove the last displayed field
if (value && displayedHeaders.length === 1) {
strapi.notification.toggle({
type: 'warning',
message: { id: 'content-manager.notification.error.displayedFields' },
});
} else {
emitEventRef.current('didChangeDisplayedFields');
onChangeListHeaders({ name, value });
}
},
[displayedHeaders, onChangeListHeaders]
);
const handleConfirmDeleteAllData = useCallback(async () => {
try {
setModalLoadingState();
await request(getRequestUrl(`collection-types/${slug}/actions/bulkDelete`), {
method: 'POST',
body: { ids: entriesToDelete },
});
onDeleteSeveralDataSucceeded();
emitEventRef.current('didBulkDeleteEntries');
} catch (err) {
strapi.notification.error(`${pluginId}.error.record.delete`);
}
}, [entriesToDelete, onDeleteSeveralDataSucceeded, slug, setModalLoadingState]);
const handleConfirmDeleteData = useCallback(async () => {
try {
let trackerProperty = {};
if (hasDraftAndPublish) {
const dataToDelete = data.find(obj => obj.id.toString() === idToDelete.toString());
const isDraftEntry = isEmpty(dataToDelete.published_at);
const status = isDraftEntry ? 'draft' : 'published';
trackerProperty = { status };
}
emitEventRef.current('willDeleteEntry', trackerProperty);
setModalLoadingState();
await request(getRequestUrl(`collection-types/${slug}/${idToDelete}`), {
method: 'DELETE',
});
strapi.notification.toggle({
type: 'success',
message: { id: `${pluginId}.success.record.delete` },
});
// Close the modal and refetch data
onDeleteDataSucceeded();
emitEventRef.current('didDeleteEntry', trackerProperty);
} catch (err) {
const errorMessage = get(
err,
'response.payload.message',
formatMessage({ id: `${pluginId}.error.record.delete` })
);
strapi.notification.toggle({
type: 'warning',
message: errorMessage,
});
// Close the modal
onDeleteDataError();
}
}, [
hasDraftAndPublish,
setModalLoadingState,
slug,
idToDelete,
onDeleteDataSucceeded,
data,
formatMessage,
onDeleteDataError,
]);
useEffect(() => {
const abortController = new AbortController();
const { signal } = abortController;
const shouldSendRequest = canRead;
const requestUrl = `/${pluginId}/collection-types/${slug}${params}`;
if (shouldSendRequest && requestUrl.includes(requestUrlRef.current)) {
fetchData(requestUrl, signal);
}
return () => {
requestUrlRef.current = slug;
abortController.abort();
};
}, [canRead, getData, slug, params, getDataSucceeded, fetchData]);
const handleClickDelete = id => {
setIdToDelete(id);
toggleModalDelete();
};
const handleModalClose = useCallback(() => {
if (didDeleteData) {
const requestUrl = `/${pluginId}/collection-types/${slug}${params}`;
fetchData(requestUrl);
}
}, [fetchData, didDeleteData, slug, params]);
const toggleFilterPickerState = useCallback(() => {
setFilterPickerState(prevState => {
if (!prevState) {
emitEventRef.current('willFilterEntries');
}
return !prevState;
});
}, []);
const headerAction = useMemo(() => {
if (!canCreate) {
return [];
}
return [
{
label: formatMessage(
{
id: 'content-manager.containers.List.addAnEntry',
},
{
entity: label || 'Content Manager',
}
),
onClick: () => {
const trackerProperty = hasDraftAndPublish ? { status: 'draft' } : {};
emitEventRef.current('willCreateEntry', trackerProperty);
push({
pathname: `${pathname}/create`,
search: query.plugins ? stringify({ plugins: query.plugins }, { encode: false }) : '',
});
},
color: 'primary',
type: 'button',
icon: true,
style: {
paddingLeft: 15,
paddingRight: 15,
fontWeight: 600,
},
},
];
}, [label, pathname, canCreate, formatMessage, hasDraftAndPublish, push, query]);
const headerProps = useMemo(() => {
/* eslint-disable indent */
return {
title: {
label: label || 'Content Manager',
},
content: canRead
? formatMessage(
{
id:
total > 1
? `${pluginId}.containers.List.pluginHeaderDescription`
: `${pluginId}.containers.List.pluginHeaderDescription.singular`,
},
{ label: total }
)
: null,
actions: headerAction,
};
}, [total, headerAction, label, canRead, formatMessage]);
const handleToggleModalDeleteAll = e => {
emitEventRef.current('willBulkDeleteEntries');
toggleModalDeleteAll(e);
};
return (
<>
<ListViewProvider
_q={_q}
_sort={_sort}
data={data}
entriesToDelete={entriesToDelete}
filters={filters}
firstSortableHeader={firstSortableHeader}
label={label}
onChangeBulk={onChangeBulk}
onChangeBulkSelectall={onChangeBulkSelectall}
onClickDelete={handleClickDelete}
slug={slug}
toggleModalDeleteAll={handleToggleModalDeleteAll}
setQuery={setQuery}
>
<FilterPicker
contentType={contentType}
filters={filters}
isOpen={isFilterPickerOpen}
metadatas={metadatas}
name={label}
toggleFilterPickerState={toggleFilterPickerState}
setQuery={setQuery}
slug={slug}
/>
<Container className="container-fluid">
{!isFilterPickerOpen && <Header {...headerProps} isLoading={isLoading && canRead} />}
{isSearchable && canRead && (
<Search changeParams={setQuery} initValue={_q} model={label} value={_q} />
)}
{!canRead && (
<Flex justifyContent="flex-end">
<Padded right size="sm">
<InjectionZone area={`${pluginId}.listView.actions`} />
</Padded>
</Flex>
)}
{canRead && (
<Wrapper>
<div className="row" style={{ marginBottom: '5px' }}>
<div className="col-9">
<div className="row" style={{ marginLeft: 0, marginRight: 0 }}>
{isFilterable && (
<>
<AddFilterCta type="button" onClick={toggleFilterPickerState}>
<FilterIcon />
<FormattedMessage id="app.utils.filters" />
</AddFilterCta>
{filters.map(({ filter: filterName, name, value }, key) => (
<Filter
contentType={contentType}
filterName={filterName}
filters={filters}
index={key}
key={key}
metadatas={metadatas}
name={name}
toggleFilterPickerState={toggleFilterPickerState}
isFilterPickerOpen={isFilterPickerOpen}
setQuery={setQuery}
value={value}
/>
))}
</>
)}
</div>
</div>
<div className="col-3">
<Flex justifyContent="flex-end">
<Padded right size="sm">
<InjectionZone area={`${pluginId}.listView.actions`} />
</Padded>
<CheckPermissions permissions={pluginPermissions.collectionTypesConfigurations}>
<FieldPicker
displayedHeaders={displayedHeaders}
items={allAllowedHeaders}
onChange={handleChangeListLabels}
onClickReset={onResetListHeaders}
slug={slug}
/>
</CheckPermissions>
</Flex>
</div>
</div>
<div className="row" style={{ paddingTop: '12px' }}>
<div className="col-12">
<CustomTable
data={data}
canCreate={canCreate}
canDelete={canDelete}
canUpdate={canUpdate}
displayedHeaders={displayedHeaders}
hasDraftAndPublish={hasDraftAndPublish}
isBulkable={isBulkable}
setQuery={setQuery}
showLoader={isLoading}
/>
<Footer count={total} params={query} onChange={setQuery} />
</div>
</div>
</Wrapper>
)}
</Container>
<PopUpWarning
isOpen={showWarningDelete}
toggleModal={toggleModalDelete}
content={{
message: getTrad('popUpWarning.bodyMessage.contentType.delete'),
}}
onConfirm={handleConfirmDeleteData}
popUpWarningType="danger"
onClosed={handleModalClose}
isConfirmButtonLoading={showModalConfirmButtonLoading}
>
<InjectionZoneList area={`${pluginId}.listView.deleteModalAdditionalInfos`} />
</PopUpWarning>
<PopUpWarning
isOpen={showWarningDeleteAll}
toggleModal={toggleModalDeleteAll}
content={{
message: getTrad(
`popUpWarning.bodyMessage.contentType.delete${
entriesToDelete.length > 1 ? '.all' : ''
}`
),
}}
popUpWarningType="danger"
onConfirm={handleConfirmDeleteAllData}
onClosed={handleModalClose}
isConfirmButtonLoading={showModalConfirmButtonLoading}
>
<InjectionZoneList area={`${pluginId}.listView.deleteModalAdditionalInfos`} />
</PopUpWarning>
</ListViewProvider>
</>
);
}
ListView.defaultProps = {
permissions: [],
};
ListView.propTypes = {
canCreate: PropTypes.bool.isRequired,
canDelete: PropTypes.bool.isRequired,
canRead: PropTypes.bool.isRequired,
canUpdate: PropTypes.bool.isRequired,
displayedHeaders: PropTypes.array.isRequired,
data: PropTypes.array.isRequired,
didDeleteData: PropTypes.bool.isRequired,
entriesToDelete: PropTypes.array.isRequired,
layout: PropTypes.exact({
components: PropTypes.object.isRequired,
contentType: PropTypes.shape({
attributes: PropTypes.object.isRequired,
metadatas: PropTypes.object.isRequired,
info: PropTypes.shape({ label: PropTypes.string.isRequired }).isRequired,
layouts: PropTypes.shape({
list: PropTypes.array.isRequired,
editRelations: PropTypes.array,
}).isRequired,
options: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
isLoading: PropTypes.bool.isRequired,
getData: PropTypes.func.isRequired,
getDataSucceeded: PropTypes.func.isRequired,
onChangeBulk: PropTypes.func.isRequired,
onChangeBulkSelectall: PropTypes.func.isRequired,
onChangeListHeaders: PropTypes.func.isRequired,
onDeleteDataError: PropTypes.func.isRequired,
onDeleteDataSucceeded: PropTypes.func.isRequired,
onDeleteSeveralDataSucceeded: PropTypes.func.isRequired,
onResetListHeaders: PropTypes.func.isRequired,
pagination: PropTypes.shape({ total: PropTypes.number.isRequired }).isRequired,
setModalLoadingState: PropTypes.func.isRequired,
showModalConfirmButtonLoading: PropTypes.bool.isRequired,
showWarningDelete: PropTypes.bool.isRequired,
showWarningDeleteAll: PropTypes.bool.isRequired,
slug: PropTypes.string.isRequired,
toggleModalDelete: PropTypes.func.isRequired,
toggleModalDeleteAll: PropTypes.func.isRequired,
setLayout: PropTypes.func.isRequired,
permissions: PropTypes.arrayOf(
PropTypes.shape({
action: PropTypes.string.isRequired,
subject: PropTypes.string.isRequired,
properties: PropTypes.object,
conditions: PropTypes.arrayOf(PropTypes.string),
})
),
};
const mapStateToProps = makeSelectListView();
export function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
getData,
getDataSucceeded,
onChangeBulk,
onChangeBulkSelectall,
onChangeListHeaders,
onDeleteDataError,
onDeleteDataSucceeded,
onDeleteSeveralDataSucceeded,
onResetListHeaders,
setModalLoadingState,
toggleModalDelete,
toggleModalDeleteAll,
setLayout,
},
dispatch
);
}
const withConnect = connect(mapStateToProps, mapDispatchToProps);
export default compose(withConnect)(memo(ListView, isEqual));