Merge branch '21388-arvados-3.0-distros-docs'
[arvados.git] / services / workbench2 / src / views-components / data-explorer / renderers.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 { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox, Chip, withTheme } from "@material-ui/core";
7 import { FavoriteStar, PublicFavoriteStar } from "../favorite-star/favorite-star";
8 import { Resource, ResourceKind, TrashableResource } from "models/resource";
9 import {
10     FreezeIcon,
11     ProjectIcon,
12     FilterGroupIcon,
13     CollectionIcon,
14     ProcessIcon,
15     DefaultIcon,
16     ShareIcon,
17     CollectionOldVersionIcon,
18     WorkflowIcon,
19     RemoveIcon,
20     RenameIcon,
21     ActiveIcon,
22     SetupIcon,
23     InactiveIcon,
24     ErrorIcon,
25 } from "components/icon/icon";
26 import { formatDate, formatFileSize, formatTime } from "common/formatters";
27 import { resourceLabel } from "common/labels";
28 import { connect, DispatchProp } from "react-redux";
29 import { RootState } from "store/store";
30 import { getResource, filterResources } from "store/resources/resources";
31 import { GroupContentsResource } from "services/groups-service/groups-service";
32 import { getProcess, Process, getProcessStatus, getProcessStatusStyles, getProcessRuntime } from "store/processes/process";
33 import { ArvadosTheme } from "common/custom-theme";
34 import { compose, Dispatch } from "redux";
35 import { WorkflowResource } from "models/workflow";
36 import { ResourceStatus as WorkflowStatus } from "views/workflow-panel/workflow-panel-view";
37 import { getUuidPrefix, openRunProcess } from "store/workflow-panel/workflow-panel-actions";
38 import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
39 import { getUserFullname, getUserDisplayName, User, UserResource } from "models/user";
40 import { LinkClass, LinkResource } from "models/link";
41 import { navigateTo, navigateToGroupDetails, navigateToUserProfile } from "store/navigation/navigation-action";
42 import { withResourceData } from "views-components/data-explorer/with-resources";
43 import { CollectionResource } from "models/collection";
44 import { IllegalNamingWarning } from "components/warning/warning";
45 import { loadResource } from "store/resources/resources-actions";
46 import { BuiltinGroups, getBuiltinGroupUuid, GroupClass, GroupResource, isBuiltinGroup } from "models/group";
47 import { openRemoveGroupMemberDialog } from "store/group-details-panel/group-details-panel-actions";
48 import { setMemberIsHidden } from "store/group-details-panel/group-details-panel-actions";
49 import { formatPermissionLevel } from "views-components/sharing-dialog/permission-select";
50 import { PermissionLevel } from "models/permission";
51 import { openPermissionEditContextMenu } from "store/context-menu/context-menu-actions";
52 import { VirtualMachinesResource } from "models/virtual-machines";
53 import { CopyToClipboardSnackbar } from "components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar";
54 import { ProjectResource } from "models/project";
55 import { ProcessResource } from "models/process";
56 import { ServiceRepository } from "services/services";
57 import { loadUsersPanel } from "store/users/users-actions";
58 import { InlinePulser } from "components/loading/inline-pulser";
59
60 export const toggleIsAdmin = (uuid: string) =>
61     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
62         const { resources } = getState();
63         const data = getResource<UserResource>(uuid)(resources);
64         const isAdmin = data!.isAdmin;
65         const newActivity = await services.userService.update(uuid, { isAdmin: !isAdmin });
66         dispatch<any>(loadUsersPanel());
67         return newActivity;
68     };
69
70 const renderName = (dispatch: Dispatch, item: GroupContentsResource) => {
71     const navFunc = "groupClass" in item && item.groupClass === GroupClass.ROLE ? navigateToGroupDetails : navigateTo;
72     return (
73         <Grid
74             container
75             alignItems="center"
76             wrap="nowrap"
77             spacing={16}
78         >
79             <Grid item>{renderIcon(item)}</Grid>
80             <Grid item>
81                 <Typography
82                     color="primary"
83                     style={{ width: "auto", cursor: "pointer" }}
84                     onClick={(ev) => {
85                         ev.stopPropagation()
86                         dispatch<any>(navFunc(item.uuid))
87                     }}
88                 >
89                     {item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION ? <IllegalNamingWarning name={item.name} /> : null}
90                     {item.name}
91                 </Typography>
92             </Grid>
93             <Grid item>
94                 <Typography variant="caption">
95                     <FavoriteStar resourceUuid={item.uuid} />
96                     <PublicFavoriteStar resourceUuid={item.uuid} />
97                     {item.kind === ResourceKind.PROJECT && <FrozenProject item={item} />}
98                 </Typography>
99             </Grid>
100         </Grid>
101     );
102 };
103
104 export const FrozenProject = (props: { item: ProjectResource }) => {
105     const [fullUsername, setFullusername] = React.useState<any>(null);
106     const getFullName = React.useCallback(() => {
107         if (props.item.frozenByUuid) {
108             setFullusername(<UserNameFromID uuid={props.item.frozenByUuid} />);
109         }
110     }, [props.item, setFullusername]);
111
112     if (props.item.frozenByUuid) {
113         return (
114             <Tooltip
115                 onOpen={getFullName}
116                 enterDelay={500}
117                 title={<span>Project was frozen by {fullUsername}</span>}
118             >
119                 <FreezeIcon style={{ fontSize: "inherit" }} />
120             </Tooltip>
121         );
122     } else {
123         return null;
124     }
125 };
126
127 export const ResourceName = connect((state: RootState, props: { uuid: string }) => {
128     const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
129     return resource;
130 })((resource: GroupContentsResource & DispatchProp<any>) => renderName(resource.dispatch, resource));
131
132 const renderIcon = (item: GroupContentsResource) => {
133     switch (item.kind) {
134         case ResourceKind.PROJECT:
135             if (item.groupClass === GroupClass.FILTER) {
136                 return <FilterGroupIcon />;
137             }
138             return <ProjectIcon />;
139         case ResourceKind.COLLECTION:
140             if (item.uuid === item.currentVersionUuid) {
141                 return <CollectionIcon />;
142             }
143             return <CollectionOldVersionIcon />;
144         case ResourceKind.PROCESS:
145             return <ProcessIcon />;
146         case ResourceKind.WORKFLOW:
147             return <WorkflowIcon />;
148         default:
149             return <DefaultIcon />;
150     }
151 };
152
153 const renderDate = (date?: string) => {
154     return (
155         <Typography
156             noWrap
157             style={{ minWidth: "100px" }}
158         >
159             {formatDate(date)}
160         </Typography>
161     );
162 };
163
164 const renderWorkflowName = (item: WorkflowResource) => (
165     <Grid
166         container
167         alignItems="center"
168         wrap="nowrap"
169         spacing={16}
170     >
171         <Grid item>{renderIcon(item)}</Grid>
172         <Grid item>
173             <Typography
174                 color="primary"
175                 style={{ width: "100px" }}
176             >
177                 {item.name}
178             </Typography>
179         </Grid>
180     </Grid>
181 );
182
183 export const ResourceWorkflowName = connect((state: RootState, props: { uuid: string }) => {
184     const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
185     return resource;
186 })(renderWorkflowName);
187
188 const getPublicUuid = (uuidPrefix: string) => {
189     return `${uuidPrefix}-tpzed-anonymouspublic`;
190 };
191
192 const resourceShare = (dispatch: Dispatch, uuidPrefix: string, ownerUuid?: string, uuid?: string) => {
193     const isPublic = ownerUuid === getPublicUuid(uuidPrefix);
194     return (
195         <div>
196             {!isPublic && uuid && (
197                 <Tooltip title="Share">
198                     <IconButton onClick={() => dispatch<any>(openSharingDialog(uuid))}>
199                         <ShareIcon />
200                     </IconButton>
201                 </Tooltip>
202             )}
203         </div>
204     );
205 };
206
207 export const ResourceShare = connect((state: RootState, props: { uuid: string }) => {
208     const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
209     const uuidPrefix = getUuidPrefix(state);
210     return {
211         uuid: resource ? resource.uuid : "",
212         ownerUuid: resource ? resource.ownerUuid : "",
213         uuidPrefix,
214     };
215 })((props: { ownerUuid?: string; uuidPrefix: string; uuid?: string } & DispatchProp<any>) =>
216     resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid)
217 );
218
219 // User Resources
220 const renderFirstName = (item: { firstName: string }) => {
221     return <Typography noWrap>{item.firstName}</Typography>;
222 };
223
224 export const ResourceFirstName = connect((state: RootState, props: { uuid: string }) => {
225     const resource = getResource<UserResource>(props.uuid)(state.resources);
226     return resource || { firstName: "" };
227 })(renderFirstName);
228
229 const renderLastName = (item: { lastName: string }) => <Typography noWrap>{item.lastName}</Typography>;
230
231 export const ResourceLastName = connect((state: RootState, props: { uuid: string }) => {
232     const resource = getResource<UserResource>(props.uuid)(state.resources);
233     return resource || { lastName: "" };
234 })(renderLastName);
235
236 const renderFullName = (dispatch: Dispatch, item: { uuid: string; firstName: string; lastName: string }, link?: boolean) => {
237     const displayName = (item.firstName + " " + item.lastName).trim() || item.uuid;
238     return link ? (
239         <Typography
240             noWrap
241             color="primary"
242             style={{ cursor: "pointer" }}
243             onClick={() => dispatch<any>(navigateToUserProfile(item.uuid))}
244         >
245             {displayName}
246         </Typography>
247     ) : (
248         <Typography noWrap>{displayName}</Typography>
249     );
250 };
251
252 export const UserResourceFullName = connect((state: RootState, props: { uuid: string; link?: boolean }) => {
253     const resource = getResource<UserResource>(props.uuid)(state.resources);
254     return { item: resource || { uuid: "", firstName: "", lastName: "" }, link: props.link };
255 })((props: { item: { uuid: string; firstName: string; lastName: string }; link?: boolean } & DispatchProp<any>) =>
256     renderFullName(props.dispatch, props.item, props.link)
257 );
258
259 const renderUuid = (item: { uuid: string }) => (
260     <Typography
261         data-cy="uuid"
262         noWrap
263     >
264         {item.uuid}
265         {(item.uuid && <CopyToClipboardSnackbar value={item.uuid} />) || "-"}
266     </Typography>
267 );
268
269 const renderUuidCopyIcon = (item: { uuid: string }) => (
270     <Typography
271         data-cy="uuid"
272         noWrap
273     >
274         {(item.uuid && <CopyToClipboardSnackbar value={item.uuid} />) || "-"}
275     </Typography>
276 );
277
278 export const ResourceUuid = connect(
279     (state: RootState, props: { uuid: string }) => getResource<UserResource>(props.uuid)(state.resources) || { uuid: "" }
280 )(renderUuid);
281
282 const renderEmail = (item: { email: string }) => <Typography noWrap>{item.email}</Typography>;
283
284 export const ResourceEmail = connect((state: RootState, props: { uuid: string }) => {
285     const resource = getResource<UserResource>(props.uuid)(state.resources);
286     return resource || { email: "" };
287 })(renderEmail);
288
289 enum UserAccountStatus {
290     ACTIVE = "Active",
291     INACTIVE = "Inactive",
292     SETUP = "Setup",
293     UNKNOWN = "",
294 }
295
296 const renderAccountStatus = (props: { status: UserAccountStatus }) => (
297     <Grid
298         container
299         alignItems="center"
300         wrap="nowrap"
301         spacing={8}
302         data-cy="account-status"
303     >
304         <Grid item>
305             {(() => {
306                 switch (props.status) {
307                     case UserAccountStatus.ACTIVE:
308                         return <ActiveIcon style={{ color: "#4caf50", verticalAlign: "middle" }} />;
309                     case UserAccountStatus.SETUP:
310                         return <SetupIcon style={{ color: "#2196f3", verticalAlign: "middle" }} />;
311                     case UserAccountStatus.INACTIVE:
312                         return <InactiveIcon style={{ color: "#9e9e9e", verticalAlign: "middle" }} />;
313                     default:
314                         return <></>;
315                 }
316             })()}
317         </Grid>
318         <Grid item>
319             <Typography noWrap>{props.status}</Typography>
320         </Grid>
321     </Grid>
322 );
323
324 const getUserAccountStatus = (state: RootState, props: { uuid: string }) => {
325     const user = getResource<UserResource>(props.uuid)(state.resources);
326     // Get membership links for all users group
327     const allUsersGroupUuid = getBuiltinGroupUuid(state.auth.localCluster, BuiltinGroups.ALL);
328     const permissions = filterResources(
329         (resource: LinkResource) =>
330             resource.kind === ResourceKind.LINK &&
331             resource.linkClass === LinkClass.PERMISSION &&
332             resource.headUuid === allUsersGroupUuid &&
333             resource.tailUuid === props.uuid
334     )(state.resources);
335
336     if (user) {
337         return user.isActive
338             ? { status: UserAccountStatus.ACTIVE }
339             : permissions.length > 0
340             ? { status: UserAccountStatus.SETUP }
341             : { status: UserAccountStatus.INACTIVE };
342     } else {
343         return { status: UserAccountStatus.UNKNOWN };
344     }
345 };
346
347 export const ResourceLinkTailAccountStatus = connect((state: RootState, props: { uuid: string }) => {
348     const link = getResource<LinkResource>(props.uuid)(state.resources);
349     return link && link.tailKind === ResourceKind.USER ? getUserAccountStatus(state, { uuid: link.tailUuid }) : { status: UserAccountStatus.UNKNOWN };
350 })(renderAccountStatus);
351
352 export const UserResourceAccountStatus = connect(getUserAccountStatus)(renderAccountStatus);
353
354 const renderIsHidden = (props: {
355     memberLinkUuid: string;
356     permissionLinkUuid: string;
357     visible: boolean;
358     canManage: boolean;
359     setMemberIsHidden: (memberLinkUuid: string, permissionLinkUuid: string, hide: boolean) => void;
360 }) => {
361     if (props.memberLinkUuid) {
362         return (
363             <Checkbox
364                 data-cy="user-visible-checkbox"
365                 color="primary"
366                 checked={props.visible}
367                 disabled={!props.canManage}
368                 onClick={e => {
369                     e.stopPropagation();
370                     props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible);
371                 }}
372             />
373         );
374     } else {
375         return <Typography />;
376     }
377 };
378
379 export const ResourceLinkTailIsVisible = connect(
380     (state: RootState, props: { uuid: string }) => {
381         const link = getResource<LinkResource>(props.uuid)(state.resources);
382         const member = getResource<Resource>(link?.tailUuid || "")(state.resources);
383         const group = getResource<GroupResource>(link?.headUuid || "")(state.resources);
384         const permissions = filterResources((resource: LinkResource) => {
385             return (
386                 resource.linkClass === LinkClass.PERMISSION &&
387                 resource.headUuid === link?.tailUuid &&
388                 resource.tailUuid === group?.uuid &&
389                 resource.name === PermissionLevel.CAN_READ
390             );
391         })(state.resources);
392
393         const permissionLinkUuid = permissions.length > 0 ? permissions[0].uuid : "";
394         const isVisible = link && group && permissions.length > 0;
395         // Consider whether the current user canManage this resurce in addition when it's possible
396         const isBuiltin = isBuiltinGroup(link?.headUuid || "");
397
398         return member?.kind === ResourceKind.USER
399             ? { memberLinkUuid: link?.uuid, permissionLinkUuid, visible: isVisible, canManage: !isBuiltin }
400             : { memberLinkUuid: "", permissionLinkUuid: "", visible: false, canManage: false };
401     },
402     { setMemberIsHidden }
403 )(renderIsHidden);
404
405 const renderIsAdmin = (props: { uuid: string; isAdmin: boolean; toggleIsAdmin: (uuid: string) => void }) => (
406     <Checkbox
407         color="primary"
408         checked={props.isAdmin}
409         onClick={e => {
410             e.stopPropagation();
411             props.toggleIsAdmin(props.uuid);
412         }}
413     />
414 );
415
416 export const ResourceIsAdmin = connect(
417     (state: RootState, props: { uuid: string }) => {
418         const resource = getResource<UserResource>(props.uuid)(state.resources);
419         return resource || { isAdmin: false };
420     },
421     { toggleIsAdmin }
422 )(renderIsAdmin);
423
424 const renderUsername = (item: { username: string; uuid: string }) => <Typography noWrap>{item.username || item.uuid}</Typography>;
425
426 export const ResourceUsername = connect((state: RootState, props: { uuid: string }) => {
427     const resource = getResource<UserResource>(props.uuid)(state.resources);
428     return resource || { username: "", uuid: props.uuid };
429 })(renderUsername);
430
431 // Virtual machine resource
432
433 const renderHostname = (item: { hostname: string }) => <Typography noWrap>{item.hostname}</Typography>;
434
435 export const VirtualMachineHostname = connect((state: RootState, props: { uuid: string }) => {
436     const resource = getResource<VirtualMachinesResource>(props.uuid)(state.resources);
437     return resource || { hostname: "" };
438 })(renderHostname);
439
440 const renderVirtualMachineLogin = (login: { user: string }) => <Typography noWrap>{login.user}</Typography>;
441
442 export const VirtualMachineLogin = connect((state: RootState, props: { linkUuid: string }) => {
443     const permission = getResource<LinkResource>(props.linkUuid)(state.resources);
444     const user = getResource<UserResource>(permission?.tailUuid || "")(state.resources);
445
446     return { user: user?.username || permission?.tailUuid || "" };
447 })(renderVirtualMachineLogin);
448
449 // Common methods
450 const renderCommonData = (data: string) => <Typography noWrap>{data}</Typography>;
451
452 const renderCommonDate = (date: string) => <Typography noWrap>{formatDate(date)}</Typography>;
453
454 export const CommonUuid = withResourceData("uuid", renderCommonData);
455
456 // Api Client Authorizations
457 export const TokenApiClientId = withResourceData("apiClientId", renderCommonData);
458
459 export const TokenApiToken = withResourceData("apiToken", renderCommonData);
460
461 export const TokenCreatedByIpAddress = withResourceData("createdByIpAddress", renderCommonDate);
462
463 export const TokenDefaultOwnerUuid = withResourceData("defaultOwnerUuid", renderCommonData);
464
465 export const TokenExpiresAt = withResourceData("expiresAt", renderCommonDate);
466
467 export const TokenLastUsedAt = withResourceData("lastUsedAt", renderCommonDate);
468
469 export const TokenLastUsedByIpAddress = withResourceData("lastUsedByIpAddress", renderCommonData);
470
471 export const TokenScopes = withResourceData("scopes", renderCommonData);
472
473 export const TokenUserId = withResourceData("userId", renderCommonData);
474
475 const clusterColors = [
476     ["#f44336", "#fff"],
477     ["#2196f3", "#fff"],
478     ["#009688", "#fff"],
479     ["#cddc39", "#fff"],
480     ["#ff9800", "#fff"],
481 ];
482
483 export const ResourceCluster = (props: { uuid: string }) => {
484     const CLUSTER_ID_LENGTH = 5;
485     const pos = props.uuid.length > CLUSTER_ID_LENGTH ? props.uuid.indexOf("-") : 5;
486     const clusterId = pos >= CLUSTER_ID_LENGTH ? props.uuid.substring(0, pos) : "";
487     const ci =
488         pos >= CLUSTER_ID_LENGTH
489             ? ((props.uuid.charCodeAt(0) * props.uuid.charCodeAt(1) + props.uuid.charCodeAt(2)) * props.uuid.charCodeAt(3) +
490                   props.uuid.charCodeAt(4)) %
491               clusterColors.length
492             : 0;
493     return (
494         <span
495             style={{
496                 backgroundColor: clusterColors[ci][0],
497                 color: clusterColors[ci][1],
498                 padding: "2px 7px",
499                 borderRadius: 3,
500             }}
501         >
502             {clusterId}
503         </span>
504     );
505 };
506
507 // Links Resources
508 const renderLinkName = (item: { name: string }) => <Typography noWrap>{item.name || "-"}</Typography>;
509
510 export const ResourceLinkName = connect((state: RootState, props: { uuid: string }) => {
511     const resource = getResource<LinkResource>(props.uuid)(state.resources);
512     return resource || { name: "" };
513 })(renderLinkName);
514
515 const renderLinkClass = (item: { linkClass: string }) => <Typography noWrap>{item.linkClass}</Typography>;
516
517 export const ResourceLinkClass = connect((state: RootState, props: { uuid: string }) => {
518     const resource = getResource<LinkResource>(props.uuid)(state.resources);
519     return resource || { linkClass: "" };
520 })(renderLinkClass);
521
522 const getResourceDisplayName = (resource: Resource): string => {
523     if ((resource as UserResource).kind === ResourceKind.USER && typeof (resource as UserResource).firstName !== "undefined") {
524         // We can be sure the resource is UserResource
525         return getUserDisplayName(resource as UserResource);
526     } else {
527         return (resource as GroupContentsResource).name;
528     }
529 };
530
531 const renderResourceLink = (dispatch: Dispatch, item: Resource ) => {
532     var displayName = getResourceDisplayName(item);
533
534     return (
535         <Typography
536             noWrap
537             color="primary"
538             style={{ cursor: "pointer" }}
539             onClick={() => {
540                 item.kind === ResourceKind.GROUP && (item as GroupResource).groupClass === "role"
541                     ? dispatch<any>(navigateToGroupDetails(item.uuid))
542                     : item.kind === ResourceKind.USER
543                     ? dispatch<any>(navigateToUserProfile(item.uuid))
544                     : dispatch<any>(navigateTo(item.uuid));
545             }}
546         >
547             {resourceLabel(item.kind, item && item.kind === ResourceKind.GROUP ? (item as GroupResource).groupClass || "" : "")}:{" "}
548             {displayName || item.uuid}
549         </Typography>
550     );
551 };
552
553 export const ResourceLinkTail = connect((state: RootState, props: { uuid: string }) => {
554     const resource = getResource<LinkResource>(props.uuid)(state.resources);
555     const tailResource = getResource<Resource>(resource?.tailUuid || "")(state.resources);
556
557     return {
558         item: tailResource || { uuid: resource?.tailUuid || "", kind: resource?.tailKind || ResourceKind.NONE },
559     };
560 })((props: { item: Resource } & DispatchProp<any>) => renderResourceLink(props.dispatch, props.item));
561
562 export const ResourceLinkHead = connect((state: RootState, props: { uuid: string }) => {
563     const resource = getResource<LinkResource>(props.uuid)(state.resources);
564     const headResource = getResource<Resource>(resource?.headUuid || "")(state.resources);
565
566     return {
567         item: headResource || { uuid: resource?.headUuid || "", kind: resource?.headKind || ResourceKind.NONE },
568     };
569 })((props: { item: Resource } & DispatchProp<any>) => renderResourceLink(props.dispatch, props.item));
570
571 export const ResourceLinkUuid = connect((state: RootState, props: { uuid: string }) => {
572     const resource = getResource<LinkResource>(props.uuid)(state.resources);
573     return resource || { uuid: "" };
574 })(renderUuid);
575
576 export const ResourceLinkHeadUuid = connect((state: RootState, props: { uuid: string }) => {
577     const link = getResource<LinkResource>(props.uuid)(state.resources);
578     const headResource = getResource<Resource>(link?.headUuid || "")(state.resources);
579
580     return headResource || { uuid: "" };
581 })(renderUuid);
582
583 export const ResourceLinkTailUuid = connect((state: RootState, props: { uuid: string }) => {
584     const link = getResource<LinkResource>(props.uuid)(state.resources);
585     const tailResource = getResource<Resource>(link?.tailUuid || "")(state.resources);
586
587     return tailResource || { uuid: "" };
588 })(renderUuid);
589
590 const renderLinkDelete = (dispatch: Dispatch, item: LinkResource, canManage: boolean) => {
591     if (item.uuid) {
592         return canManage ? (
593             <Typography noWrap>
594                 <IconButton
595                     data-cy="resource-delete-button"
596                     onClick={() => dispatch<any>(openRemoveGroupMemberDialog(item.uuid))}
597                 >
598                     <RemoveIcon />
599                 </IconButton>
600             </Typography>
601         ) : (
602             <Typography noWrap>
603                 <IconButton
604                     disabled
605                     data-cy="resource-delete-button"
606                 >
607                     <RemoveIcon />
608                 </IconButton>
609             </Typography>
610         );
611     } else {
612         return <Typography noWrap></Typography>;
613     }
614 };
615
616 export const ResourceLinkDelete = connect((state: RootState, props: { uuid: string }) => {
617     const link = getResource<LinkResource>(props.uuid)(state.resources);
618     const isBuiltin = isBuiltinGroup(link?.headUuid || "") || isBuiltinGroup(link?.tailUuid || "");
619
620     return {
621         item: link || { uuid: "", kind: ResourceKind.NONE },
622         canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
623     };
624 })((props: { item: LinkResource; canManage: boolean } & DispatchProp<any>) => renderLinkDelete(props.dispatch, props.item, props.canManage));
625
626 export const ResourceLinkTailEmail = connect((state: RootState, props: { uuid: string }) => {
627     const link = getResource<LinkResource>(props.uuid)(state.resources);
628     const resource = getResource<UserResource>(link?.tailUuid || "")(state.resources);
629
630     return resource || { email: "" };
631 })(renderEmail);
632
633 export const ResourceLinkTailUsername = connect((state: RootState, props: { uuid: string }) => {
634     const link = getResource<LinkResource>(props.uuid)(state.resources);
635     const resource = getResource<UserResource>(link?.tailUuid || "")(state.resources);
636
637     return resource || { username: "" };
638 })(renderUsername);
639
640 const renderPermissionLevel = (dispatch: Dispatch, link: LinkResource, canManage: boolean) => {
641     return (
642         <Typography noWrap>
643             {formatPermissionLevel(link.name as PermissionLevel)}
644             {canManage ? (
645                 <IconButton
646                     data-cy="edit-permission-button"
647                     onClick={event => dispatch<any>(openPermissionEditContextMenu(event, link))}
648                 >
649                     <RenameIcon />
650                 </IconButton>
651             ) : (
652                 ""
653             )}
654         </Typography>
655     );
656 };
657
658 export const ResourceLinkHeadPermissionLevel = connect((state: RootState, props: { uuid: string }) => {
659     const link = getResource<LinkResource>(props.uuid)(state.resources);
660     const isBuiltin = isBuiltinGroup(link?.headUuid || "") || isBuiltinGroup(link?.tailUuid || "");
661
662     return {
663         link: link || { uuid: "", name: "", kind: ResourceKind.NONE },
664         canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
665     };
666 })((props: { link: LinkResource; canManage: boolean } & DispatchProp<any>) => renderPermissionLevel(props.dispatch, props.link, props.canManage));
667
668 export const ResourceLinkTailPermissionLevel = connect((state: RootState, props: { uuid: string }) => {
669     const link = getResource<LinkResource>(props.uuid)(state.resources);
670     const isBuiltin = isBuiltinGroup(link?.headUuid || "") || isBuiltinGroup(link?.tailUuid || "");
671
672     return {
673         link: link || { uuid: "", name: "", kind: ResourceKind.NONE },
674         canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
675     };
676 })((props: { link: LinkResource; canManage: boolean } & DispatchProp<any>) => renderPermissionLevel(props.dispatch, props.link, props.canManage));
677
678 const getResourceLinkCanManage = (state: RootState, link: LinkResource) => {
679     const headResource = getResource<Resource>(link.headUuid)(state.resources);
680     if (headResource && headResource.kind === ResourceKind.GROUP) {
681         return (headResource as GroupResource).canManage;
682     } else {
683         // true for now
684         return true;
685     }
686 };
687
688 // Process Resources
689 const resourceRunProcess = (dispatch: Dispatch, uuid: string) => {
690     return (
691         <div>
692             {uuid && (
693                 <Tooltip title="Run process">
694                     <IconButton onClick={() => dispatch<any>(openRunProcess(uuid))}>
695                         <ProcessIcon />
696                     </IconButton>
697                 </Tooltip>
698             )}
699         </div>
700     );
701 };
702
703 export const ResourceRunProcess = connect((state: RootState, props: { uuid: string }) => {
704     const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
705     return {
706         uuid: resource ? resource.uuid : "",
707     };
708 })((props: { uuid: string } & DispatchProp<any>) => resourceRunProcess(props.dispatch, props.uuid));
709
710 const renderWorkflowStatus = (uuidPrefix: string, ownerUuid?: string) => {
711     if (ownerUuid === getPublicUuid(uuidPrefix)) {
712         return renderStatus(WorkflowStatus.PUBLIC);
713     } else {
714         return renderStatus(WorkflowStatus.PRIVATE);
715     }
716 };
717
718 const renderStatus = (status: string) => (
719     <Typography
720         noWrap
721         style={{ width: "60px" }}
722     >
723         {status}
724     </Typography>
725 );
726
727 export const ResourceWorkflowStatus = connect((state: RootState, props: { uuid: string }) => {
728     const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
729     const uuidPrefix = getUuidPrefix(state);
730     return {
731         ownerUuid: resource ? resource.ownerUuid : "",
732         uuidPrefix,
733     };
734 })((props: { ownerUuid?: string; uuidPrefix: string }) => renderWorkflowStatus(props.uuidPrefix, props.ownerUuid));
735
736 export const ResourceContainerUuid = connect((state: RootState, props: { uuid: string }) => {
737     const process = getProcess(props.uuid)(state.resources);
738     return { uuid: process?.container?.uuid ? process?.container?.uuid : "" };
739 })((props: { uuid: string }) => renderUuid({ uuid: props.uuid }));
740
741 enum ColumnSelection {
742     OUTPUT_UUID = "outputUuid",
743     LOG_UUID = "logUuid",
744 }
745
746 const renderUuidLinkWithCopyIcon = (dispatch: Dispatch, item: ProcessResource, column: string) => {
747     const selectedColumnUuid = item[column];
748     return (
749         <Grid
750             container
751             alignItems="center"
752             wrap="nowrap"
753         >
754             <Grid item>
755                 {selectedColumnUuid ? (
756                     <Typography
757                         color="primary"
758                         style={{ width: "auto", cursor: "pointer" }}
759                         noWrap
760                         onClick={() => dispatch<any>(navigateTo(selectedColumnUuid))}
761                     >
762                         {selectedColumnUuid}
763                     </Typography>
764                 ) : (
765                     "-"
766                 )}
767             </Grid>
768             <Grid item>{selectedColumnUuid && renderUuidCopyIcon({ uuid: selectedColumnUuid })}</Grid>
769         </Grid>
770     );
771 };
772
773 export const ResourceOutputUuid = connect((state: RootState, props: { uuid: string }) => {
774     const resource = getResource<ProcessResource>(props.uuid)(state.resources);
775     return resource;
776 })((process: ProcessResource & DispatchProp<any>) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.OUTPUT_UUID));
777
778 export const ResourceLogUuid = connect((state: RootState, props: { uuid: string }) => {
779     const resource = getResource<ProcessResource>(props.uuid)(state.resources);
780     return resource;
781 })((process: ProcessResource & DispatchProp<any>) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.LOG_UUID));
782
783 export const ResourceParentProcess = connect((state: RootState, props: { uuid: string }) => {
784     const process = getProcess(props.uuid)(state.resources);
785     return { parentProcess: process?.containerRequest?.requestingContainerUuid || "" };
786 })((props: { parentProcess: string }) => renderUuid({ uuid: props.parentProcess }));
787
788 export const ResourceModifiedByUserUuid = connect((state: RootState, props: { uuid: string }) => {
789     const process = getProcess(props.uuid)(state.resources);
790     return { userUuid: process?.containerRequest?.modifiedByUserUuid || "" };
791 })((props: { userUuid: string }) => renderUuid({ uuid: props.userUuid }));
792
793 export const ResourceCreatedAtDate = connect((state: RootState, props: { uuid: string }) => {
794     const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
795     return { date: resource ? resource.createdAt : "" };
796 })((props: { date: string }) => renderDate(props.date));
797
798 export const ResourceLastModifiedDate = connect((state: RootState, props: { uuid: string }) => {
799     const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
800     return { date: resource ? resource.modifiedAt : "" };
801 })((props: { date: string }) => renderDate(props.date));
802
803 export const ResourceTrashDate = connect((state: RootState, props: { uuid: string }) => {
804     const resource = getResource<TrashableResource>(props.uuid)(state.resources);
805     return { date: resource ? resource.trashAt : "" };
806 })((props: { date: string }) => renderDate(props.date));
807
808 export const ResourceDeleteDate = connect((state: RootState, props: { uuid: string }) => {
809     const resource = getResource<TrashableResource>(props.uuid)(state.resources);
810     return { date: resource ? resource.deleteAt : "" };
811 })((props: { date: string }) => renderDate(props.date));
812
813 export const renderFileSize = (fileSize?: number) => (
814     <Typography
815         noWrap
816         style={{ minWidth: "45px" }}
817     >
818         {formatFileSize(fileSize)}
819     </Typography>
820 );
821
822 export const ResourceFileSize = connect((state: RootState, props: { uuid: string }) => {
823     const resource = getResource<CollectionResource>(props.uuid)(state.resources);
824
825     if (resource && resource.kind !== ResourceKind.COLLECTION) {
826         return { fileSize: "" };
827     }
828
829     return { fileSize: resource ? resource.fileSizeTotal : 0 };
830 })((props: { fileSize?: number }) => renderFileSize(props.fileSize));
831
832 const renderOwner = (owner: string) => <Typography noWrap>{owner || "-"}</Typography>;
833
834 export const ResourceOwner = connect((state: RootState, props: { uuid: string }) => {
835     const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
836     return { owner: resource ? resource.ownerUuid : "" };
837 })((props: { owner: string }) => renderOwner(props.owner));
838
839 export const ResourceOwnerName = connect((state: RootState, props: { uuid: string }) => {
840     const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
841     const ownerNameState = state.ownerName;
842     const ownerName = ownerNameState.find(it => it.uuid === resource!.ownerUuid);
843     return { owner: ownerName ? ownerName!.name : resource!.ownerUuid };
844 })((props: { owner: string }) => renderOwner(props.owner));
845
846 export const ResourceUUID = connect((state: RootState, props: { uuid: string }) => {
847     const resource = getResource<CollectionResource>(props.uuid)(state.resources);
848     return { uuid: resource ? resource.uuid : "" };
849 })((props: { uuid: string }) => renderUuid({ uuid: props.uuid }));
850
851 const renderVersion = (version: number) => {
852     return <Typography>{version ?? "-"}</Typography>;
853 };
854
855 export const ResourceVersion = connect((state: RootState, props: { uuid: string }) => {
856     const resource = getResource<CollectionResource>(props.uuid)(state.resources);
857     return { version: resource ? resource.version : "" };
858 })((props: { version: number }) => renderVersion(props.version));
859
860 const renderPortableDataHash = (portableDataHash: string | null) => (
861     <Typography noWrap>
862         {portableDataHash ? (
863             <>
864                 {portableDataHash}
865                 <CopyToClipboardSnackbar value={portableDataHash} />
866             </>
867         ) : (
868             "-"
869         )}
870     </Typography>
871 );
872
873 export const ResourcePortableDataHash = connect((state: RootState, props: { uuid: string }) => {
874     const resource = getResource<CollectionResource>(props.uuid)(state.resources);
875     return { portableDataHash: resource ? resource.portableDataHash : "" };
876 })((props: { portableDataHash: string }) => renderPortableDataHash(props.portableDataHash));
877
878 const renderFileCount = (fileCount: number) => {
879     return <Typography>{fileCount ?? "-"}</Typography>;
880 };
881
882 export const ResourceFileCount = connect((state: RootState, props: { uuid: string }) => {
883     const resource = getResource<CollectionResource>(props.uuid)(state.resources);
884     return { fileCount: resource ? resource.fileCount : "" };
885 })((props: { fileCount: number }) => renderFileCount(props.fileCount));
886
887 const userFromID = connect((state: RootState, props: { uuid: string }) => {
888     let userFullname = "";
889     const resource = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
890
891     if (resource) {
892         userFullname = getUserFullname(resource as User) || (resource as GroupContentsResource).name;
893     }
894
895     return { uuid: props.uuid, userFullname };
896 });
897
898 const ownerFromResourceId = compose(
899     connect((state: RootState, props: { uuid: string }) => {
900         const childResource = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
901         return { uuid: childResource ? (childResource as Resource).ownerUuid : "" };
902     }),
903     userFromID
904 );
905
906 const _resourceWithName = withStyles(
907     {},
908     { withTheme: true }
909 )((props: { uuid: string; userFullname: string; dispatch: Dispatch; theme: ArvadosTheme }) => {
910     const { uuid, userFullname, dispatch, theme } = props;
911     if (userFullname === "") {
912         dispatch<any>(loadResource(uuid, false));
913         return (
914             <Typography
915                 style={{ color: theme.palette.primary.main }}
916                 inline
917             >
918                 {uuid}
919             </Typography>
920         );
921     }
922
923     return (
924         <Typography
925             style={{ color: theme.palette.primary.main }}
926             inline
927         >
928             {userFullname} ({uuid})
929         </Typography>
930     );
931 });
932
933 const _resourceWithNameLink = withStyles(
934     {},
935     { withTheme: true }
936 )((props: { uuid: string; userFullname: string; dispatch: Dispatch; theme: ArvadosTheme }) => {
937     const { uuid, userFullname, dispatch, theme } = props;
938     if (!userFullname) {
939         dispatch<any>(loadResource(uuid, false));
940     }
941
942     return (
943         <Typography
944             style={{ color: theme.palette.primary.main, cursor: 'pointer' }}
945             inline
946             noWrap
947             onClick={() => dispatch<any>(navigateTo(uuid))}
948         >
949             {userFullname ? userFullname : uuid}
950         </Typography>
951     )
952 });
953
954
955 export const ResourceOwnerWithNameLink = ownerFromResourceId(_resourceWithNameLink);
956
957 export const ResourceOwnerWithName = ownerFromResourceId(_resourceWithName);
958
959 export const ResourceWithName = userFromID(_resourceWithName);
960
961 export const UserNameFromID = compose(userFromID)((props: { uuid: string; displayAsText?: string; userFullname: string; dispatch: Dispatch }) => {
962     const { uuid, userFullname, dispatch } = props;
963
964     if (userFullname === "") {
965         dispatch<any>(loadResource(uuid, false));
966     }
967     return <span>{userFullname ? userFullname : uuid}</span>;
968 });
969
970 export const ResponsiblePerson = compose(
971     connect((state: RootState, props: { uuid: string; parentRef: HTMLElement | null }) => {
972         let responsiblePersonName: string = "";
973         let responsiblePersonUUID: string = "";
974         let responsiblePersonProperty: string = "";
975
976         if (state.auth.config.clusterConfig.Collections.ManagedProperties) {
977             let index = 0;
978             const keys = Object.keys(state.auth.config.clusterConfig.Collections.ManagedProperties);
979
980             while (!responsiblePersonProperty && keys[index]) {
981                 const key = keys[index];
982                 if (state.auth.config.clusterConfig.Collections.ManagedProperties[key].Function === "original_owner") {
983                     responsiblePersonProperty = key;
984                 }
985                 index++;
986             }
987         }
988
989         let resource: Resource | undefined = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
990
991         while (resource && resource.kind !== ResourceKind.USER && responsiblePersonProperty) {
992             responsiblePersonUUID = (resource as CollectionResource).properties[responsiblePersonProperty];
993             resource = getResource<GroupContentsResource & UserResource>(responsiblePersonUUID)(state.resources);
994         }
995
996         if (resource && resource.kind === ResourceKind.USER) {
997             responsiblePersonName = getUserFullname(resource as UserResource) || (resource as GroupContentsResource).name;
998         }
999
1000         return { uuid: responsiblePersonUUID, responsiblePersonName, parentRef: props.parentRef };
1001     }),
1002     withStyles({}, { withTheme: true })
1003 )((props: { uuid: string | null; responsiblePersonName: string; parentRef: HTMLElement | null; theme: ArvadosTheme }) => {
1004     const { uuid, responsiblePersonName, parentRef, theme } = props;
1005
1006     if (!uuid && parentRef) {
1007         parentRef.style.display = "none";
1008         return null;
1009     } else if (parentRef) {
1010         parentRef.style.display = "block";
1011     }
1012
1013     if (!responsiblePersonName) {
1014         return (
1015             <Typography
1016                 style={{ color: theme.palette.primary.main }}
1017                 inline
1018                 noWrap
1019             >
1020                 {uuid}
1021             </Typography>
1022         );
1023     }
1024
1025     return (
1026         <Typography
1027             style={{ color: theme.palette.primary.main }}
1028             inline
1029             noWrap
1030         >
1031             {responsiblePersonName} ({uuid})
1032         </Typography>
1033     );
1034 });
1035
1036 const renderType = (type: string, subtype: string) => <Typography noWrap>{resourceLabel(type, subtype)}</Typography>;
1037
1038 export const ResourceType = connect((state: RootState, props: { uuid: string }) => {
1039     const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
1040     return { type: resource ? resource.kind : "", subtype: resource && resource.kind === ResourceKind.GROUP ? resource.groupClass : "" };
1041 })((props: { type: string; subtype: string }) => renderType(props.type, props.subtype));
1042
1043 export const ResourceStatus = connect((state: RootState, props: { uuid: string }) => {
1044     return { resource: getResource<GroupContentsResource>(props.uuid)(state.resources) };
1045 })((props: { resource: GroupContentsResource }) =>
1046     props.resource && props.resource.kind === ResourceKind.COLLECTION ? (
1047         <CollectionStatus uuid={props.resource.uuid} />
1048     ) : (
1049         <ProcessStatus uuid={props.resource.uuid} />
1050     )
1051 );
1052
1053 export const CollectionStatus = connect((state: RootState, props: { uuid: string }) => {
1054     return { collection: getResource<CollectionResource>(props.uuid)(state.resources) };
1055 })((props: { collection: CollectionResource }) =>
1056     props.collection.uuid !== props.collection.currentVersionUuid ? (
1057         <Typography>version {props.collection.version}</Typography>
1058     ) : (
1059         <Typography>head version</Typography>
1060     )
1061 );
1062
1063 export const CollectionName = connect((state: RootState, props: { uuid: string; className?: string }) => {
1064     return {
1065         collection: getResource<CollectionResource>(props.uuid)(state.resources),
1066         uuid: props.uuid,
1067         className: props.className,
1068     };
1069 })((props: { collection: CollectionResource; uuid: string; className?: string }) => (
1070     <Typography className={props.className}>{props.collection?.name || props.uuid}</Typography>
1071 ));
1072
1073 export const ProcessStatus = compose(
1074     connect((state: RootState, props: { uuid: string }) => {
1075         return { process: getProcess(props.uuid)(state.resources) };
1076     }),
1077     withStyles({}, { withTheme: true })
1078 )((props: { process?: Process; theme: ArvadosTheme }) =>
1079     props.process ? (
1080         <Chip
1081             label={getProcessStatus(props.process)}
1082             style={{
1083                 height: props.theme.spacing.unit * 3,
1084                 width: props.theme.spacing.unit * 12,
1085                 ...getProcessStatusStyles(getProcessStatus(props.process), props.theme),
1086                 fontSize: "0.875rem",
1087                 borderRadius: props.theme.spacing.unit * 0.625,
1088             }}
1089         />
1090     ) : (
1091         <Typography>-</Typography>
1092     )
1093 );
1094
1095 export const ProcessStartDate = connect((state: RootState, props: { uuid: string }) => {
1096     const process = getProcess(props.uuid)(state.resources);
1097     return { date: process && process.container ? process.container.startedAt : "" };
1098 })((props: { date: string }) => renderDate(props.date));
1099
1100 export const renderRunTime = (time: number) => (
1101     <Typography
1102         noWrap
1103         style={{ minWidth: "45px" }}
1104     >
1105         {formatTime(time, true)}
1106     </Typography>
1107 );
1108
1109 interface ContainerRunTimeProps {
1110     process: Process;
1111 }
1112
1113 interface ContainerRunTimeState {
1114     runtime: number;
1115 }
1116
1117 export const ContainerRunTime = connect((state: RootState, props: { uuid: string }) => {
1118     return { process: getProcess(props.uuid)(state.resources) };
1119 })(
1120     class extends React.Component<ContainerRunTimeProps, ContainerRunTimeState> {
1121         private timer: any;
1122
1123         constructor(props: ContainerRunTimeProps) {
1124             super(props);
1125             this.state = { runtime: this.getRuntime() };
1126         }
1127
1128         getRuntime() {
1129             return this.props.process ? getProcessRuntime(this.props.process) : 0;
1130         }
1131
1132         updateRuntime() {
1133             this.setState({ runtime: this.getRuntime() });
1134         }
1135
1136         componentDidMount() {
1137             this.timer = setInterval(this.updateRuntime.bind(this), 5000);
1138         }
1139
1140         componentWillUnmount() {
1141             clearInterval(this.timer);
1142         }
1143
1144         render() {
1145             return this.props.process ? renderRunTime(this.state.runtime) : <Typography>-</Typography>;
1146         }
1147     }
1148 );
1149
1150 export const GroupMembersCount = connect(
1151     (state: RootState, props: { uuid: string }) => {
1152         const group = getResource<GroupResource>(props.uuid)(state.resources);
1153
1154         return {
1155             value: group?.memberCount,
1156         };
1157
1158     }
1159 )(withTheme()((props: {value: number | null | undefined, theme:ArvadosTheme}) => {
1160     if (props.value === undefined) {
1161         // Loading
1162         return <Typography component={"div"}>
1163             <InlinePulser />
1164         </Typography>;
1165     } else if (props.value === null) {
1166         // Error
1167         return <Typography>
1168             <Tooltip title="Failed to load member count">
1169                 <ErrorIcon style={{color: props.theme.customs.colors.greyL}}/>
1170             </Tooltip>
1171         </Typography>;
1172     } else {
1173         return <Typography children={props.value} />;
1174     }
1175 }));