UNPKG

@craftercms/studio-ui

Version:

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

558 lines (556 loc) 23.1 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 { ofType } from 'redux-observable'; import { filter, ignoreElements, map, mergeMap, switchMap, tap, throttleTime, withLatestFrom } from 'rxjs/operators'; import { pathNavigatorTreeBackgroundRefresh, pathNavigatorTreeBulkFetchPathChildren, pathNavigatorTreeBulkFetchPathChildrenComplete, pathNavigatorTreeBulkFetchPathChildrenFailed, pathNavigatorTreeBulkRefresh, pathNavigatorTreeBulkRestoreComplete, pathNavigatorTreeBulkRestoreFailed, pathNavigatorTreeCollapsePath, pathNavigatorTreeExpandPath, pathNavigatorTreeFetchPathChildren, pathNavigatorTreeFetchPathChildrenComplete, pathNavigatorTreeFetchPathChildrenFailed, pathNavigatorTreeFetchPathPage, pathNavigatorTreeFetchPathPageComplete, pathNavigatorTreeFetchPathPageFailed, pathNavigatorTreeInit, pathNavigatorTreeRefresh, pathNavigatorTreeRestore, pathNavigatorTreeRestoreComplete, pathNavigatorTreeRestoreFailed, pathNavigatorTreeRootMissing, pathNavigatorTreeSetKeyword, pathNavigatorTreeToggleCollapsed, pathNavigatorTreeUpdate } from '../actions/pathNavigatorTree'; import { checkPathExistence, fetchChildrenByPath, fetchChildrenByPaths, fetchItemsByPath } from '../../services/content'; import { catchAjaxError } from '../../utils/ajax'; import { removeStoredPathNavigatorTree, setStoredPathNavigatorTree } from '../../utils/state'; import { forkJoin, NEVER } from 'rxjs'; import { createPresenceTable } from '../../utils/array'; import { getFileExtension, getIndividualPaths, getParentPath, getRootPath, withIndex, withoutIndex } from '../../utils/path'; import { batchActions } from '../actions/misc'; import { contentEvent, deleteContentEvent, deleteContentEvents, moveContentEvent, pluginInstalled, publishEvent, workflowEvent } from '../actions/system'; import { contentAndDeleteEventForEachApplicableTree } from '../reducers/pathNavigatorTree'; import { pluckProps } from '../../utils/object'; const createGetChildrenOptions = (chunk, optionOverrides) => ({ ...pluckProps(chunk, true, 'limit', 'excludes', 'systemTypes', 'sortStrategy', 'order'), ...optionOverrides }); export default [ // region pathNavigatorTreeInit, pathNavigatorTreeRefresh, pathNavigatorTreeBackgroundRefresh (action$, state$) => action$.pipe( ofType(pathNavigatorTreeInit.type, pathNavigatorTreeRefresh.type, pathNavigatorTreeBackgroundRefresh.type), withLatestFrom(state$), filter( ([{ type, payload }, state]) => type === pathNavigatorTreeInit.type || // For pathNavigatorTreeRefresh (e.g. path is missing, refresh is pressed on the navigator // or socket refresh, fetch again to check if the path was created in the background). state.pathNavigatorTree[payload.id].isRootPathMissing ), mergeMap(([{ payload }, state]) => checkPathExistence(state.sites.active, state.pathNavigatorTree[payload.id].rootPath).pipe( map((exists) => exists ? pathNavigatorTreeRestore({ id: payload.id }) : pathNavigatorTreeRootMissing({ id: payload.id }) ) ) ) ), // endregion // region pathNavigatorTreeRestore, pathNavigatorTreeRefresh, pathNavigatorTreeBackgroundRefresh (action$, state$) => action$.pipe( ofType(pathNavigatorTreeRestore.type, pathNavigatorTreeRefresh.type, pathNavigatorTreeBackgroundRefresh.type), withLatestFrom(state$), filter(([{ payload }, state]) => !state.pathNavigatorTree[payload.id].isRootPathMissing), mergeMap(([{ payload }, state]) => { let chunk = state.pathNavigatorTree[payload.id]; let { id, path = chunk.rootPath, expanded = chunk.expanded, collapsed = chunk.collapsed, keywordByPath = chunk.keywordByPath, offsetByPath = chunk.offsetByPath, limit = chunk.limit } = payload; let paths = []; expanded.forEach((expandedPath) => { getIndividualPaths(expandedPath, path).forEach((parentPath) => { if (!paths.includes(parentPath)) { paths.push(parentPath); } }); }); // When initializing — unless there's a stored state — need to manually push the root, // so it gets loaded and pushed on to the state. if (paths.length === 0) { paths.push(state.pathNavigatorTree[id].rootPath); } return forkJoin([ fetchItemsByPath(state.sites.active, paths, { castAsDetailedItem: true }), fetchChildrenByPaths( state.sites.active, createPresenceTable(expanded, (value) => ({ ...(keywordByPath[value] ? { keyword: keywordByPath[value] } : {}), ...(offsetByPath[value] ? { limit: limit + offsetByPath[value] } : {}) })), createGetChildrenOptions(chunk, pluckProps(payload, true, 'limit', 'excludes')) ) ]).pipe( map(([items, children]) => { let updatedExpanded = expanded; if (items.missingItems.length) { // remove items.missingItems from expanded updatedExpanded = expanded.filter((expandedPath) => !items.missingItems.includes(expandedPath)); const uuid = state.sites.byId[state.sites.active].uuid; setStoredPathNavigatorTree(uuid, state.user.username, id, { expanded: updatedExpanded, collapsed: state.pathNavigatorTree[id].collapsed, keywordByPath: state.pathNavigatorTree[id].keywordByPath }); } return pathNavigatorTreeRestoreComplete({ id, expanded: updatedExpanded, collapsed, items, children }); }), catchAjaxError((error) => { if (error.status === 404) { const uuid = state.sites.byId[state.sites.active].uuid; setStoredPathNavigatorTree(uuid, state.user.username, id, { expanded: state.pathNavigatorTree[id].expanded, collapsed: state.pathNavigatorTree[id].collapsed, keywordByPath: state.pathNavigatorTree[id].keywordByPath }); return batchActions([pathNavigatorTreeUpdate({ id, expanded: [] }), pathNavigatorTreeRefresh({ id })]); } else { return pathNavigatorTreeRestoreFailed({ error, id }); } }) ); }) ), // endregion // region pathNavigatorTreeBulkBackgroundRefresh (action$, state$) => action$.pipe( // TODO: There's no special handling for background vs visible. Should there be? ofType(pathNavigatorTreeBulkRefresh.type), withLatestFrom(state$), mergeMap(([{ payload }, state]) => { const { requests } = payload; let paths = []; let optionsByPath = {}; // For each tree, get the paths of the expanded nodes that will be retrieved, and the options for the children // that will be fetched requests.forEach(({ id }) => { const chunk = state.pathNavigatorTree[id]; const { expanded, keywordByPath, offsetByPath, limit } = chunk; expanded.forEach((expandedPath) => { getIndividualPaths(expandedPath, chunk.rootPath).forEach((parentPath) => { if (!paths.includes(parentPath)) { paths.push(parentPath); } }); }); if (!expanded.length) { paths.push(chunk.rootPath); } optionsByPath = { ...optionsByPath, ...createPresenceTable(expanded, (value) => ({ ...createGetChildrenOptions(chunk, pluckProps(payload, true, 'limit', 'excludes')), ...(keywordByPath[value] ? { keyword: keywordByPath[value] } : {}), ...(offsetByPath[value] ? { limit: limit + offsetByPath[value] } : {}) })) }; }); return requests.length ? forkJoin([ fetchItemsByPath(state.sites.active, paths, { castAsDetailedItem: true }), fetchChildrenByPaths(state.sites.active, optionsByPath) ]).pipe( map(([items, children]) => { const trees = []; requests.forEach(({ id }) => { let updatedExpanded = state.pathNavigatorTree[id].expanded; if (items.missingItems.length) { updatedExpanded = state.pathNavigatorTree[id].expanded.filter( (expandedPath) => !items.missingItems.includes(expandedPath) ); const uuid = state.sites.byId[state.sites.active].uuid; setStoredPathNavigatorTree(uuid, state.user.username, id, { expanded: updatedExpanded, collapsed: state.pathNavigatorTree[id].collapsed, keywordByPath: state.pathNavigatorTree[id].keywordByPath }); } // Filter children, only keep those that are children of the rootPath of the current tree const treeChildrenByPath = {}; Object.entries(children).forEach(([parentPath, children]) => { if (parentPath.startsWith(withoutIndex(state.pathNavigatorTree[id].rootPath))) { treeChildrenByPath[parentPath] = children; } }); // Add the restored tree, containing the filtered items and children for the current tree. trees.push({ id, expanded: updatedExpanded, collapsed: state.pathNavigatorTree[id].collapsed, items: items.filter((item) => item.path.startsWith(withoutIndex(state.pathNavigatorTree[id].rootPath)) ), children: treeChildrenByPath }); }); return pathNavigatorTreeBulkRestoreComplete({ trees }); }), catchAjaxError((error) => pathNavigatorTreeBulkRestoreFailed({ ids: requests.map(({ id }) => id), error }) ) ) : NEVER; }) ), // endregion // region pathNavigatorFetchPathChildren (action$, state$) => action$.pipe( ofType(pathNavigatorTreeFetchPathChildren.type), withLatestFrom(state$), mergeMap(([{ payload }, state]) => { const { id, path, options } = payload; const chunk = state.pathNavigatorTree[id]; const finalOptions = createGetChildrenOptions(chunk, options); return fetchChildrenByPath(state.sites.active, path, { ...finalOptions, ...(chunk.offsetByPath[path] && { limit: chunk.limit + chunk.offsetByPath[path] }) }).pipe( map((children) => pathNavigatorTreeFetchPathChildrenComplete({ id, children, parentPath: path, options: finalOptions }) ), catchAjaxError((error) => pathNavigatorTreeFetchPathChildrenFailed({ error, id, path })) ); }) ), // endregion // region pathNavigatorTreeBulkFetchPathChildren (action$, state$) => action$.pipe( ofType(pathNavigatorTreeBulkFetchPathChildren.type), withLatestFrom(state$), mergeMap(([action, state]) => { const { requests } = action.payload; const optionsByPath = {}; requests.forEach((request) => { const chunk = state.pathNavigatorTree[request.id]; optionsByPath[request.path] = { ...createGetChildrenOptions(chunk, request.options), ...(chunk.offsetByPath[request.path] && { limit: chunk.limit + chunk.offsetByPath[request.path] }) }; }); return fetchChildrenByPaths(state.sites.active, optionsByPath).pipe( map((children) => { const paths = []; requests.forEach((item) => { paths.push({ id: item.id, children: children[item.path], parentPath: item.path, options: optionsByPath[item.path] }); }); return pathNavigatorTreeBulkFetchPathChildrenComplete({ paths }); }), catchAjaxError((error) => pathNavigatorTreeBulkFetchPathChildrenFailed({ ids: requests.map((item) => item.id), error }) ) ); }) ), // endregion // region pathNavigatorTreeSetKeyword (action$, state$) => action$.pipe( ofType(pathNavigatorTreeSetKeyword.type), withLatestFrom(state$), switchMap(([{ payload }, state]) => { const { id, path, keyword } = payload; const chunk = state.pathNavigatorTree[id]; const options = createGetChildrenOptions(chunk, { keyword }); return fetchChildrenByPath(state.sites.active, path, options).pipe( map((children) => pathNavigatorTreeFetchPathChildrenComplete({ id, parentPath: path, children, options })), catchAjaxError((error) => pathNavigatorTreeFetchPathChildrenFailed({ error, id, path })) ); }) ), // endregion // region pathNavigatorTreeFetchPathPage (action$, state$) => action$.pipe( ofType(pathNavigatorTreeFetchPathPage.type), withLatestFrom(state$), mergeMap(([{ payload }, state]) => { const { id, path } = payload; const chunk = state.pathNavigatorTree[id]; const keyword = state.pathNavigatorTree[id].keywordByPath[path]; const offset = state.pathNavigatorTree[id].offsetByPath[path]; return fetchChildrenByPath( state.sites.active, path, createGetChildrenOptions(chunk, { keyword: keyword, offset: offset }) ).pipe( map((children) => pathNavigatorTreeFetchPathPageComplete({ id, parentPath: path, children, options: { keyword, offset } }) ), catchAjaxError((error) => pathNavigatorTreeFetchPathPageFailed({ error, id })) ); }) ), // endregion // region Local storage setting (action$, state$) => action$.pipe( ofType( pathNavigatorTreeCollapsePath.type, pathNavigatorTreeExpandPath.type, pathNavigatorTreeFetchPathChildrenComplete.type, pathNavigatorTreeFetchPathPageComplete.type, pathNavigatorTreeToggleCollapsed.type ), withLatestFrom(state$), tap(([{ payload }, state]) => { const { id } = payload; const { expanded, collapsed, keywordByPath } = state.pathNavigatorTree[id]; const uuid = state.sites.byId[state.sites.active].uuid; setStoredPathNavigatorTree(uuid, state.user.username, id, { expanded, collapsed, keywordByPath }); }), ignoreElements() ), // endregion // region contentEvent (action$, state$) => action$.pipe( ofType(contentEvent.type), withLatestFrom(state$), mergeMap(([action, state]) => { const actions = []; const refreshRequests = []; const idsToRefreshChildrenOnly = []; // Content Event Cases: // a. New file/folder: fetch parent // b. File/folder updated (with no `sortStrategy` or `order` configurations set up): fetch item contentAndDeleteEventForEachApplicableTree( state.pathNavigatorTree, action.payload.targetPath, (tree, targetPath, parentPathOfTargetPath) => { const id = tree.id; const rootPath = tree.rootPath; const extension = getFileExtension(targetPath); const isFile = extension === ''; const parentPath = isFile ? parentPathOfTargetPath : getParentPath(targetPath); const sortingOptionsSet = Boolean(tree.sortStrategy || tree.order); if ( // If the path corresponds to the root and the root didn't exist, root now exists tree.isRootPathMissing && (targetPath === rootPath || withIndex(targetPath) === rootPath) ) { refreshRequests.push({ id }); } else if ( // If an entry for the path exists, assume it's an update to an existing item. (targetPath in tree.totalByPath || withIndex(targetPath) in tree.totalByPath) && // If sorting options are set, the parent path needs to be updated for the sort order to be correct. !sortingOptionsSet ) { // Reloading the item done by content epics // actions.push(fetchSandboxItem({ path })); } else if ( // If an entry for the folder exists, fetch parentPath in tree.totalByPath || withIndex(parentPath) in tree.totalByPath ) { const pathToUpdate = parentPath in tree.totalByPath ? parentPath : withIndex(parentPath); // Show/fetch the new child(ren) pathToUpdate in tree.childrenByParentPath && idsToRefreshChildrenOnly.push({ id, path: pathToUpdate, expand: false }); // Update child count done by content epics. // fetchSandboxItem({ path: parentPath }) } } ); refreshRequests.length && actions.push(pathNavigatorTreeBulkRefresh({ requests: refreshRequests })); idsToRefreshChildrenOnly.length && actions.push(pathNavigatorTreeBulkFetchPathChildren({ requests: idsToRefreshChildrenOnly })); return actions; }) ), // endregion // region deleteContentEvent, deleteContentEvents (action$, state$) => action$.pipe( ofType(deleteContentEvent.type, deleteContentEvents.type), withLatestFrom(state$), tap(([, state]) => { Object.values(state.pathNavigatorTree).forEach((tree) => { tree.isRootPathMissing && removeStoredPathNavigatorTree(state.sites.byId[state.sites.active].uuid, state.user.username, tree.id); }); }), ignoreElements() ), // endregion // region moveContentEvent (action$, state$) => action$.pipe( ofType(moveContentEvent.type), withLatestFrom(state$), mergeMap(([action, state]) => { const actions = []; const targetPath = action.payload.targetPath; const sourcePath = action.payload.sourcePath; const parentPathOfTargetPath = getParentPath(targetPath); const parentPathOfSourcePath = getParentPath(sourcePath); const idsToRefresh = []; const idsToRefreshChildrenOnly = []; Object.values(state.pathNavigatorTree).forEach((tree) => { const id = tree.id; if ( // The missing path got created. tree.isRootPathMissing && tree.rootPath === targetPath ) { idsToRefresh.push({ id }); } else { [parentPathOfTargetPath, parentPathOfSourcePath].forEach((path) => { if ( // If in totalByPath is an item that has been loaded and must update... // path in totalByPath may be a page, and its path has index.xml, so it needs to be validated too. path in tree.totalByPath || withIndex(path) in tree.totalByPath ) { // Get correct path to fetch (may include index.xml) const pathToFetch = path in tree.totalByPath ? path : withIndex(path); // If its children are loaded, then re-fetch to get the new if (tree.childrenByParentPath[pathToFetch]) { idsToRefreshChildrenOnly.push({ id, path: pathToFetch, expand: false }); } // Re-fetching the item done by content epics. // fetchSandboxItem({ path: path }) } }); } }); idsToRefresh.length && actions.push(pathNavigatorTreeBulkRefresh({ requests: idsToRefresh })); idsToRefreshChildrenOnly.length && actions.push(pathNavigatorTreeBulkFetchPathChildren({ requests: idsToRefreshChildrenOnly })); return actions; }) ), // endregion // region pluginInstalled // Can't be smart about this one given the level of information the event provides. (action$, state$) => action$.pipe( ofType(pluginInstalled.type), throttleTime(500), withLatestFrom(state$), switchMap(([, state]) => { const requests = []; Object.values(state.pathNavigatorTree).forEach((tree) => { if (['/templates', '/scripts', '/static-assets'].includes(getRootPath(tree.rootPath))) { requests.push({ id: tree.id, backgroundRefresh: true }); } }); return requests.length ? [pathNavigatorTreeBulkRefresh({ requests: requests })] : NEVER; }) ), // endregion // region workflowEvent, publishEvent // Can't be smart about these given the level of information the events provide. (action$, state$) => action$.pipe( ofType(workflowEvent.type, publishEvent.type), throttleTime(500), withLatestFrom(state$), map(([, state]) => pathNavigatorTreeBulkRefresh({ requests: Object.keys(state.pathNavigatorTree).map((id) => ({ id, backgroundRefresh: true })) }) ) ) // endregion ];