1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import { unionize, ofType, UnionOf } from "common/unionize";
6 import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree, setNode, createTree } from 'models/tree';
7 import { CollectionFileType, createCollectionFilesTree, getCollectionResourceCollectionUuid } from "models/collection-file";
8 import { Dispatch } from 'redux';
9 import { RootState } from 'store/store';
10 import { getUserUuid } from "common/getuser";
11 import { ServiceRepository } from 'services/services';
12 import { FilterBuilder } from 'services/api/filter-builder';
13 import { pipe, values } from 'lodash/fp';
14 import { ResourceKind } from 'models/resource';
15 import { GroupContentsResource } from 'services/groups-service/groups-service';
16 import { getTreePicker, TreePicker } from './tree-picker';
17 import { ProjectsTreePickerItem } from './tree-picker-middleware';
18 import { OrderBuilder } from 'services/api/order-builder';
19 import { ProjectResource } from 'models/project';
20 import { mapTree } from '../../models/tree';
21 import { LinkResource, LinkClass } from "models/link";
22 import { mapTreeValues } from "models/tree";
23 import { sortFilesTree } from "services/collection-service/collection-service-files-response";
24 import { GroupClass, GroupResource } from "models/group";
25 import { CollectionResource } from "models/collection";
26 import { getResource } from "store/resources/resources";
27 import { updateResources } from "store/resources/resources-actions";
29 export const treePickerActions = unionize({
30 LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
31 LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
32 APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
33 TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
34 EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
35 ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
36 DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
37 TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string }>(),
38 SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
39 DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
40 EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
41 RESET_TREE_PICKER: ofType<{ pickerId: string }>()
44 export type TreePickerAction = UnionOf<typeof treePickerActions>;
46 export interface LoadProjectParams {
47 includeCollections?: boolean;
48 includeDirectories?: boolean;
49 includeFiles?: boolean;
50 includeFilterGroups?: boolean;
51 options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
54 export const treePickerSearchActions = unionize({
55 SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
56 SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(),
57 SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(),
58 REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(),
61 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
63 export const getProjectsTreePickerIds = (pickerId: string) => ({
64 home: `${pickerId}_home`,
65 shared: `${pickerId}_shared`,
66 favorites: `${pickerId}_favorites`,
67 publicFavorites: `${pickerId}_publicFavorites`,
68 search: `${pickerId}_search`,
71 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
73 () => values(getProjectsTreePickerIds(pickerId)),
76 .map(id => getTreePicker<Value>(id)(state)),
79 .map(getNodeDescendants(''))
80 .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
83 .reduce((map, node) =>
85 ? map.set(node.id, node)
86 : map, new Map<string, TreeNode<Value>>())
89 uniqueNodes => Array.from(uniqueNodes),
91 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
92 getAllNodes<Value>(pickerId, node => node.selected)(state);
94 export const initProjectsTreePicker = (pickerId: string, selectedItemUuid?: string) =>
95 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
96 const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
97 dispatch<any>(initUserProject(home));
98 dispatch<any>(initSharedProject(shared));
99 dispatch<any>(initFavoritesProject(favorites));
100 dispatch<any>(initPublicFavoritesProject(publicFavorites));
101 dispatch<any>(initSearchProject(search));
103 if (selectedItemUuid) {
104 dispatch<any>(loadInitialValue(selectedItemUuid, pickerId));
108 interface ReceiveTreePickerDataParams<T> {
110 extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
115 export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>) =>
116 (dispatch: Dispatch) => {
117 const { data, extractNodeData, id, pickerId, } = params;
118 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
120 nodes: data.map(item => initTreeNode(extractNodeData(item))),
123 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
126 interface LoadProjectParamsWithId extends LoadProjectParams {
129 loadShared?: boolean;
130 searchProjects?: boolean;
133 export const loadProject = (params: LoadProjectParamsWithId) =>
134 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
138 includeCollections = false,
139 includeDirectories = false,
140 includeFiles = false,
141 includeFilterGroups = false,
144 searchProjects = false
147 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
149 let filterB = new FilterBuilder();
151 filterB = (includeCollections && !searchProjects)
152 ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
153 : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
155 const state = getState();
157 if (state.treePickerSearch.collectionFilterValues[pickerId]) {
158 filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
160 filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
163 if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
164 filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
167 const filters = filterB.getFilters();
169 const itemLimit = 200;
171 const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
172 dispatch<any>(updateResources(items));
174 if (itemsAvailable > itemLimit) {
176 uuid: "more-items-available",
177 kind: ResourceKind.WORKFLOW,
178 name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
183 modifiedByClientUuid: "",
184 modifiedByUserUuid: "",
191 dispatch<any>(receiveTreePickerData<GroupContentsResource>({
194 data: items.filter((item) => {
195 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
199 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
205 extractNodeData: item => (
206 item.uuid === "more-items-available" ?
210 status: TreeNodeStatus.LOADED
215 status: item.kind === ResourceKind.PROJECT
216 ? TreeNodeStatus.INITIAL
217 : includeDirectories || includeFiles
218 ? TreeNodeStatus.INITIAL
219 : TreeNodeStatus.LOADED
224 export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
225 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
226 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
228 const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
231 const node = getNode(id)(picker);
232 if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
233 const files = (await services.collectionService.files(node.value.uuid))
236 (includeDirectories && file.type === CollectionFileType.DIRECTORY)
238 const tree = createCollectionFilesTree(files);
239 const sorted = sortFilesTree(tree);
240 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
243 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
246 subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
249 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
254 export const HOME_PROJECT_ID = 'Home Projects';
255 export const initUserProject = (pickerId: string) =>
256 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
257 const uuid = getUserUuid(getState());
259 dispatch(receiveTreePickerData({
262 data: [{ uuid, name: HOME_PROJECT_ID }],
263 extractNodeData: value => ({
265 status: TreeNodeStatus.INITIAL,
271 export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
272 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
273 const uuid = getUserUuid(getState());
275 dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
279 export const SHARED_PROJECT_ID = 'Shared with me';
280 export const initSharedProject = (pickerId: string) =>
281 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
282 dispatch(receiveTreePickerData({
285 data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
286 extractNodeData: value => ({
288 status: TreeNodeStatus.INITIAL,
294 export const loadInitialValue = (initialValue: string, pickerId: string) =>
295 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
296 const { home, shared } = getProjectsTreePickerIds(pickerId);
297 const homeUuid = getUserUuid(getState());
298 const ancestors = (await services.ancestorsService.ancestors(initialValue, ''))
300 item.kind === ResourceKind.GROUP ||
301 item.kind === ResourceKind.COLLECTION
302 ) as (GroupResource | CollectionResource)[];
304 if (ancestors.length) {
305 const isUserHomeProject = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
306 const pickerTreeId = isUserHomeProject ? home : shared;
307 const pickerTreeRootUuid: string = (homeUuid && isUserHomeProject) ? homeUuid : SHARED_PROJECT_ID;
309 ancestors[0].ownerUuid = '';
310 const tree = createInitialLocationTree(ancestors, initialValue);
312 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
313 id: pickerTreeRootUuid,
314 pickerId: pickerTreeId,
317 dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: initialValue, pickerId: pickerTreeId }));
318 dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: pickerTreeId }));
323 export const FAVORITES_PROJECT_ID = 'Favorites';
324 export const initFavoritesProject = (pickerId: string) =>
325 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
326 dispatch(receiveTreePickerData({
329 data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
330 extractNodeData: value => ({
332 status: TreeNodeStatus.INITIAL,
338 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
339 export const initPublicFavoritesProject = (pickerId: string) =>
340 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
341 dispatch(receiveTreePickerData({
344 data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
345 extractNodeData: value => ({
347 status: TreeNodeStatus.INITIAL,
353 export const SEARCH_PROJECT_ID = 'Search all Projects';
354 export const initSearchProject = (pickerId: string) =>
355 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
356 dispatch(receiveTreePickerData({
359 data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
360 extractNodeData: value => ({
362 status: TreeNodeStatus.INITIAL,
369 interface LoadFavoritesProjectParams {
371 includeCollections?: boolean;
372 includeDirectories?: boolean;
373 includeFiles?: boolean;
374 options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
377 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
378 options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
379 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
380 const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
381 const uuid = getUserUuid(getState());
383 const filters = pipe(
384 (fb: FilterBuilder) => includeCollections
385 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
386 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
387 fb => fb.getFilters(),
388 )(new FilterBuilder());
390 const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
392 dispatch<any>(receiveTreePickerData<GroupContentsResource>({
395 data: items.filter((item) => {
396 if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
400 if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
406 extractNodeData: item => ({
409 status: item.kind === ResourceKind.PROJECT
410 ? TreeNodeStatus.INITIAL
411 : includeDirectories || includeFiles
412 ? TreeNodeStatus.INITIAL
413 : TreeNodeStatus.LOADED
419 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
420 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
421 const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
422 const uuidPrefix = getState().auth.config.uuidPrefix;
423 const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
425 const filters = pipe(
426 (fb: FilterBuilder) => includeCollections
427 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
428 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
430 .addEqual('link_class', LinkClass.STAR)
431 .addEqual('owner_uuid', publicProjectUuid)
433 )(new FilterBuilder());
435 const { items } = await services.linkService.list({ filters });
437 dispatch<any>(receiveTreePickerData<LinkResource>({
438 id: 'Public Favorites',
440 data: items.filter(item => {
441 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
447 extractNodeData: item => ({
450 status: item.headKind === ResourceKind.PROJECT
451 ? TreeNodeStatus.INITIAL
452 : includeDirectories || includeFiles
453 ? TreeNodeStatus.INITIAL
454 : TreeNodeStatus.LOADED
459 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
460 (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
461 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
463 nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
467 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
470 export const loadProjectTreePickerProjects = (id: string) =>
471 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
472 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
475 const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
476 const { items } = await services.projectService.list(buildParams(ownerUuid));
478 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
481 export const loadFavoriteTreePickerProjects = (id: string) =>
482 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
483 const parentId = getUserUuid(getState()) || '';
486 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
487 const { items } = await services.favoriteService.list(parentId);
488 dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
490 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
491 const { items } = await services.projectService.list(buildParams(id));
492 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
497 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
498 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
499 const parentId = getUserUuid(getState()) || '';
502 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
503 const { items } = await services.favoriteService.list(parentId);
504 dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
506 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
507 const { items } = await services.projectService.list(buildParams(id));
508 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
513 const buildParams = (ownerUuid: string) => {
515 filters: new FilterBuilder()
516 .addEqual('owner_uuid', ownerUuid)
518 order: new OrderBuilder<ProjectResource>()
525 * Given a tree picker item, return collection uuid and path
526 * if the item represents a valid target/destination location
528 export type FileOperationLocation = {
534 export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
535 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
536 if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
540 pdh: item.portableDataHash,
543 } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
544 const uuid = getCollectionResourceCollectionUuid(item.id);
546 const collection = getResource<CollectionResource>(uuid)(getState().resources);
548 const itemPath = [item.path, item.name].join('/');
553 pdh: collection.portableDataHash,
563 * Create an expanded tree picker subtree from array of nested projects/collection
564 * Assumes the root item of the subtree already has an empty string ownerUuid
566 export const createInitialLocationTree = (data: Array<GroupResource | CollectionResource>, tailUuid: string) => {
568 .reduce((tree, item) => setNode({
571 parent: item.ownerUuid,
576 status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
577 })(tree), createTree<GroupResource | CollectionResource>());