Merge branch 'main' into 21720-material-ui-upgrade
[arvados.git] / services / workbench2 / src / components / multiselect-toolbar / MultiselectToolbar.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React from "react";
6 import { connect } from "react-redux";
7 import { CustomStyleRulesCallback } from 'common/custom-theme';
8 import { Toolbar, Tooltip, IconButton } from "@mui/material";
9 import { WithStyles } from '@mui/styles';
10 import withStyles from '@mui/styles/withStyles';
11 import { ArvadosTheme } from "common/custom-theme";
12 import { RootState } from "store/store";
13 import { Dispatch } from "redux";
14 import { TCheckedList } from "components/data-table/data-table";
15 import { ContextMenuResource } from "store/context-menu/context-menu-actions";
16 import { Resource, ResourceKind, extractUuidKind } from "models/resource";
17 import { getResource } from "store/resources/resources";
18 import { ResourcesState } from "store/resources/resources";
19 import { MultiSelectMenuAction, MultiSelectMenuActionSet } from "views-components/multiselect-toolbar/ms-menu-actions";
20 import { ContextMenuAction, ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
21 import { multiselectActionsFilters, TMultiselectActionsFilters } from "./ms-toolbar-action-filters";
22 import { kindToActionSet, findActionByName } from "./ms-kind-action-differentiator";
23 import { msToggleTrashAction } from "views-components/multiselect-toolbar/ms-project-action-set";
24 import { copyToClipboardAction } from "store/open-in-new-tab/open-in-new-tab.actions";
25 import { ContainerRequestResource } from "models/container-request";
26 import { FavoritesState } from "store/favorites/favorites-reducer";
27 import { resourceIsFrozen } from "common/frozen-resources";
28 import { getResourceWithEditableStatus } from "store/resources/resources";
29 import { GroupResource } from "models/group";
30 import { EditableResource } from "models/resource";
31 import { User } from "models/user";
32 import { GroupClass } from "models/group";
33 import { isProcessCancelable } from "store/processes/process";
34 import { CollectionResource } from "models/collection";
35 import { getProcess } from "store/processes/process";
36 import { Process } from "store/processes/process";
37 import { PublicFavoritesState } from "store/public-favorites/public-favorites-reducer";
38 import { AuthState } from "store/auth/auth-reducer";
39 import { IntersectionObserverWrapper } from "./ms-toolbar-overflow-wrapper";
40 import classNames from "classnames";
41 import { ContextMenuKind, sortMenuItems, menuDirection } from 'views-components/context-menu/menu-item-sort';
42
43 type CssRules = "root" | "button" | "iconContainer" | "icon" | "divider";
44
45 const styles: CustomStyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
46     root: {
47         display: "flex",
48         flexDirection: "row",
49         width: 0,
50         height: '2.5rem',
51         padding: 0,
52         margin: 0,
53         overflow: 'hidden',
54     },
55     button: {
56         width: "2.5rem",
57         height: "2.5rem ",
58         paddingLeft: 0,
59         border: "1px solid transparent",
60     },
61     iconContainer: {
62         height: '100%',
63     },
64     icon: {
65         marginLeft: '-0.5rem',
66     },
67     divider: {
68         display: "flex",
69         alignItems: "center",
70     },
71 });
72
73 export type MultiselectToolbarProps = {
74     checkedList: TCheckedList;
75     selectedResourceUuid: string | null;
76     iconProps: IconProps
77     user: User | null
78     disabledButtons: Set<string>
79     auth: AuthState;
80     location: string;
81     forceMultiSelectMode?: boolean;
82     injectedStyles?: string;
83     executeMulti: (action: ContextMenuAction | MultiSelectMenuAction, checkedList: TCheckedList, resources: ResourcesState) => void;
84 };
85
86 type IconProps = {
87     resources: ResourcesState;
88     favorites: FavoritesState;
89     publicFavorites: PublicFavoritesState;
90 }
91
92 const detailsCardPaths = [
93     '/projects',
94 ]
95
96 export const usesDetailsCard = (location: string): boolean => {
97     return detailsCardPaths.some(path => location.includes(path))
98 }
99
100 export const MultiselectToolbar = connect(
101     mapStateToProps,
102     mapDispatchToProps
103 )(
104     withStyles(styles)((props: MultiselectToolbarProps & WithStyles<CssRules>) => {
105         const { classes, checkedList, iconProps, user, disabledButtons, location, forceMultiSelectMode, injectedStyles } = props;
106         const selectedResourceArray = selectedToArray(checkedList);
107         const selectedResourceUuid = usesDetailsCard(location) ? props.selectedResourceUuid : selectedResourceArray.length === 1 ? selectedResourceArray[0] : null;
108         const singleResourceKind = selectedResourceUuid && !forceMultiSelectMode ? [resourceToMsResourceKind(selectedResourceUuid, iconProps.resources, user)] : null
109         const currentResourceKinds = singleResourceKind ? singleResourceKind : Array.from(selectedToKindSet(checkedList, iconProps.resources));
110         const currentPathIsTrash = window.location.pathname === "/trash";
111
112         const rawActions =
113             currentPathIsTrash && selectedToKindSet(checkedList).size
114                 ? [msToggleTrashAction]
115                 : selectActionsByKind(currentResourceKinds as string[], multiselectActionsFilters).filter((action) =>
116                         selectedResourceUuid === null ? action.isForMulti : true
117                     );
118
119         const actions: ContextMenuAction[] | MultiSelectMenuAction[] = sortMenuItems(
120             singleResourceKind && singleResourceKind.length ? (singleResourceKind[0] as ContextMenuKind) : ContextMenuKind.MULTI,
121             rawActions,
122             menuDirection.HORIZONTAL
123         );
124
125         const targetResources = selectedResourceUuid ? {[selectedResourceUuid]: true} as TCheckedList : checkedList
126
127         return (
128             <React.Fragment>
129                 <Toolbar
130                     className={classNames(classes.root, injectedStyles)}
131                     style={{ width: `${(actions.length * 2.5) + 2}rem`}}
132                     data-cy='multiselect-toolbar'
133                     >
134                     {actions.length ? (
135                         <IntersectionObserverWrapper menuLength={actions.length}>
136                             {actions.map((action, i) =>{
137                                 const { hasAlts, useAlts, name, altName, icon, altIcon } = action;
138                             return action.name === ContextMenuActionNames.DIVIDER ? (
139                                 action.component && (
140                                     <div
141                                         className={classes.divider}
142                                         data-targetid={`${name}${i}`}
143                                         key={i}
144                                     >
145                                         <action.component />
146                                     </div>
147                                 )
148                             ) : hasAlts ? (
149                                 <Tooltip
150                                     className={classes.button}
151                                     data-targetid={name}
152                                     title={currentPathIsTrash || (useAlts && useAlts(selectedResourceUuid, iconProps)) ? altName : name}
153                                     key={i}
154                                     disableFocusListener
155                                 >
156                                     <span className={classes.iconContainer}>
157                                         <IconButton
158                                             data-cy='multiselect-button'
159                                             disabled={disabledButtons.has(name)}
160                                             onClick={() => props.executeMulti(action, targetResources, iconProps.resources)}
161                                             className={classes.icon}
162                                             size="large">
163                                             {currentPathIsTrash || (useAlts && useAlts(selectedResourceUuid, iconProps)) ? altIcon && altIcon({}) : icon({})}
164                                         </IconButton>
165                                     </span>
166                                 </Tooltip>
167                             ) : (
168                                 <Tooltip
169                                     className={classes.button}
170                                     data-targetid={name}
171                                     title={action.name}
172                                     key={i}
173                                     disableFocusListener
174                                 >
175                                     <span className={classes.iconContainer}>
176                                         <IconButton
177                                             data-cy='multiselect-button'
178                                             onClick={() => {
179                                                 props.executeMulti(action, targetResources, iconProps.resources)}}
180                                             className={classes.icon}
181                                             size="large">
182                                             {action.icon({})}
183                                         </IconButton>
184                                     </span>
185                                 </Tooltip>
186                             );
187                             })}
188                         </IntersectionObserverWrapper>
189                     ) : (
190                         <></>
191                     )}
192                 </Toolbar>
193             </React.Fragment>
194         );
195     })
196 );
197
198 export function selectedToArray(checkedList: TCheckedList): Array<string> {
199     const arrayifiedSelectedList: Array<string> = [];
200     for (const [key, value] of Object.entries(checkedList)) {
201         if (value === true) {
202             arrayifiedSelectedList.push(key);
203         }
204     }
205     return arrayifiedSelectedList;
206 }
207
208 export function selectedToKindSet(checkedList: TCheckedList, resources: ResourcesState = {}): Set<string> {
209     const setifiedList = new Set<string>();
210     for (const [key, value] of Object.entries(checkedList)) {
211         if (value === true) {
212             isGroupResource(key, resources) ? setifiedList.add(ContextMenuKind.GROUPS) : setifiedList.add(extractUuidKind(key) as string);
213         }
214     }
215     return setifiedList;
216 }
217
218 export const isGroupResource = (uuid: string, resources: ResourcesState): boolean => {
219     const resource = getResource(uuid)(resources);
220     if(!resource) return false;
221     return resource.kind === ResourceKind.PROJECT && (resource as GroupResource).groupClass === GroupClass.ROLE;
222 };
223
224 function groupByKind(checkedList: TCheckedList, resources: ResourcesState): Record<string, ContextMenuResource[]> {
225     const result = {};
226     selectedToArray(checkedList).forEach(uuid => {
227         const resource = getResource(uuid)(resources) as ContainerRequestResource | Resource;
228         const kind = isGroupResource(uuid, resources) ? ContextMenuKind.GROUPS : resource.kind;
229         if (!result[kind]) result[kind] = [];
230         result[kind].push(resource);
231     });
232     return result;
233 }
234
235 function filterActions(actionArray: MultiSelectMenuActionSet, filters: Set<string>): Array<MultiSelectMenuAction> {
236     return actionArray[0].filter(action => filters.has(action.name as string));
237 }
238
239 const resourceToMsResourceKind = (uuid: string, resources: ResourcesState, user: User | null, readonly = false): (ContextMenuKind | ResourceKind) | undefined => {
240     if (!user) return;
241     const resource = getResourceWithEditableStatus<GroupResource & EditableResource>(uuid, user.uuid)(resources);
242     const { isAdmin } = user;
243     const kind = extractUuidKind(uuid);
244
245     const isFrozen = resource?.kind && resource.kind === ResourceKind.PROJECT ? resourceIsFrozen(resource, resources) : false;
246     const isEditable = (user.isAdmin || (resource || ({} as EditableResource)).isEditable) && !readonly && !isFrozen;
247
248     switch (kind) {
249         case ResourceKind.PROJECT:
250             if (isFrozen) {
251                 return isAdmin ? ContextMenuKind.FROZEN_PROJECT_ADMIN : ContextMenuKind.FROZEN_PROJECT;
252             }
253
254             if (isAdmin && !readonly) {
255                 if (resource?.groupClass === GroupClass.FILTER) {
256                     return ContextMenuKind.FILTER_GROUP_ADMIN;
257                 }
258                 if (resource?.groupClass === GroupClass.ROLE) {
259                     return ContextMenuKind.GROUPS;
260                 }
261                 return ContextMenuKind.PROJECT_ADMIN;
262             }
263
264             return isEditable
265                 ? resource && resource.groupClass !== GroupClass.FILTER
266                     ? ContextMenuKind.PROJECT
267                     : ContextMenuKind.FILTER_GROUP
268                 : ContextMenuKind.READONLY_PROJECT;
269         case ResourceKind.COLLECTION:
270             const c = getResource<CollectionResource>(uuid)(resources);
271             if (c === undefined) {
272                 return;
273             }
274             const isOldVersion = c.uuid !== c.currentVersionUuid;
275             const isTrashed = c.isTrashed;
276             return isOldVersion
277                 ? ContextMenuKind.OLD_VERSION_COLLECTION
278                 : isTrashed && isEditable
279                 ? ContextMenuKind.TRASHED_COLLECTION
280                 : isAdmin && isEditable
281                 ? ContextMenuKind.COLLECTION_ADMIN
282                 : isEditable
283                 ? ContextMenuKind.COLLECTION
284                 : ContextMenuKind.READONLY_COLLECTION;
285         case ResourceKind.PROCESS:
286             return isAdmin && isEditable
287                 ? resource && isProcessCancelable(getProcess(resource.uuid)(resources) as Process)
288                     ? ContextMenuKind.RUNNING_PROCESS_ADMIN
289                     : ContextMenuKind.PROCESS_ADMIN
290                 : readonly
291                 ? ContextMenuKind.READONLY_PROCESS_RESOURCE
292                 : resource && isProcessCancelable(getProcess(resource.uuid)(resources) as Process)
293                 ? ContextMenuKind.RUNNING_PROCESS_RESOURCE
294                 : ContextMenuKind.PROCESS_RESOURCE;
295         case ResourceKind.USER:
296             return isAdmin ? ContextMenuKind.ROOT_PROJECT_ADMIN : ContextMenuKind.ROOT_PROJECT;
297         case ResourceKind.LINK:
298             return ContextMenuKind.LINK;
299         case ResourceKind.WORKFLOW:
300             return isEditable ? ContextMenuKind.WORKFLOW : ContextMenuKind.READONLY_WORKFLOW;
301         default:
302             return;
303     }
304 };
305
306 function selectActionsByKind(currentResourceKinds: Array<string>, filterSet: TMultiselectActionsFilters): MultiSelectMenuAction[] {
307     const rawResult: Set<MultiSelectMenuAction> = new Set();
308     const resultNames = new Set();
309     const allFiltersArray: MultiSelectMenuAction[][] = []
310     currentResourceKinds.forEach(kind => {
311         if (filterSet[kind]) {
312             const actions = filterActions(...filterSet[kind]);
313             allFiltersArray.push(actions);
314             actions.forEach(action => {
315                 if (!resultNames.has(action.name)) {
316                     rawResult.add(action);
317                     resultNames.add(action.name);
318                 }
319             });
320         }
321     });
322
323     const filteredNameSet = allFiltersArray.map(filterArray => {
324         const resultSet = new Set<string>();
325         filterArray.forEach(action => resultSet.add(action.name as string || ""));
326         return resultSet;
327     });
328
329     const filteredResult = Array.from(rawResult).filter(action => {
330         for (let i = 0; i < filteredNameSet.length; i++) {
331             if (!filteredNameSet[i].has(action.name as string)) return false;
332         }
333         return true;
334     });
335
336     return filteredResult;
337 }
338
339
340 //--------------------------------------------------//
341
342 function mapStateToProps({auth, multiselect, resources, favorites, publicFavorites, selectedResourceUuid}: RootState) {
343     return {
344         checkedList: multiselect.checkedList as TCheckedList,
345         user: auth && auth.user ? auth.user : null,
346         disabledButtons: new Set<string>(multiselect.disabledButtons),
347         auth,
348         selectedResourceUuid,
349         location: window.location.pathname,
350         iconProps: {
351             resources,
352             favorites,
353             publicFavorites
354         }
355     }
356 }
357
358 function mapDispatchToProps(dispatch: Dispatch) {
359     return {
360         executeMulti: (selectedAction: ContextMenuAction, checkedList: TCheckedList, resources: ResourcesState): void => {
361             const kindGroups = groupByKind(checkedList, resources);
362             const currentList = selectedToArray(checkedList)
363             switch (selectedAction.name) {
364                 case ContextMenuActionNames.MOVE_TO:
365                 case ContextMenuActionNames.REMOVE:
366                     const firstResourceKind = isGroupResource(currentList[0], resources) 
367                         ? ContextMenuKind.GROUPS 
368                         : (getResource(currentList[0])(resources) as ContainerRequestResource | Resource).kind;
369                     const action = findActionByName(selectedAction.name as string, kindToActionSet[firstResourceKind]);
370                     if (action) action.execute(dispatch, kindGroups[firstResourceKind]);
371                     break;
372                 case ContextMenuActionNames.COPY_LINK_TO_CLIPBOARD:
373                     const selectedResources = currentList.map(uuid => getResource(uuid)(resources));
374                     dispatch<any>(copyToClipboardAction(selectedResources));
375                     break;
376                 default:
377                     for (const kind in kindGroups) {
378                         const action = findActionByName(selectedAction.name as string, kindToActionSet[kind]);
379                         if (action) action.execute(dispatch, kindGroups[kind]);
380                     }
381                     break;
382             }
383         },
384     };
385 }