UNPKG

@craftercms/studio-ui

Version:

Services, components, models & utils to build CrafterCMS authoring extensions.

493 lines (491 loc) 18.7 kB
/* * 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) { 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?.some((item) => !selectedItems[item.path]), [items, selectedItems] ); const hasThisPageItemsChecked = useMemo( () => 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(() => { fnRefs.current.onSubmittingAndOrPendingChange?.({ 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); setOffset(0); 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(); setOffset(0); if (id === 'any') { setFiltersLookup(createPresenceTable(states, !value)); } else { setFiltersLookup({ [id]: value }); } }; const onClearFilters = () => { setOffset(0); 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({ ...selectedItems, ...selectedItemsOnPage }); } else { setSelectedItems({ ...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({ ...selectedItems, ...selectedItemsOnPage }); } }; const showStatesUpdatedNotification = () => { const count = selectedItem ? 1 : isSelectedItemsOnAllPages ? 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?.length === 0 && React.createElement(EmptyState, { title: React.createElement(FormattedMessage, { id: 'itemStates.emptyStateMessage', defaultMessage: 'No results found' }) }), Boolean(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?.total : selectedItemsLength, name: selectedItem?.label ?? Object.values(selectedItems)[0]?.label } }), open: openSetStateDialog, onClose: onSetItemStateDialogClose, onConfirm: onSetItemStateDialogConfirm }) ); } export default WorkflowStateManagement;