UNPKG

@craftercms/studio-ui

Version:

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

406 lines (404 loc) 15.6 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, 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 } from 'rxjs'; import { createPresenceTable } from '../../utils/array'; import { getFileExtension, getIndividualPaths, getParentPath, getRootPath } from '../../utils/path'; import { batchActions } from '../actions/misc'; import { contentEvent, deleteContentEvent, moveContentEvent, pluginInstalled, publishEvent, workflowEvent } from '../actions/system'; import { contentAndDeleteEventForEachApplicableTree } from '../reducers/pathNavigatorTree'; import { pluckProps } from '../../utils/object'; const createGetChildrenOptions = (chunk, optionOverrides) => Object.assign(Object.assign({}, pluckProps(chunk, true, 'limit', 'excludes', 'systemTypes')), 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) => Object.assign( Object.assign({}, keywordByPath[value] ? { keyword: keywordByPath[value] } : {}), offsetByPath[value] ? { limit: limit + offsetByPath[value] } : {} ) ), createGetChildrenOptions(chunk, pluckProps(payload, true, 'limit', 'excludes')) ) ]).pipe( map(([items, children]) => pathNavigatorTreeRestoreComplete({ id, expanded, 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 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, Object.assign( Object.assign({}, 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 })) ); }) ), // 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 })) ); }) ), // 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 = []; // Content Event Cases: // a. New file/folder: fetch parent // b. File/folder updated: 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); if ( // If the path corresponds to the root and the root didn't exist, root now exists tree.isRootPathMissing && targetPath === rootPath ) { actions.push(pathNavigatorTreeRefresh({ id })); } else if ( // If an entry for the path exists, assume it's an update to an existing item targetPath in tree.totalByPath ) { // 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 ) { // Show the new child parentPath in tree.childrenByParentPath && actions.push(pathNavigatorTreeFetchPathChildren({ id, path: parentPath, expand: false })); // Update child count done by content epics. // fetchSandboxItem({ path: parentPath }) } } ); return actions; }) ), // endregion // region deleteContentEvent (action$, state$) => action$.pipe( ofType(deleteContentEvent.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); Object.values(state.pathNavigatorTree).forEach((tree) => { const id = tree.id; if ( // The missing path got created. tree.isRootPathMissing && tree.rootPath === targetPath ) { actions.push(pathNavigatorTreeRefresh({ id })); } else { [parentPathOfTargetPath, parentPathOfSourcePath].forEach((path) => { if ( // If in totalByPath is an item that has been loaded and must update... path in tree.totalByPath ) { // If its children are loaded, then re-fetch to get the new tree.childrenByParentPath[path] && actions.push( pathNavigatorTreeFetchPathChildren({ id: id, path: path, expand: false }) ); // Re-fetching the item done by content epics. // fetchSandboxItem({ path: path }) } }); } }); 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$), mergeMap(([, state]) => { const actions = []; Object.values(state.pathNavigatorTree).forEach((tree) => { if (['/templates', '/scripts', '/static-assets'].includes(getRootPath(tree.rootPath))) { actions.push(pathNavigatorTreeBackgroundRefresh({ id: tree.id })); } }); return actions; }) ), // 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$), mergeMap(([, state]) => { const actions = []; Object.values(state.pathNavigatorTree).forEach((tree) => { actions.push(pathNavigatorTreeBackgroundRefresh({ id: tree.id })); }); return actions; }) ) // endregion ];