UNPKG

@craftercms/studio-ui

Version:

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

433 lines (431 loc) 14.9 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, useReducer, useRef } from 'react'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import { makeStyles } from 'tss-react/mui'; import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded'; import Popover from '@mui/material/Popover'; import Paper from '@mui/material/Paper'; import { createAction } from '@reduxjs/toolkit'; import Breadcrumbs from '../PathNavigator/PathNavigatorBreadcrumbs'; import PathNavigatorList from '../PathNavigator/PathNavigatorList'; import { fetchChildrenByPath, fetchItemsByPath, fetchItemWithChildrenByPath } from '../../services/content'; import { getIndividualPaths, getParentPath, withIndex, withoutIndex } from '../../utils/path'; import { createLookupTable, nou } from '../../utils/object'; import { forkJoin } from 'rxjs'; import { isFolder } from '../PathNavigator/utils'; import { lookupItemByPath, parseSandBoxItemToDetailedItem } from '../../utils/content'; import Pagination from '../Pagination'; import NavItem from '../PathNavigator/PathNavigatorItem'; import { useActiveSiteId } from '../../hooks/useActiveSiteId'; import ItemDisplay from '../ItemDisplay'; import PathNavigatorSkeleton from '../PathNavigator/PathNavigatorSkeleton'; import Tooltip from '@mui/material/Tooltip'; const useStyles = makeStyles()((theme) => ({ popoverRoot: { minWidth: '200px', maxWidth: '400px', marginTop: '5px', padding: '0 5px 5px 5px' }, root: { backgroundColor: theme.palette.background.paper, display: 'flex', paddingLeft: '15px', alignItems: 'center', justifyContent: 'space-between', marginRight: 'auto', maxWidth: '100%', minWidth: '200px', '&.disable': { minWidth: 'auto', backgroundColor: 'inherit' } }, selectedItem: { marginLeft: 'auto', display: 'flex', minWidth: 0 }, title: { fontWeight: 600, marginRight: '30px' }, changeBtn: {}, itemName: {}, selectIcon: {} })); const init = (props) => ({ byId: null, isFetching: null, error: null, items: [], keywords: '', pageNumber: 0, breadcrumb: [], offset: 0, limit: 20, total: 0, rootPath: props.rootPath, // If path is not a child of the rootPath (or the rootPath itself), then set the currentPath to be the rootPath currentPath: props.selectedItem?.path && props.selectedItem.path.includes(withoutIndex(props.rootPath)) ? props.selectedItem.path : props.rootPath }); const reducer = (state, { type, payload }) => { switch (type) { case changeCurrentPath.type: { return { ...state, currentPath: payload.path }; } case setKeyword.type: { return { ...state, keywords: payload, isFetching: true }; } case changePage.type: { return { ...state, isFetching: true }; } case fetchParentsItems.type: case fetchChildrenByPathAction.type: { return { ...state, // If the path is not a child of the rootPath (or the rootPath itself), then set the currentPath to be the rootPath currentPath: payload.includes(withoutIndex(state.rootPath)) ? payload : state.rootPath, isFetching: true }; } case fetchChildrenByPathComplete.type: { const { currentPath, rootPath, byId } = state; const { children, parent } = payload; if (children.length === 0 && withoutIndex(currentPath) !== withoutIndex(rootPath)) { return { ...state, currentPath: getNextPath(currentPath, byId), total: children.total, isFetching: false }; } else { const nextItems = { ...{ ...state.byId, ...createLookupTable(children, 'path') }, ...(parent && { [parent.path]: parent }) }; return { ...state, byId: nextItems, items: children.map((item) => item.path), isFetching: false, total: children.total, offset: children.offset, limit: children.limit, breadcrumb: getIndividualPaths(withoutIndex(currentPath), withoutIndex(rootPath)) }; } } case fetchParentsItemsComplete.type: { const { currentPath, rootPath, byId } = state; const { children, items } = payload; return { ...state, byId: { ...byId, ...createLookupTable(children.map(parseSandBoxItemToDetailedItem), 'path'), ...createLookupTable(items, 'path') }, items: children.map((item) => item.path), isFetching: false, limit: children.limit, total: children.total, offset: children.offset, breadcrumb: getIndividualPaths(withoutIndex(currentPath), withoutIndex(rootPath)) }; } default: throw new Error(`Unknown action "${type}"`); } }; function getNextPath(currentPath, byId) { let pieces = currentPath.split('/').slice(0); pieces.pop(); if (currentPath.includes('index.xml')) { pieces.pop(); } let nextPath = pieces.join('/'); if (nou(byId[nextPath])) { nextPath = withIndex(nextPath); } return nextPath; } const changeCurrentPath = /*#__PURE__*/ createAction('CHANGE_SELECTED_ITEM'); const setKeyword = /*#__PURE__*/ createAction('SET_KEYWORD'); const changePage = /*#__PURE__*/ createAction('CHANGE_PAGE'); const changeRowsPerPage = /*#__PURE__*/ createAction('CHANGE_PAGE'); const fetchChildrenByPathAction = /*#__PURE__*/ createAction('FETCH_CHILDREN_BY_PATH'); const fetchParentsItems = /*#__PURE__*/ createAction('FETCH_PARENTS_ITEMS'); const fetchParentsItemsComplete = /*#__PURE__*/ createAction('FETCH_PARENTS_ITEMS_COMPLETE'); const fetchChildrenByPathComplete = /*#__PURE__*/ createAction('FETCH_CHILDREN_BY_PATH_COMPLETE'); const fetchChildrenByPathFailed = /*#__PURE__*/ createAction('FETCH_CHILDREN_BY_PATH_FAILED'); export function SingleItemSelector(props) { // region const { ... } = props; const { selectIcon: SelectIcon = ExpandMoreRoundedIcon, classes: propClasses, titleVariant = 'body1', hideUI = false, disabled = false, onItemClicked, onDropdownClick, onClose, label, open, selectedItem, rootPath, canSelectFolders = false, filterChildren = () => true, buttonSize = 'large', tooltip = '' } = props; // endregion const { classes, cx } = useStyles(); const buttonElRef = useRef(); const [state, _dispatch] = useReducer(reducer, props, init); const site = useActiveSiteId(); const exec = useCallback( (action) => { _dispatch(action); const { type, payload } = action; switch (type) { case setKeyword.type: { fetchChildrenByPath(site, state.currentPath, { limit: state.limit, keyword: payload }).subscribe( (children) => exec(fetchChildrenByPathComplete({ children })), (response) => exec(fetchChildrenByPathFailed(response)) ); break; } case changeRowsPerPage.type: case changePage.type: { fetchChildrenByPath(site, state.currentPath, { limit: payload.limit ?? state.limit, offset: payload.offset ?? state.offset }).subscribe( (children) => exec(fetchChildrenByPathComplete({ children })), (response) => exec(fetchChildrenByPathFailed(response)) ); break; } case fetchChildrenByPathAction.type: fetchItemWithChildrenByPath(site, payload, { limit: state.limit }).subscribe( ({ item, children }) => exec(fetchChildrenByPathComplete({ parent: item, children })), (response) => exec(fetchChildrenByPathFailed(response)) ); break; case fetchParentsItems.type: const parentsPath = getIndividualPaths(payload, state.rootPath); if (parentsPath.length > 1) { forkJoin([ fetchItemsByPath(site, parentsPath, { castAsDetailedItem: true }), fetchChildrenByPath(site, payload, { limit: state.limit }) ]).subscribe( ([items, children]) => { const { levelDescriptor, total, offset, limit } = children; return exec( fetchParentsItemsComplete({ items, children: Object.assign(children.filter(filterChildren), { levelDescriptor, total, offset, limit }) }) ); }, (response) => exec(fetchChildrenByPathFailed(response)) ); } else { fetchItemWithChildrenByPath(site, payload, { limit: state.limit }).subscribe( ({ item, children }) => { const { levelDescriptor, total, offset, limit } = children; return exec( fetchChildrenByPathComplete({ parent: item, children: Object.assign(children.filter(filterChildren), { levelDescriptor, total, offset, limit }) }) ); }, (response) => exec(fetchChildrenByPathFailed(response)) ); } break; } }, [state, site, filterChildren] ); const handleDropdownClick = (item) => { onDropdownClick(); let nextPath = item ? withoutIndex(item.path) === withoutIndex(rootPath) ? item.path : getParentPath(item.path) : rootPath; exec(fetchParentsItems(nextPath)); }; const onPathSelected = (item) => { exec(fetchChildrenByPathAction(item.path)); }; const onSearch = (keyword) => { exec(setKeyword(keyword)); }; const onCrumbSelected = (item) => { if (state.breadcrumb.length === 1) { handleItemClicked(item); } else { exec(fetchChildrenByPathAction(item.path)); } }; const handleItemClicked = (item) => { const folder = isFolder(item); if (folder && canSelectFolders === false) { onPathSelected(item); } else { exec(changeCurrentPath(item)); onItemClicked(item); } }; const onPageChanged = (e, page) => { const offset = page * state.limit; exec(changePage({ offset })); }; const onChangeRowsPerPage = (e) => { exec(changeRowsPerPage({ offset: 0, limit: e.target.value })); }; const Wrapper = hideUI ? React.Fragment : Paper; const wrapperProps = hideUI ? {} : { elevation: 0, className: cx(classes.root, !onDropdownClick && 'disable', propClasses?.root) }; return React.createElement( Wrapper, { ...wrapperProps }, !hideUI && React.createElement( React.Fragment, null, label && React.createElement( Typography, { variant: titleVariant, className: cx(classes.title, propClasses?.title) }, label ), selectedItem && React.createElement( 'div', { className: classes.selectedItem }, React.createElement(ItemDisplay, { item: selectedItem, showNavigableAsLinks: false }) ) ), onDropdownClick && React.createElement( Tooltip, { title: tooltip }, React.createElement( IconButton, { className: classes.changeBtn, ref: buttonElRef, disabled: disabled, onClick: disabled ? null : () => handleDropdownClick(selectedItem), size: buttonSize }, React.createElement(SelectIcon, { className: cx(classes.selectIcon, propClasses?.selectIcon) }) ) ), React.createElement( Popover, { anchorEl: buttonElRef.current, open: open, classes: { paper: cx(classes.popoverRoot, propClasses?.popoverRoot) }, onClose: onClose, anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, transformOrigin: { vertical: 'top', horizontal: 'right' } }, React.createElement(Breadcrumbs, { keyword: state?.keywords, breadcrumb: state.breadcrumb.flatMap((path) => state.byId[path] ?? state.byId[withIndex(path)] ?? []), onSearch: onSearch, onCrumbSelected: onCrumbSelected }), state.byId && lookupItemByPath(state.currentPath, state.byId) && React.createElement(NavItem, { item: lookupItemByPath(state.currentPath, state.byId), locale: 'en_US', isLevelDescriptor: false, isCurrentPath: true, onItemClicked: handleItemClicked, showItemNavigateToButton: false }), state.isFetching ? React.createElement(PathNavigatorSkeleton, null) : React.createElement( React.Fragment, null, React.createElement(PathNavigatorList, { locale: 'en_US', items: state.items.map((p) => state.byId[p]), onPathSelected: onPathSelected, onItemClicked: handleItemClicked }), React.createElement(Pagination, { count: state.total, rowsPerPageOptions: [5, 10, 20], rowsPerPage: state.limit, page: state && Math.ceil(state.offset / state.limit), onRowsPerPageChange: onChangeRowsPerPage, onPageChange: onPageChanged }) ) ) ); } export default SingleItemSelector;