diff --git a/studio-ui/ui/app/src/components/BrowseFilesDialog/BrowseFilesDialogContainer.tsx b/studio-ui/ui/app/src/components/BrowseFilesDialog/BrowseFilesDialogContainer.tsx index 5ad410cbe..dc0747825 100644 --- a/studio-ui/ui/app/src/components/BrowseFilesDialog/BrowseFilesDialogContainer.tsx +++ b/studio-ui/ui/app/src/components/BrowseFilesDialog/BrowseFilesDialogContainer.tsx @@ -24,7 +24,7 @@ import { useSpreadState } from '../../hooks/useSpreadState'; import { useDispatch } from 'react-redux'; import LookupTable from '../../models/LookupTable'; import { BrowseFilesDialogUI, viewModes } from '.'; -import { BrowseFilesDialogContainerProps, initialParameters } from './utils'; +import { BrowseFilesDialogContainerProps, contentItemToMediaItem, initialParameters } from './utils'; import { checkPathExistence } from '../../services/content'; import { FormattedMessage } from 'react-intl'; import EmptyState from '../EmptyState'; @@ -40,6 +40,8 @@ import { popDialog, pushDialog } from '../../state/actions/dialogStack'; import { nanoid } from 'nanoid'; import { createComponentId } from '../../utils/system'; +import { useItemsByPath } from '../../hooks/useItemsByPath'; +import { lookupItemByPath } from '../../utils/content'; const defaultPreselectedPaths = []; @@ -82,6 +84,7 @@ export function BrowseFilesDialogContainer(props: BrowseFilesDialogContainerProp (items?.length > 0 && selectedInCurrentPage.length > 0 && selectedInCurrentPage.length < items?.length) ?? false; const browsePath = path.replace(/\/+$/, ''); const [currentPath, setCurrentPath] = useState(browsePath); + const [treeSelectedPath, setTreeSelectedPath] = useState(); const [fetchingBrowsePathExists, setFetchingBrowsePathExists] = useState(false); const [browsePathExists, setBrowsePathExists] = useState(false); const [sortKeys, setSortKeys] = useState([]); @@ -90,6 +93,8 @@ export function BrowseFilesDialogContainer(props: BrowseFilesDialogContainerProp const [fetchingPreselectedItems, setFetchingPreselectedItems] = useState(false); const disableSubmission = fetchingPreselectedItems || (!selectedArray.length && !selectedCard); const preselectedLookup = createPresenceTable(preselectedPaths); + const itemsByPath = useItemsByPath(); + const isCurrentPathLeaf = Boolean(treeSelectedPath); // treeSelectedPath is set when a leaf page is selected in the tree view const fetchItems = useCallback(() => { // Since lookahead regex is not supported by opensearch, we are excluding the current path from the search using a @@ -195,7 +200,28 @@ export function BrowseFilesDialogContainer(props: BrowseFilesDialogContainerProp }; const onPathSelected = (path: string) => { - setCurrentPath(withoutIndex(path)); + const item = lookupItemByPath(path, itemsByPath); + const nextPath = withoutIndex(path); + setCurrentPath(nextPath); + + // If the selected path is a page and has no children, select the page itself. + if (item?.systemType === 'page' && item.childrenCount === 0) { + const mediaItem = items?.find((searchItem) => searchItem.path === item.path) ?? contentItemToMediaItem(item); + multiSelect ? replaceSelectedLookup({ [mediaItem.path]: mediaItem }) : setSelectedCard(mediaItem); + setTreeSelectedPath(withoutIndex(mediaItem.path)); + } else if (treeSelectedPath) { + multiSelect ? replaceSelectedLookup() : setSelectedCard(null); + setTreeSelectedPath(null); + } + }; + + const replaceSelectedLookup = (lookup?: LookupTable) => { + const cleared = Object.fromEntries(Object.keys(selectedLookup).map((key) => [key, null])); + if (!lookup || Object.keys(lookup).length === 0) { + setSelectedLookup(cleared); + } else { + setSelectedLookup({ ...cleared, ...lookup }); + } }; const onCloseButtonClick = (e: React.MouseEvent) => onClose(e, null); @@ -266,6 +292,7 @@ export function BrowseFilesDialogContainer(props: BrowseFilesDialogContainerProp handleSearchKeyword={handleSearchKeyword} onCloseButtonClick={onCloseButtonClick} onPathSelected={onPathSelected} + treeSelectedPath={treeSelectedPath} onSelectButtonClick={onSelectButtonClick} numOfLoaderItems={numOfLoaderItems} onRefresh={onRefresh} @@ -277,6 +304,7 @@ export function BrowseFilesDialogContainer(props: BrowseFilesDialogContainerProp onSelectAll={onSelectAll} allSelected={allSelectedInCurrentPage} someSelected={someSelectedInCurrentPage} + isCurrentPathLeaf={isCurrentPathLeaf} /> ) : ( - - - + theme.spacing(1) - }} - display="flex" - flexDirection="column" - rowGap="20px" - > - - - + overflowY: 'auto', + overflowX: 'hidden' + } + }} + > + + + theme.spacing(1), @@ -355,14 +385,20 @@ export function BrowseFilesDialogUI(props: BrowseFilesDialogUIProps) { }) : new Array(numOfLoaderItems).fill(null).map((x, i) => )} - {items && items.length === 0 && ( - } - /> - )} + {items && + items.length === 0 && + (isCurrentPathLeaf ? ( + } + /> + ) : ( + } + /> + ))} - diff --git a/studio-ui/ui/app/src/components/BrowseFilesDialog/utils.ts b/studio-ui/ui/app/src/components/BrowseFilesDialog/utils.ts index 03c866195..57e8d8d0c 100644 --- a/studio-ui/ui/app/src/components/BrowseFilesDialog/utils.ts +++ b/studio-ui/ui/app/src/components/BrowseFilesDialog/utils.ts @@ -15,6 +15,7 @@ */ import { ElasticParams, MediaItem, SearchItem } from '../../models/Search'; +import { ContentItem } from '../../models/Item'; import StandardAction from '../../models/StandardAction'; import { EnhancedDialogProps } from '../EnhancedDialog'; import React from 'react'; @@ -71,11 +72,13 @@ export interface BrowseFilesDialogUIProps { disableSubmission?: boolean; allSelected: boolean; someSelected: boolean; + isCurrentPathLeaf: boolean; onCardSelected(item: MediaItem): void; onPreviewImage?(item: MediaItem): void; onCheckboxChecked(path: string, selected: boolean): void; handleSearchKeyword(keyword: string): void; - onPathSelected(path: string): void; + onPathSelected(path: string, item?: ContentItem): void; + treeSelectedPath: string; onSelectButtonClick(): void; onChangePage(page: number): void; onChangeRowsPerPage(event): void; @@ -97,3 +100,17 @@ export const initialParameters: ElasticParams = { }; export const viewModes: MediaCardViewModes[] = ['card', 'compact', 'row']; + +export function contentItemToMediaItem(item: ContentItem): MediaItem { + return { + path: item.path, + name: item.label, + type: item.contentTypeId, + mimeType: item.mimeType ?? '', + previewUrl: item.previewUrl ?? '', + lastModifier: item.modifier?.username ?? '', + lastModified: item.dateModified ?? '', + size: 0, + snippets: '' + }; +} diff --git a/studio-ui/ui/app/src/components/FolderBrowserTreeView/FolderBrowserTreeView.tsx b/studio-ui/ui/app/src/components/FolderBrowserTreeView/FolderBrowserTreeView.tsx index fdc5c2ac4..afc2832b9 100644 --- a/studio-ui/ui/app/src/components/FolderBrowserTreeView/FolderBrowserTreeView.tsx +++ b/studio-ui/ui/app/src/components/FolderBrowserTreeView/FolderBrowserTreeView.tsx @@ -15,7 +15,7 @@ */ // @ts-ignore - React typings haven't been updated to include react 18 hooks -import React, { useEffect, useId } from 'react'; +import React, { useCallback, useEffect, useId, useRef } from 'react'; import useActiveSite from '../../hooks/useActiveSite'; import { PathNavigatorTree } from '../PathNavigatorTree'; import { removeStoredPathNavigatorTree } from '../../utils/state'; @@ -23,7 +23,6 @@ import useActiveUser from '../../hooks/useActiveUser'; import { useDispatch } from 'react-redux'; import { pathNavigatorTreeExpandPath, pathNavigatorTreeFetchPathChildren } from '../../state/actions/pathNavigatorTree'; import { getIndividualPaths, withIndex } from '../../utils/path'; -import { forkJoin, of } from 'rxjs'; import { batchActions } from '../../state/actions/misc'; import useSelection from '../../hooks/useSelection'; import useUpdateRefs from '../../hooks/useUpdateRefs'; @@ -32,11 +31,12 @@ import { useIntl } from 'react-intl'; export interface FolderBrowserTreeViewProps { rootPath: string; selectedPath: string; + highlightedPath?: string; onPathSelected(path: string): void; } export function FolderBrowserTreeView(props: FolderBrowserTreeViewProps) { - const { rootPath, selectedPath, onPathSelected } = props; + const { rootPath, selectedPath, highlightedPath, onPathSelected } = props; const { formatMessage } = useIntl(); const id = useId(); const tree = useSelection((state) => state.pathNavigatorTree[id]); @@ -44,6 +44,7 @@ export function FolderBrowserTreeView(props: FolderBrowserTreeViewProps) { const { username } = useActiveUser(); const dispatch = useDispatch(); const selectedPathWithIndex = withIndex(selectedPath); + const pendingChildFetchPathsRef = useRef>(new Set()); const refs = useUpdateRefs({ tree }); useEffect(() => { if ( @@ -51,33 +52,24 @@ export function FolderBrowserTreeView(props: FolderBrowserTreeViewProps) { // avoid changes on its state to trigger this effect unnecessarily. tree?.id === id ) { + const chunk = refs.current.tree; const path = selectedPath || rootPath; - // If it's `/site/website/*`, there's possibility of `index.xml` behaviours - if (path.startsWith('/site/website')) { - const paths = getIndividualPaths(path, rootPath); - forkJoin( - paths.map((p) => { + const actions = path.startsWith('/site/website') + ? getIndividualPaths(path, rootPath).map((p) => { const withIndexXml = withIndex(p); - return withIndexXml in refs.current.tree.childrenByParentPath || p in refs.current.tree.childrenByParentPath - ? of( - pathNavigatorTreeExpandPath({ - id, - path: withIndexXml in refs.current.tree.childrenByParentPath ? withIndexXml : p - }) - ) - : of(pathNavigatorTreeFetchPathChildren({ id, path: p, expand: true })); + return withIndexXml in chunk.childrenByParentPath || p in chunk.childrenByParentPath + ? pathNavigatorTreeExpandPath({ + id, + path: withIndexXml in chunk.childrenByParentPath ? withIndexXml : p + }) + : pathNavigatorTreeFetchPathChildren({ id, path: p, expand: true }); }) - ).subscribe((actions) => { - dispatch(actions.length === 1 ? actions[0] : batchActions(actions)); - }); - } else { - const actions = getIndividualPaths(path, rootPath).map((p) => - p in refs.current.tree.childrenByParentPath - ? pathNavigatorTreeExpandPath({ id, path: p }) - : pathNavigatorTreeFetchPathChildren({ id, path: p, expand: true }) - ); - actions.length && dispatch(actions.length === 1 ? actions[0] : batchActions(actions)); - } + : getIndividualPaths(path, rootPath).map((p) => + p in chunk.childrenByParentPath + ? pathNavigatorTreeExpandPath({ id, path: p }) + : pathNavigatorTreeFetchPathChildren({ id, path: p, expand: true }) + ); + actions.length && dispatch(actions.length === 1 ? actions[0] : batchActions(actions)); } }, [refs, dispatch, id, rootPath, selectedPath, siteId, tree?.id]); useEffect(() => { @@ -85,6 +77,44 @@ export function FolderBrowserTreeView(props: FolderBrowserTreeViewProps) { removeStoredPathNavigatorTree(uuid, username, id); }; }, [id, uuid, username]); + + const handleNodeClick = useCallback( + (event: React.MouseEvent, path: string) => { + onPathSelected?.(path); + if (tree?.id !== id) { + return; + } + const withIndexXml = withIndex(path); + const isExpanded = tree.expanded.includes(path) || tree.expanded.includes(withIndexXml); + const childCount = tree.totalByPath[path] ?? tree.totalByPath[withIndexXml] ?? 0; + if (childCount <= 0) { + return; + } + const childrenLoaded = path in tree.childrenByParentPath || withIndexXml in tree.childrenByParentPath; + if (childrenLoaded) { + if (!isExpanded) { + dispatch( + pathNavigatorTreeExpandPath({ + id, + path: withIndexXml in tree.childrenByParentPath ? withIndexXml : path + }) + ); + } + return; + } + const fetchError = tree.errorByPath[path]; + if (fetchError) { + pendingChildFetchPathsRef.current.delete(path); + } + if ((!fetchError && isExpanded) || pendingChildFetchPathsRef.current.has(path)) { + return; + } + pendingChildFetchPathsRef.current.add(path); + dispatch(pathNavigatorTreeFetchPathChildren({ id, path, expand: true })); + }, + [dispatch, id, onPathSelected, tree] + ); + return ( onPathSelected?.(path)} + onNodeClick={handleNodeClick} sxs={{ - header: { '.MuiTypography-root': { fontWeight: 'bold' } } + header: { '.MuiTypography-root': { fontWeight: 'bold' } }, + activeItem: + selectedPath === highlightedPath + ? { boxShadow: (theme) => `0px 0px 2px 2px ${theme.palette.primary.main}`, borderRadius: '2px' } + : {} }} showNavigableAsLinks={false} showPublishingTarget={false} diff --git a/studio-ui/ui/app/src/components/PathNavigatorTree/PathNavigatorTree.tsx b/studio-ui/ui/app/src/components/PathNavigatorTree/PathNavigatorTree.tsx index 7d001841f..30cbbacf3 100644 --- a/studio-ui/ui/app/src/components/PathNavigatorTree/PathNavigatorTree.tsx +++ b/studio-ui/ui/app/src/components/PathNavigatorTree/PathNavigatorTree.tsx @@ -66,11 +66,10 @@ import usePossibleTranslation from '../../hooks/usePossibleTranslation'; import TranslationOrText from '../../models/TranslationOrText'; import { useIntl } from 'react-intl'; -export interface PathNavigatorTreeProps - extends Pick< - PathNavigatorTreeItemProps, - 'showNavigableAsLinks' | 'showPublishingTarget' | 'showWorkflowState' | 'showItemMenu' - > { +export interface PathNavigatorTreeProps extends Pick< + PathNavigatorTreeItemProps, + 'showNavigableAsLinks' | 'showPublishingTarget' | 'showWorkflowState' | 'showItemMenu' +> { id: string; label: TranslationOrText; rootPath: string; @@ -89,7 +88,7 @@ export interface PathNavigatorTreeProps onNodeClick?: PathNavigatorTreeUIProps['onLabelClick']; active?: PathNavigatorTreeItemProps['active']; classes?: Partial>; - sxs?: PartialSxRecord<'header'>; + sxs?: PartialSxRecord<'header' | 'activeItem'>; } export interface PathNavigatorTreeStateProps { @@ -353,7 +352,7 @@ export function PathNavigatorTree(props: PathNavigatorTreeProps) { <> .${treeItemClasses.content} > .${treeItemClasses.label}`]: { - ...(active[path] ? { backgroundColor: (theme) => theme.palette.action.selected } : {}) + ...(active[path] + ? { + backgroundColor: (theme) => theme.palette.action.selected, + ...sxs?.activeItem + } + : {}) }, [`& .${treeItemClasses.iconContainer}`]: { width: '26px', diff --git a/studio-ui/ui/app/src/components/PathNavigatorTree/PathNavigatorTreeUI.tsx b/studio-ui/ui/app/src/components/PathNavigatorTree/PathNavigatorTreeUI.tsx index 4d9db9ebb..9e1c02eb6 100644 --- a/studio-ui/ui/app/src/components/PathNavigatorTree/PathNavigatorTreeUI.tsx +++ b/studio-ui/ui/app/src/components/PathNavigatorTree/PathNavigatorTreeUI.tsx @@ -29,7 +29,7 @@ import { ErrorState } from '../ErrorState'; import { SimpleTreeView } from '@mui/x-tree-view'; import { PartialSxRecord } from '../../models'; -export type PathNavigatorTreeUIClassKey = 'root' | 'body' | 'header'; +export type PathNavigatorTreeUIClassKey = 'root' | 'body' | 'header' | 'activeItem'; export interface PathNavigatorTreeUIProps extends Pick< @@ -145,6 +145,7 @@ export function PathNavigatorTreeUI(props: PathNavigatorTreeUIProps) {