@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
406 lines (404 loc) • 15.6 kB
JavaScript
/*
* 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
];