@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
514 lines (512 loc) • 19.4 kB
JavaScript
/*
* Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License version 3 as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fetchItemStates, setItemStates, setItemStatesByQuery } from '../../services/workflow';
import GlobalAppToolbar from '../GlobalAppToolbar';
import { FormattedMessage, useIntl } from 'react-intl';
import ItemStatesGridUI, { ItemStatesGridSkeletonTable } from '../ItemStatesGrid';
import SetItemStateDialog from '../SetWorkflowStateDialog';
import Button from '@mui/material/Button';
import FilterListRoundedIcon from '@mui/icons-material/FilterListRounded';
import { useStyles } from './styles';
import { createPresenceTable } from '../../utils/array';
import { getStateBitmap } from './utils';
import Box from '@mui/material/Box';
import CloseIcon from '@mui/icons-material/Close';
import TextField from '@mui/material/TextField';
import FormControl from '@mui/material/FormControl';
import FormLabel from '@mui/material/FormLabel';
import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import { Divider } from '@mui/material';
import ItemPublishingTargetIcon from '../ItemPublishingTargetIcon';
import { getItemPublishingTargetText, getItemStateText } from '../ItemDisplay/utils';
import ItemStateIcon from '../ItemStateIcon';
import translations from './translations';
import ResizeableDrawer from '../ResizeableDrawer/ResizeableDrawer';
import { useActiveSiteId } from '../../hooks/useActiveSiteId';
import { useDebouncedInput } from '../../hooks/useDebouncedInput';
import { useSpreadState } from '../../hooks/useSpreadState';
import ItemActionsSnackbar from '../ItemActionsSnackbar';
import SecondaryButton from '../SecondaryButton';
import useUpdateRefs from '../../hooks/useUpdateRefs';
import { useDispatch } from 'react-redux';
import { showSystemNotification } from '../../state/actions/system';
import { defineMessages } from 'react-intl';
import useMount from '../../hooks/useMount';
import { fetchPublishingTargets } from '../../services/publishing';
import { ApiResponseErrorState } from '../ApiResponseErrorState';
import { EmptyState } from '../EmptyState';
const workflowStateManagementMessages = defineMessages({
statesUpdatedMessage: {
id: 'workflowStateManagementMessages.statesUpdatedMessage',
defaultMessage: 'State for {count} {count, plural, one {item} other {items}} updated successfully'
}
});
const drawerWidth = 260;
const initialStates = [
'new',
'modified',
'deleted',
'locked',
'systemProcessing',
'submitted',
'scheduled',
'staged',
'live'
];
export function WorkflowStateManagement(props) {
var _a, _b;
const { embedded, showAppsButton = !embedded, onSubmittingAndOrPendingChange } = props;
const [fetching, setFetching] = useState(false);
const [items, setItems] = useState(null);
const [error, setError] = useState();
const siteId = useActiveSiteId();
const [openSetStateDialog, setOpenSetStateDialog] = useState(false);
const [openFiltersDrawer, setOpenFiltersDrawer] = useState(false);
const [filtersLookup, setFiltersLookup] = useSpreadState(createPresenceTable(initialStates, false));
const [pathRegex, setPathRegex] = useState('');
const [debouncePathRegex, setDebouncePathRegex] = useState('');
const [invalidPathRegex, setInvalidPathRegex] = useState(false);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(10);
const [selectedItems, setSelectedItems] = useState({});
const [selectedItem, setSelectedItem] = useState(null);
const [isSelectedItemsOnAllPages, setIsSelectedItemsOnAllPages] = useState(false);
const [hasStaging, setHasStaging] = useState(false);
const states = useMemo(
() => (hasStaging ? initialStates : initialStates.filter((state) => state !== 'staged')),
[hasStaging]
);
const { classes } = useStyles();
const { formatMessage } = useIntl();
const fnRefs = useUpdateRefs({ onSubmittingAndOrPendingChange });
const dispatch = useDispatch();
const hasSelectedItems = useMemo(() => Object.values(selectedItems).some(Boolean), [selectedItems]);
const selectedItemsLength = useMemo(() => Object.values(selectedItems).filter(Boolean).length, [selectedItems]);
const isThisPageIndeterminate = useMemo(
() => (items === null || items === void 0 ? void 0 : items.some((item) => !selectedItems[item.path])),
[items, selectedItems]
);
const hasThisPageItemsChecked = useMemo(
() => (items === null || items === void 0 ? void 0 : items.some((item) => selectedItems[item.path])),
[items, selectedItems]
);
const rootRef = useRef();
const fetchStates = useCallback(() => {
let stateBitmap = getStateBitmap(filtersLookup);
setFetching(true);
fetchItemStates(siteId, debouncePathRegex, stateBitmap ? stateBitmap : null, { limit, offset }).subscribe({
next(states) {
setItems(states);
setFetching(false);
},
error({ response }) {
setError(response);
setFetching(false);
}
});
}, [debouncePathRegex, filtersLookup, siteId, limit, offset]);
useEffect(() => {
fetchStates();
}, [fetchStates]);
useEffect(() => {
var _a, _b;
(_b = (_a = fnRefs.current).onSubmittingAndOrPendingChange) === null || _b === void 0
? void 0
: _b.call(_a, {
hasPendingChanges: hasSelectedItems
});
}, [hasSelectedItems, fnRefs]);
useMount(() => {
const sub = fetchPublishingTargets(siteId).subscribe({
next({ publishingTargets: targets }) {
setHasStaging(targets.some((target) => target.name === 'staging'));
}
});
return () => {
sub.unsubscribe();
};
});
const onPathRegex$ = useDebouncedInput(
useCallback(
(keyword) => {
clearSelectedItems();
try {
new RegExp(keyword);
setDebouncePathRegex(keyword);
setInvalidPathRegex(false);
} catch (e) {
// Not a valid regex
setInvalidPathRegex(true);
}
},
[setDebouncePathRegex]
),
400
);
const onPathRegexInputChanges = (value) => {
setPathRegex(value);
onPathRegex$.next(value);
};
const onFilterChecked = (id, value) => {
clearSelectedItems();
if (id === 'any') {
setFiltersLookup(createPresenceTable(states, !value));
} else {
setFiltersLookup({ [id]: value });
}
};
const onClearFilters = () => {
setFiltersLookup(createPresenceTable(states, false));
setDebouncePathRegex('');
setPathRegex('');
};
const onPageChange = (page) => {
setOffset(page * limit);
};
const onRowsPerPageChange = (e) => {
setLimit(e.target.value);
};
const onItemSelected = (selectedItem, value) => {
if (isSelectedItemsOnAllPages) {
const selectedItemsOnPage = {};
setIsSelectedItemsOnAllPages(false);
items.forEach((item) => {
if (item.path !== selectedItem.path) {
selectedItemsOnPage[item.path] = item;
}
});
setSelectedItems(Object.assign(Object.assign({}, selectedItems), selectedItemsOnPage));
} else {
setSelectedItems(
Object.assign(Object.assign({}, selectedItems), { [selectedItem.path]: value ? selectedItem : null })
);
}
};
const onRowSelected = (item) => {
setSelectedItem(item);
setOpenSetStateDialog(true);
};
const onOptionClicked = (option) => {
if (option === 'editStates') {
setSelectedItem(null);
setOpenSetStateDialog(true);
} else if (option === 'clearSelected') {
clearSelectedItems();
setIsSelectedItemsOnAllPages(false);
} else if ('selectAll') {
clearSelectedItems();
setIsSelectedItemsOnAllPages(true);
}
};
const clearSelectedItems = () => {
setSelectedItems({});
};
const onToggleSelectAllItems = () => {
if (isSelectedItemsOnAllPages) {
setIsSelectedItemsOnAllPages(false);
} else {
const selectedItemsOnPage = {};
if (isThisPageIndeterminate) {
items.forEach((item) => (selectedItemsOnPage[item.path] = item));
} else {
items.forEach((item) => (selectedItemsOnPage[item.path] = null));
}
setSelectedItems(Object.assign(Object.assign({}, selectedItems), selectedItemsOnPage));
}
};
const showStatesUpdatedNotification = () => {
const count = selectedItem
? 1
: isSelectedItemsOnAllPages
? items === null || items === void 0
? void 0
: items.total
: selectedItemsLength;
dispatch(
showSystemNotification({
message: formatMessage(workflowStateManagementMessages.statesUpdatedMessage, { count })
})
);
};
const onSetItemStateDialogConfirm = (update) => {
if (selectedItem) {
setItemStates(siteId, [selectedItem.path], update).subscribe(() => {
fetchStates();
showStatesUpdatedNotification();
});
} else if (isSelectedItemsOnAllPages) {
let stateBitmap = getStateBitmap(filtersLookup);
setItemStatesByQuery(siteId, stateBitmap ? stateBitmap : null, update, debouncePathRegex).subscribe(() => {
fetchStates();
showStatesUpdatedNotification();
});
} else {
setItemStates(
siteId,
Object.values(selectedItems)
.filter(Boolean)
.map((item) => item.path),
update
).subscribe(() => {
fetchStates();
showStatesUpdatedNotification();
});
}
setOpenSetStateDialog(false);
};
const onSetItemStateDialogClose = () => {
setSelectedItem(null);
setOpenSetStateDialog(false);
};
return React.createElement(
'section',
{ ref: rootRef, className: classes.root },
React.createElement(GlobalAppToolbar, {
title:
!embedded &&
React.createElement(FormattedMessage, { id: 'workflowStates.title', defaultMessage: 'Workflow States' }),
rightContent: React.createElement(
SecondaryButton,
{
className: embedded ? '' : classes.filterButton,
endIcon: React.createElement(FilterListRoundedIcon, null),
onClick: () => setOpenFiltersDrawer(!openFiltersDrawer)
},
React.createElement(FormattedMessage, { id: 'words.filters', defaultMessage: 'Filters' })
),
showHamburgerMenuButton: !embedded,
showAppsButton: showAppsButton
}),
React.createElement(
Box,
{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
paddingRight: openFiltersDrawer ? `${drawerWidth}px` : 0,
className: classes.wrapper,
position: 'relative'
},
(hasSelectedItems || isSelectedItemsOnAllPages) &&
React.createElement(ItemActionsSnackbar, {
open: hasSelectedItems || isSelectedItemsOnAllPages,
options: [
{
id: 'editStates',
label: formatMessage(translations.editStates)
},
...(isSelectedItemsOnAllPages
? []
: [
{
id: 'selectAll',
label: formatMessage(translations.selectAll, { count: items.total })
}
]),
{
id: 'clearSelected',
label: formatMessage(translations.clearSelected, {
count: isSelectedItemsOnAllPages ? items.total : selectedItemsLength
})
}
],
onActionClicked: onOptionClicked
}),
fetching && React.createElement(ItemStatesGridSkeletonTable, null),
error && React.createElement(ApiResponseErrorState, { error: error }),
(items === null || items === void 0 ? void 0 : items.length) === 0 &&
React.createElement(EmptyState, {
title: React.createElement(FormattedMessage, {
id: 'itemStates.emptyStateMessage',
defaultMessage: 'No results found'
})
}),
Boolean(items === null || items === void 0 ? void 0 : items.length) &&
React.createElement(ItemStatesGridUI, {
itemStates: items,
selectedItems: selectedItems,
allItemsSelected: isSelectedItemsOnAllPages,
hasThisPageItemsChecked: hasThisPageItemsChecked,
isThisPageIndeterminate: isThisPageIndeterminate,
onItemSelected: onItemSelected,
onToggleSelectedItems: onToggleSelectAllItems,
onPageChange: onPageChange,
onRowsPerPageChange: onRowsPerPageChange,
onRowSelected: onRowSelected
}),
React.createElement(
ResizeableDrawer,
{
open: openFiltersDrawer,
width: drawerWidth,
anchor: 'right',
styles: {
drawerBody: {
overflowY: 'inherit'
},
drawerPaper: {
overflow: 'inherit',
position: 'absolute'
}
},
classes: {
drawerPaper: classes.drawerPaper
}
},
React.createElement(
'form',
{
noValidate: true,
autoComplete: 'off',
onSubmit: (e) => {
e.preventDefault();
}
},
React.createElement(
Button,
{
disabled: pathRegex === '' && !Object.values(filtersLookup).some(Boolean),
endIcon: React.createElement(CloseIcon, null),
variant: 'outlined',
onClick: onClearFilters,
fullWidth: true
},
React.createElement(FormattedMessage, { id: 'itemStates.clearFilters', defaultMessage: 'Clear Filters' })
),
React.createElement(TextField, {
value: pathRegex,
className: classes.inputPath,
onChange: (e) => onPathRegexInputChanges(e.target.value),
label: React.createElement(FormattedMessage, {
id: 'itemStates.pathRegex',
defaultMessage: 'Path (regex)'
}),
fullWidth: true,
variant: 'outlined',
error: invalidPathRegex,
FormHelperTextProps: {
className: classes.helperText
},
helperText: invalidPathRegex
? React.createElement(FormattedMessage, {
id: 'itemStates.invalidPathRegexHelperText',
defaultMessage: 'The regular expression is invalid'
})
: React.createElement(FormattedMessage, {
id: 'itemStates.pathRegexHelperText',
defaultMessage: 'Use a path-matching regex'
})
}),
React.createElement(
FormControl,
{ component: 'fieldset', className: classes.formControl },
React.createElement(
FormLabel,
{ component: 'legend', className: classes.formLabel },
React.createElement(FormattedMessage, { id: 'itemStates.showItemsIn', defaultMessage: 'Show Items In' })
),
React.createElement(
FormGroup,
{ className: classes.formGroup },
React.createElement(FormControlLabel, {
classes: { label: classes.iconLabel },
control: React.createElement(Checkbox, {
checked: !Object.values(filtersLookup).some(Boolean),
name: 'any',
onChange: (event) => {
onFilterChecked(event.target.name, !Object.values(filtersLookup).every(Boolean));
}
}),
label: React.createElement(FormattedMessage, { id: 'itemStates.anyState', defaultMessage: 'Any state' })
}),
React.createElement(Divider, null),
states.map((id) =>
React.createElement(FormControlLabel, {
key: id,
classes: { label: classes.iconLabel },
control: React.createElement(Checkbox, {
checked: filtersLookup[id],
name: id,
onChange: (event) => {
onFilterChecked(event.target.name, event.target.checked);
}
}),
label: ['staged', 'live'].includes(id)
? React.createElement(
React.Fragment,
null,
React.createElement(ItemPublishingTargetIcon, { item: { stateMap: { [id]: true } } }),
getItemPublishingTargetText({ [id]: true })
)
: React.createElement(
React.Fragment,
null,
React.createElement(ItemStateIcon, { item: { stateMap: { [id]: true } } }),
getItemStateText({ [id]: true })
)
})
)
)
)
)
)
),
React.createElement(SetItemStateDialog, {
title: React.createElement(FormattedMessage, {
id: 'workflowStates.setState',
defaultMessage: '{count, plural, one {Set State for "{name}"} other {Set State for Items ({count})}}',
values: {
count: selectedItem
? 1
: isSelectedItemsOnAllPages
? items === null || items === void 0
? void 0
: items.total
: selectedItemsLength,
name:
(_a = selectedItem === null || selectedItem === void 0 ? void 0 : selectedItem.label) !== null &&
_a !== void 0
? _a
: (_b = Object.values(selectedItems)[0]) === null || _b === void 0
? void 0
: _b.label
}
}),
open: openSetStateDialog,
onClose: onSetItemStateDialogClose,
onConfirm: onSetItemStateDialogConfirm
})
);
}
export default WorkflowStateManagement;