Merge branch '21128-toolbar-context-menu'
[arvados-workbench2.git] / src / store / tree-picker / tree-picker-actions.ts
index 6b9be5396396a1421fbe603f9d7cb6bd8ef851b4..883847d85464e7f374504118c50bba935872de4f 100644 (file)
@@ -2,47 +2,72 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { unionize, ofType, UnionOf } from "~/common/unionize";
-import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree } from '~/models/tree';
-import { createCollectionFilesTree } from "~/models/collection-file";
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree, setNode, createTree } from 'models/tree';
+import { CollectionFileType, createCollectionFilesTree, getCollectionResourceCollectionUuid } from "models/collection-file";
 import { Dispatch } from 'redux';
-import { RootState } from '~/store/store';
-import { getUserUuid } from "~/common/getuser";
-import { ServiceRepository } from '~/services/services';
-import { FilterBuilder } from '~/services/api/filter-builder';
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { ServiceRepository } from 'services/services';
+import { FilterBuilder } from 'services/api/filter-builder';
 import { pipe, values } from 'lodash/fp';
-import { ResourceKind } from '~/models/resource';
-import { GroupContentsResource } from '~/services/groups-service/groups-service';
+import { ResourceKind } from 'models/resource';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
 import { getTreePicker, TreePicker } from './tree-picker';
-import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
-import { OrderBuilder } from '~/services/api/order-builder';
-import { ProjectResource } from '~/models/project';
+import { ProjectsTreePickerItem } from './tree-picker-middleware';
+import { OrderBuilder } from 'services/api/order-builder';
+import { ProjectResource } from 'models/project';
 import { mapTree } from '../../models/tree';
-import { LinkResource, LinkClass } from "~/models/link";
-import { mapTreeValues } from "~/models/tree";
-import { sortFilesTree } from "~/services/collection-service/collection-service-files-response";
+import { LinkResource, LinkClass } from "models/link";
+import { mapTreeValues } from "models/tree";
+import { sortFilesTree } from "services/collection-service/collection-service-files-response";
+import { GroupClass, GroupResource } from "models/group";
+import { CollectionResource } from "models/collection";
+import { getResource } from "store/resources/resources";
+import { updateResources } from "store/resources/resources-actions";
+import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions";
 
 export const treePickerActions = unionize({
     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
     LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
     APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
     TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
+    EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
+    EXPAND_TREE_PICKER_NODE_ANCESTORS: ofType<{ id: string, pickerId: string }>(),
     ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
     DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
-    TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string }>(),
-    SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
-    DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
+    TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string, cascade: boolean }>(),
+    SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
+    DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
     EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
     RESET_TREE_PICKER: ofType<{ pickerId: string }>()
 });
 
 export type TreePickerAction = UnionOf<typeof treePickerActions>;
 
+export interface LoadProjectParams {
+    includeCollections?: boolean;
+    includeDirectories?: boolean;
+    includeFiles?: boolean;
+    includeFilterGroups?: boolean;
+    options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
+}
+
+export const treePickerSearchActions = unionize({
+    SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
+    SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(),
+    SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(),
+    REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(),
+});
+
+export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
+
 export const getProjectsTreePickerIds = (pickerId: string) => ({
     home: `${pickerId}_home`,
     shared: `${pickerId}_shared`,
     favorites: `${pickerId}_favorites`,
-    publicFavorites: `${pickerId}_publicFavorites`
+    publicFavorites: `${pickerId}_publicFavorites`,
+    search: `${pickerId}_search`,
 });
 
 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
@@ -68,13 +93,31 @@ export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Va
 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
     getAllNodes<Value>(pickerId, node => node.selected)(state);
 
-export const initProjectsTreePicker = (pickerId: string) =>
-    async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
-        const { home, shared, favorites, publicFavorites } = getProjectsTreePickerIds(pickerId);
+interface TreePickerPreloadParams {
+    selectedItemUuids: string[];
+    includeDirectories: boolean;
+    includeFiles: boolean;
+    multi: boolean;
+}
+
+export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
         dispatch<any>(initUserProject(home));
         dispatch<any>(initSharedProject(shared));
         dispatch<any>(initFavoritesProject(favorites));
         dispatch<any>(initPublicFavoritesProject(publicFavorites));
+        dispatch<any>(initSearchProject(search));
+
+        if (preloadParams && preloadParams.selectedItemUuids.length) {
+            await dispatch<any>(loadInitialValue(
+                preloadParams.selectedItemUuids,
+                pickerId,
+                preloadParams.includeDirectories,
+                preloadParams.includeFiles,
+                preloadParams.multi
+            ));
+        }
     };
 
 interface ReceiveTreePickerDataParams<T> {
@@ -92,48 +135,117 @@ export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>)
             nodes: data.map(item => initTreeNode(extractNodeData(item))),
             pickerId,
         }));
-        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
+        dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
     };
 
-interface LoadProjectParams {
+interface LoadProjectParamsWithId extends LoadProjectParams {
     id: string;
     pickerId: string;
-    includeCollections?: boolean;
-    includeFiles?: boolean;
     loadShared?: boolean;
+    searchProjects?: boolean;
 }
-export const loadProject = (params: LoadProjectParams) =>
-    async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
-        const { id, pickerId, includeCollections = false, includeFiles = false, loadShared = false } = params;
+
+/**
+ * loadProject is used to load or refresh a project node in a tree picker
+ *   Errors are caught and a toast is shown if the project fails to load
+ */
+export const loadProject = (params: LoadProjectParamsWithId) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const {
+            id,
+            pickerId,
+            includeCollections = false,
+            includeDirectories = false,
+            includeFiles = false,
+            includeFilterGroups = false,
+            loadShared = false,
+            options,
+            searchProjects = false
+        } = params;
 
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
 
-        const filters = pipe(
-            (fb: FilterBuilder) => includeCollections
-                ? fb.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
-                : fb.addIsA('uuid', [ResourceKind.PROJECT]),
-            fb => fb.getFilters(),
-        )(new FilterBuilder());
+        let filterB = new FilterBuilder();
 
-        const { items } = await services.groupsService.contents(loadShared ? '' : id, { filters, excludeHomeProject: loadShared || undefined });
+        filterB = (includeCollections && !searchProjects)
+            ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
+            : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
 
-        dispatch<any>(receiveTreePickerData<GroupContentsResource>({
-            id,
-            pickerId,
-            data: items,
-            extractNodeData: item => ({
-                id: item.uuid,
-                value: item,
-                status: item.kind === ResourceKind.PROJECT
-                    ? TreeNodeStatus.INITIAL
-                    : includeFiles
-                        ? TreeNodeStatus.INITIAL
-                        : TreeNodeStatus.LOADED
-            }),
-        }));
+        const state = getState();
+
+        if (state.treePickerSearch.collectionFilterValues[pickerId]) {
+            filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
+        } else {
+            filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
+        }
+
+        if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
+            filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
+        }
+
+        const filters = filterB.getFilters();
+
+        const itemLimit = 200;
+
+        try {
+            const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
+            dispatch<any>(updateResources(items));
+
+            if (itemsAvailable > itemLimit) {
+                items.push({
+                    uuid: "more-items-available",
+                    kind: ResourceKind.WORKFLOW,
+                    name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
+                    description: "",
+                    definition: "",
+                    ownerUuid: "",
+                    createdAt: "",
+                    modifiedByClientUuid: "",
+                    modifiedByUserUuid: "",
+                    modifiedAt: "",
+                    href: "",
+                    etag: ""
+                });
+            }
+
+            dispatch<any>(receiveTreePickerData<GroupContentsResource>({
+                id,
+                pickerId,
+                data: items.filter((item) => {
+                    if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
+                        return false;
+                    }
+
+                    if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+                        return false;
+                    }
+
+                    return true;
+                }),
+                extractNodeData: item => (
+                    item.uuid === "more-items-available" ?
+                        {
+                            id: item.uuid,
+                            value: item,
+                            status: TreeNodeStatus.LOADED
+                        }
+                        : {
+                            id: item.uuid,
+                            value: item,
+                            status: item.kind === ResourceKind.PROJECT
+                                ? TreeNodeStatus.INITIAL
+                                : includeDirectories || includeFiles
+                                    ? TreeNodeStatus.INITIAL
+                                    : TreeNodeStatus.LOADED
+                        }),
+            }));
+        } catch(e) {
+            console.error("Failed to load project into tree picker:", e);;
+            dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR }));
+        }
     };
 
-export const loadCollection = (id: string, pickerId: string) =>
+export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
 
@@ -142,25 +254,30 @@ export const loadCollection = (id: string, pickerId: string) =>
 
             const node = getNode(id)(picker);
             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
-
-                const files = await services.collectionService.files(node.value.portableDataHash);
+                const files = (await services.collectionService.files(node.value.uuid))
+                    .filter((file) => (
+                        (includeFiles) ||
+                        (includeDirectories && file.type === CollectionFileType.DIRECTORY)
+                    ));
                 const tree = createCollectionFilesTree(files);
                 const sorted = sortFilesTree(tree);
                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
 
-                dispatch(
+                // await tree modifications so that consumers can guarantee node presence
+                await dispatch(
                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
                         id,
                         pickerId,
                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
                     }));
 
+                // Expand collection root node
                 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
             }
         }
     };
 
-
+export const HOME_PROJECT_ID = 'Home Projects';
 export const initUserProject = (pickerId: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const uuid = getUserUuid(getState());
@@ -168,7 +285,7 @@ export const initUserProject = (pickerId: string) =>
             dispatch(receiveTreePickerData({
                 id: '',
                 pickerId,
-                data: [{ uuid, name: 'Projects' }],
+                data: [{ uuid, name: HOME_PROJECT_ID }],
                 extractNodeData: value => ({
                     id: value.uuid,
                     status: TreeNodeStatus.INITIAL,
@@ -177,11 +294,11 @@ export const initUserProject = (pickerId: string) =>
             }));
         }
     };
-export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false) =>
+export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const uuid = getUserUuid(getState());
         if (uuid) {
-            dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles }));
+            dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
         }
     };
 
@@ -200,6 +317,134 @@ export const initSharedProject = (pickerId: string) =>
         }));
     };
 
+type PickerItemPreloadData = {
+    itemId: string;
+    mainItemUuid: string;
+    ancestors: (GroupResource | CollectionResource)[];
+    isHomeProjectItem: boolean;
+}
+
+type PickerTreePreloadData = {
+    tree: Tree<GroupResource | CollectionResource>;
+    pickerTreeId: string;
+    pickerTreeRootUuid: string;
+};
+
+export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const homeUuid = getUserUuid(getState());
+
+        // Request ancestor trees in paralell and save home project status
+        const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => {
+            const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId;
+
+            const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
+            .filter(item =>
+                item.kind === ResourceKind.GROUP ||
+                item.kind === ResourceKind.COLLECTION
+            ) as (GroupResource | CollectionResource)[];
+
+            if (ancestors.length === 0) {
+                return Promise.reject({item: itemId});
+            }
+
+            const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
+
+            return {
+                itemId,
+                mainItemUuid,
+                ancestors,
+                isHomeProjectItem,
+            };
+        })).then((res) => {
+            // Show toast if any selections failed to restore
+            const rejectedPromises = res.filter((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'));
+            if (rejectedPromises.length) {
+                rejectedPromises.forEach(item => {
+                    console.error("The following item failed to load into the tree picker", item.reason);
+                });
+                dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR }));
+            }
+            // Filter out any failed promises and map to resulting preload data with ancestors
+            return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
+                promiseResult.status === 'fulfilled'
+            )).map(res => res.value)
+        });
+
+        // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
+        const initialTreePreloadData: PickerTreePreloadData[] = [
+            pickerItemsData.filter((item) => item.isHomeProjectItem),
+            pickerItemsData.filter((item) => !item.isHomeProjectItem),
+        ]
+            .filter((items) => items.length > 0)
+            .map((itemGroup) =>
+                itemGroup.reduce(
+                    (preloadTree, itemData) => ({
+                        tree: createInitialPickerTree(
+                            itemData.ancestors,
+                            itemData.mainItemUuid,
+                            preloadTree.tree
+                        ),
+                        pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
+                        pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
+                    }),
+                    {
+                        tree: createTree<GroupResource | CollectionResource>(),
+                        pickerTreeId: '',
+                        pickerTreeRootUuid: '',
+                    } as PickerTreePreloadData
+                )
+            );
+
+        // Load initial trees into corresponding picker store
+        await Promise.all(initialTreePreloadData.map(preloadTree => (
+            dispatch(
+                treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
+                    id: preloadTree.pickerTreeRootUuid,
+                    pickerId: preloadTree.pickerTreeId,
+                    subtree: preloadTree.tree,
+                })
+            )
+        )));
+
+        // Await loading collection before attempting to select items
+        await Promise.all(pickerItemsData.map(async itemData => {
+            const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
+
+            // Selected item resides in collection subpath
+            if (itemData.itemId.includes('/')) {
+                // Load collection into tree
+                // loadCollection includes more than dispatched actions and must be awaited
+                await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
+            }
+            // Expand nodes down to destination
+            dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
+        }));
+
+        // Select or activate nodes
+        pickerItemsData.forEach(itemData => {
+            const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
+
+            if (multi) {
+                dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
+            } else {
+                dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
+            }
+        });
+
+        // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
+        await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
+    }
+
+const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
+    const { home, shared } = getProjectsTreePickerIds(pickerId);
+    return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
+};
+
+const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
+    return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
+};
+
 export const FAVORITES_PROJECT_ID = 'Favorites';
 export const initFavoritesProject = (pickerId: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
@@ -230,15 +475,34 @@ export const initPublicFavoritesProject = (pickerId: string) =>
         }));
     };
 
+export const SEARCH_PROJECT_ID = 'Search all Projects';
+export const initSearchProject = (pickerId: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(receiveTreePickerData({
+            id: '',
+            pickerId,
+            data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
+            extractNodeData: value => ({
+                id: value.uuid,
+                status: TreeNodeStatus.INITIAL,
+                value,
+            }),
+        }));
+    };
+
+
 interface LoadFavoritesProjectParams {
     pickerId: string;
     includeCollections?: boolean;
+    includeDirectories?: boolean;
     includeFiles?: boolean;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
 }
 
-export const loadFavoritesProject = (params: LoadFavoritesProjectParams) =>
+export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
+    options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const { pickerId, includeCollections = false, includeFiles = false } = params;
+        const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
         const uuid = getUserUuid(getState());
         if (uuid) {
             const filters = pipe(
@@ -248,18 +512,28 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams) =>
                 fb => fb.getFilters(),
             )(new FilterBuilder());
 
-            const { items } = await services.favoriteService.list(uuid, { filters });
+            const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
 
             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
                 id: 'Favorites',
                 pickerId,
-                data: items,
+                data: items.filter((item) => {
+                    if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
+                        return false;
+                    }
+
+                    if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+                        return false;
+                    }
+
+                    return true;
+                }),
                 extractNodeData: item => ({
                     id: item.uuid,
                     value: item,
                     status: item.kind === ResourceKind.PROJECT
                         ? TreeNodeStatus.INITIAL
-                        : includeFiles
+                        : includeDirectories || includeFiles
                             ? TreeNodeStatus.INITIAL
                             : TreeNodeStatus.LOADED
                 }),
@@ -269,39 +543,42 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams) =>
 
 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const { pickerId, includeCollections = false, includeFiles = false } = params;
+        const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
         const uuidPrefix = getState().auth.config.uuidPrefix;
-        const uuid = `${uuidPrefix}-j7d0g-fffffffffffffff`;
-        if (uuid) {
+        const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
 
-            const filters = pipe(
-                (fb: FilterBuilder) => includeCollections
-                    ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
-                    : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
-                fb => fb
-                    .addEqual('link_class', LinkClass.STAR)
-                    .addEqual('owner_uuid', uuid)
-                    .addLike('name', '')
-                    .getFilters(),
-            )(new FilterBuilder());
+        const filters = pipe(
+            (fb: FilterBuilder) => includeCollections
+                ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
+                : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
+            fb => fb
+                .addEqual('link_class', LinkClass.STAR)
+                .addEqual('owner_uuid', publicProjectUuid)
+                .getFilters(),
+        )(new FilterBuilder());
 
-            const { items } = await services.linkService.list({ filters });
+        const { items } = await services.linkService.list({ filters });
 
-            dispatch<any>(receiveTreePickerData<LinkResource>({
-                id: 'Public Favorites',
-                pickerId,
-                data: items,
-                extractNodeData: item => ({
-                    id: item.headUuid,
-                    value: item,
-                    status: item.headKind === ResourceKind.PROJECT
+        dispatch<any>(receiveTreePickerData<LinkResource>({
+            id: 'Public Favorites',
+            pickerId,
+            data: items.filter(item => {
+                if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
+                    return false;
+                }
+
+                return true;
+            }),
+            extractNodeData: item => ({
+                id: item.headUuid,
+                value: item,
+                status: item.headKind === ResourceKind.PROJECT
+                    ? TreeNodeStatus.INITIAL
+                    : includeDirectories || includeFiles
                         ? TreeNodeStatus.INITIAL
-                        : includeFiles
-                            ? TreeNodeStatus.INITIAL
-                            : TreeNodeStatus.LOADED
-                }),
-            }));
-        }
+                        : TreeNodeStatus.LOADED
+            }),
+        }));
     };
 
 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
@@ -368,3 +645,74 @@ const buildParams = (ownerUuid: string) => {
             .getOrder()
     };
 };
+
+/**
+ * Given a tree picker item, return collection uuid and path
+ *   if the item represents a valid target/destination location
+ */
+export type FileOperationLocation = {
+    name: string;
+    uuid: string;
+    pdh?: string;
+    subpath: string;
+}
+export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
+        if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
+            return {
+                name: item.name,
+                uuid: item.uuid,
+                pdh: item.portableDataHash,
+                subpath: '/',
+            };
+        } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
+            const uuid = getCollectionResourceCollectionUuid(item.id);
+            if (uuid) {
+                const collection = getResource<CollectionResource>(uuid)(getState().resources);
+                if (collection) {
+                    const itemPath = [item.path, item.name].join('/');
+
+                    return {
+                        name: item.name,
+                        uuid,
+                        pdh: collection.portableDataHash,
+                        subpath: itemPath,
+                    };
+                }
+            }
+        }
+        return undefined;
+    };
+
+/**
+ * Create an expanded tree picker subtree from array of nested projects/collection
+ *   First item is assumed to be root and gets empty parent id
+ *   Nodes must be sorted from top down to prevent orphaned nodes
+ */
+export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
+    return sortedAncestors
+        .reduce((tree, item, index) => {
+            if (getNode(item.uuid)(tree)) {
+                return tree;
+            } else {
+                return setNode({
+                    children: [],
+                    id: item.uuid,
+                    parent: index === 0 ? '' : item.ownerUuid,
+                    value: item,
+                    active: false,
+                    selected: false,
+                    expanded: false,
+                    status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
+                })(tree);
+            }
+        }, initialTree);
+};
+
+export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
+    let id = location.uuid;
+    if (location.subpath.length && location.subpath !== '/') {
+        id = id + location.subpath;
+    }
+    return id;
+}