1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React, { ReactElement, memo, useState } from "react";
6 import { Dispatch } from "redux";
28 } from "@material-ui/core";
29 import { ArvadosTheme } from "common/custom-theme";
30 import { CloseIcon, ImageIcon, InputIcon, ImageOffIcon, OutputIcon, MaximizeIcon, UnMaximizeIcon, InfoIcon } from "components/icon/icon";
31 import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
33 BooleanCommandInputParameter,
34 CommandInputParameter,
37 DirectoryArrayCommandInputParameter,
38 DirectoryCommandInputParameter,
39 EnumCommandInputParameter,
40 FileArrayCommandInputParameter,
41 FileCommandInputParameter,
42 FloatArrayCommandInputParameter,
43 FloatCommandInputParameter,
44 IntArrayCommandInputParameter,
45 IntCommandInputParameter,
48 StringArrayCommandInputParameter,
49 StringCommandInputParameter,
51 } from "models/workflow";
52 import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
53 import { File } from "models/workflow";
54 import { getInlineFileUrl } from "views-components/context-menu/actions/helpers";
55 import { AuthState } from "store/auth/auth-reducer";
56 import mime from "mime";
57 import { DefaultView } from "components/default-view/default-view";
58 import { getNavUrl } from "routes/routes";
59 import { Link as RouterLink } from "react-router-dom";
60 import { Link as MuiLink } from "@material-ui/core";
61 import { InputCollectionMount } from "store/processes/processes-actions";
62 import { connect } from "react-redux";
63 import { RootState } from "store/store";
64 import { ProcessOutputCollectionFiles } from "./process-output-collection-files";
65 import { Process } from "store/processes/process";
66 import { navigateTo } from "store/navigation/navigation-action";
67 import classNames from "classnames";
68 import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet";
69 import { KEEP_URL_REGEX } from "models/resource";
95 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
100 paddingTop: theme.spacing.unit,
104 fontSize: "1.875rem",
105 color: theme.customs.colors.greyL,
108 alignSelf: "flex-start",
109 paddingTop: theme.spacing.unit * 0.5,
112 height: `calc(100% - ${theme.spacing.unit * 7}px - ${theme.spacing.unit * 1.5}px)`,
113 padding: theme.spacing.unit * 1.0,
116 paddingBottom: theme.spacing.unit * 1,
121 paddingTop: theme.spacing.unit * 0.5,
122 color: theme.customs.colors.greyD,
123 fontSize: "1.875rem",
127 maxHeight: `calc(100% - ${theme.spacing.unit * 3}px)`,
133 verticalAlign: "bottom",
134 paddingBottom: "10px",
137 paddingRight: "25px",
142 alignItems: "flex-start",
143 flexDirection: "column",
146 color: theme.palette.primary.main,
147 textDecoration: "none",
148 overflowWrap: "break-word",
154 color: theme.palette.primary.main,
155 textDecoration: "none",
156 overflowWrap: "break-word",
163 marginBottom: theme.spacing.unit,
178 verticalAlign: "top",
179 position: "relative",
183 color: theme.customs.colors.grey700,
187 borderBottom: "none",
189 paddingBottom: "2px",
202 alignItems: "center",
203 justifyContent: "center",
204 backgroundColor: "#cecece",
205 borderRadius: "10px",
208 verticalAlign: "bottom",
217 paddingBottom: "2px",
222 export enum ProcessIOCardType {
223 INPUT = "Input Parameters",
224 OUTPUT = "Output Parameters",
226 export interface ProcessIOCardDataProps {
228 label: ProcessIOCardType;
229 params: ProcessIOParameter[] | null;
231 mounts?: InputCollectionMount[];
233 forceShowParams?: boolean;
236 export interface ProcessIOCardActionProps {
237 navigateTo: (uuid: string) => void;
240 const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
241 navigateTo: uuid => dispatch<any>(navigateTo(uuid)),
244 type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles<CssRules> & MPVPanelProps;
246 export const ProcessIOCard = withStyles(styles)(
266 }: ProcessIOCardProps) => {
267 const [mainProcTabState, setMainProcTabState] = useState(0);
268 const [subProcTabState, setSubProcTabState] = useState(0);
269 const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
270 setMainProcTabState(value);
272 const handleSubProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
273 setSubProcTabState(value);
276 const [showImagePreview, setShowImagePreview] = useState(false);
278 const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
279 const mainProcess = !(process && process!.containerRequest.requestingContainerUuid);
280 const showParamTable = mainProcess || forceShowParams;
282 const loading = raw === null || raw === undefined || params === null;
284 const hasRaw = !!(raw && Object.keys(raw).length > 0);
285 const hasParams = !!(params && params.length > 0);
286 // isRawLoaded allows subprocess panel to display raw even if it's {}
287 const isRawLoaded = !!(raw && Object.keys(raw).length >= 0);
290 const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
291 const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
292 // Subprocess should not show loading if hasOutputCollection or hasInputMounts
293 const subProcessLoading = loading && !hasOutputCollecton && !hasInputMounts;
297 className={classes.card}
298 data-cy="process-io-card"
301 className={classes.header}
303 content: classes.title,
304 avatar: classes.avatar,
306 avatar={<PanelIcon className={classes.iconHeader} />}
320 title={"Toggle Image Preview"}
324 data-cy="io-preview-image-toggle"
326 setShowImagePreview(!showImagePreview);
329 {showImagePreview ? <ImageIcon /> : <ImageOffIcon />}
333 {doUnMaximizePanel && panelMaximized && (
335 title={`Unmaximize ${panelName || "panel"}`}
338 <IconButton onClick={doUnMaximizePanel}>
343 {doMaximizePanel && !panelMaximized && (
345 title={`Maximize ${panelName || "panel"}`}
348 <IconButton onClick={doMaximizePanel}>
355 title={`Close ${panelName || "panel"}`}
359 disabled={panelMaximized}
360 onClick={doHidePanel}
369 <CardContent className={classes.content}>
372 {/* raw is undefined until params are loaded */}
383 {/* Once loaded, either raw or params may still be empty
384 * Raw when all params are empty
385 * Params when raw is provided by containerRequest properties but workflow mount is absent for preview
387 {!loading && (hasRaw || hasParams) && (
390 value={mainProcTabState}
391 onChange={handleMainProcTabChange}
393 className={classes.symmetricTabs}
395 {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
396 {hasParams && <Tab label="Parameters" />}
397 {!forceShowParams && <Tab label="JSON" />}
398 {hasOutputCollecton && <Tab label="Collection" />}
400 {mainProcTabState === 0 && params && hasParams && (
401 <div className={classes.tableWrapper}>
404 showImagePreview={showImagePreview}
405 valueLabel={forceShowParams ? "Default value" : "Value"}
409 {(mainProcTabState === 1 || !hasParams) && (
410 <div className={classes.tableWrapper}>
411 <ProcessIORaw data={raw} />
414 {mainProcTabState === 2 && hasOutputCollecton && (
417 <Typography className={classes.collectionLink}>
418 Output Collection:{" "}
420 className={classes.keepLink}
422 navigateTo(outputUuid || "");
429 <ProcessOutputCollectionFiles
431 currentItemUuid={outputUuid}
438 {!loading && !hasRaw && !hasParams && (
445 <DefaultView messages={["No parameters found"]} />
452 {subProcessLoading ? (
461 ) : !subProcessLoading && (hasInputMounts || hasOutputCollecton || isRawLoaded) ? (
464 value={subProcTabState}
465 onChange={handleSubProcTabChange}
467 className={classes.symmetricTabs}
469 {hasInputMounts && <Tab label="Collections" />}
470 {hasOutputCollecton && <Tab label="Collection" />}
471 {isRawLoaded && <Tab label="JSON" />}
473 <div className={classes.tableWrapper}>
474 {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
475 {subProcTabState === 0 && hasOutputCollecton && (
478 <Typography className={classes.collectionLink}>
479 Output Collection:{" "}
481 className={classes.keepLink}
483 navigateTo(outputUuid || "");
490 <ProcessOutputCollectionFiles
492 currentItemUuid={outputUuid}
496 {isRawLoaded && (subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
497 <div className={classes.tableWrapper}>
498 <ProcessIORaw data={raw} />
510 <DefaultView messages={["No data to display"]} />
522 export type ProcessIOValue = {
523 display: ReactElement<any, any>;
525 collection?: ReactElement<any, any>;
529 export type ProcessIOParameter = {
532 value: ProcessIOValue[];
535 interface ProcessIOPreviewDataProps {
536 data: ProcessIOParameter[];
537 showImagePreview: boolean;
541 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
543 const ProcessIOPreview = memo(
544 withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => {
545 const showLabel = data.some((param: ProcessIOParameter) => param.label);
548 className={classes.tableRoot}
549 aria-label="Process IO Preview"
553 <TableCell>Name</TableCell>
554 {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
555 <TableCell>{valueLabel}</TableCell>
556 <TableCell>Collection</TableCell>
560 {data.map((param: ProcessIOParameter) => {
561 const firstVal = param.value.length > 0 ? param.value[0] : undefined;
562 const rest = param.value.slice(1);
563 const mainRowClasses = {
564 [classes.noBorderRow]: rest.length > 0,
565 [classes.primaryRow]: true
569 <React.Fragment key={param.id}>
571 className={classNames(mainRowClasses)}
572 data-cy="process-io-param"
574 <TableCell>{param.id}</TableCell>
575 {showLabel && <TableCell>{param.label}</TableCell>}
580 showImagePreview={showImagePreview}
584 <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
585 <Typography className={classes.paramValue}>{firstVal?.collection}</Typography>
588 {rest.map((val, i) => {
590 [classes.noBorderRow]: i < rest.length - 1,
591 [classes.secondaryRow]: val.secondary,
592 [classes.primaryRow]: !val.secondary,
596 className={classNames(rowClasses)}
600 {showLabel && <TableCell />}
604 showImagePreview={showImagePreview}
607 <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
608 <Typography className={classes.paramValue}>{val.collection}</Typography>
622 interface ProcessValuePreviewProps {
623 value: ProcessIOValue;
624 showImagePreview: boolean;
627 const ProcessValuePreview = withStyles(styles)(({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles<CssRules>) => (
628 <Typography className={classes.paramValue}>
629 {value.imageUrl && showImagePreview ? (
631 className={classes.imagePreview}
638 {value.imageUrl && !showImagePreview ? <ImagePlaceholder /> : ""}
639 <span className={classNames(classes.valArray, value.secondary && classes.secondaryVal)}>{value.display}</span>
643 interface ProcessIORawDataProps {
644 data: ProcessIOParameter[];
647 const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
648 <Paper elevation={0}>
650 lines={[JSON.stringify(data, null, 2)]}
656 interface ProcessInputMountsDataProps {
657 mounts: InputCollectionMount[];
660 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
662 const ProcessInputMounts = withStyles(styles)(
663 connect((state: RootState) => ({
665 }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
667 className={classes.tableRoot}
668 aria-label="Process Input Mounts"
672 <TableCell>Path</TableCell>
673 <TableCell>Portable Data Hash</TableCell>
677 {mounts.map(mount => (
678 <TableRow key={mount.path}>
680 <pre>{mount.path}</pre>
684 to={getNavUrl(mount.pdh, auth)}
685 className={classes.keepLink}
697 type FileWithSecondaryFiles = {
698 secondaryFiles: File[];
701 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
703 case isPrimitiveOfType(input, CWLType.BOOLEAN):
704 const boolValue = (input as BooleanCommandInputParameter).value;
705 return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
706 ? [{ display: renderPrimitiveValue(boolValue, false) }]
707 : [{ display: <EmptyValue /> }];
709 case isPrimitiveOfType(input, CWLType.INT):
710 case isPrimitiveOfType(input, CWLType.LONG):
711 const intValue = (input as IntCommandInputParameter).value;
712 return intValue !== undefined &&
713 // Missing values are empty array
714 !(Array.isArray(intValue) && intValue.length === 0)
715 ? [{ display: renderPrimitiveValue(intValue, false) }]
716 : [{ display: <EmptyValue /> }];
718 case isPrimitiveOfType(input, CWLType.FLOAT):
719 case isPrimitiveOfType(input, CWLType.DOUBLE):
720 const floatValue = (input as FloatCommandInputParameter).value;
721 return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0)
722 ? [{ display: renderPrimitiveValue(floatValue, false) }]
723 : [{ display: <EmptyValue /> }];
725 case isPrimitiveOfType(input, CWLType.STRING):
726 const stringValue = (input as StringCommandInputParameter).value || undefined;
727 return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0)
728 ? [{ display: renderPrimitiveValue(stringValue, false) }]
729 : [{ display: <EmptyValue /> }];
731 case isPrimitiveOfType(input, CWLType.FILE):
732 const mainFile = (input as FileCommandInputParameter).value;
733 // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
734 const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
735 const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles];
736 const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
738 ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : ""))
739 : [{ display: <EmptyValue /> }];
741 case isPrimitiveOfType(input, CWLType.DIRECTORY):
742 const directory = (input as DirectoryCommandInputParameter).value;
743 return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
744 ? [directoryToProcessIOValue(directory, auth, pdh)]
745 : [{ display: <EmptyValue /> }];
747 case getEnumType(input) !== null:
748 const enumValue = (input as EnumCommandInputParameter).value;
749 return enumValue !== undefined && enumValue ? [{ display: <pre>{enumValue}</pre> }] : [{ display: <EmptyValue /> }];
751 case isArrayOfType(input, CWLType.STRING):
752 const strArray = (input as StringArrayCommandInputParameter).value || [];
753 return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
755 case isArrayOfType(input, CWLType.INT):
756 case isArrayOfType(input, CWLType.LONG):
757 const intArray = (input as IntArrayCommandInputParameter).value || [];
758 return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
760 case isArrayOfType(input, CWLType.FLOAT):
761 case isArrayOfType(input, CWLType.DOUBLE):
762 const floatArray = (input as FloatArrayCommandInputParameter).value || [];
763 return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
765 case isArrayOfType(input, CWLType.FILE):
766 const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || [];
767 const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
769 // Convert each main and secondaryFiles into array of ProcessIOValue preserving ordering
770 let fileArrayValues: ProcessIOValue[] = [];
771 for (let i = 0; i < fileArrayMainFiles.length; i++) {
772 const secondaryFiles = (fileArrayMainFiles[i] as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
773 fileArrayValues.push(
774 // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
775 ...(fileArrayMainFiles[i] ? [fileToProcessIOValue(fileArrayMainFiles[i], false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
776 ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh))
780 return fileArrayValues.length ? fileArrayValues : [{ display: <EmptyValue /> }];
782 case isArrayOfType(input, CWLType.DIRECTORY):
783 const directories = (input as DirectoryArrayCommandInputParameter).value || [];
784 return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
787 return [{ display: <UnsupportedValue /> }];
791 const renderPrimitiveValue = (value: any, asChip: boolean) => {
792 const isObject = typeof value === "object";
797 label={String(value)}
800 <pre key={value}>{String(value)}</pre>
803 return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
808 * @returns keep url without keep: prefix
810 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
811 const isKeepUrl = file.location?.startsWith("keep:") || false;
812 const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
813 return keepUrl || "";
816 interface KeepUrlProps {
818 res: File | Directory;
822 const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
823 const keepUrl = getKeepUrl(res, pdh);
824 return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
827 const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
828 const pdhUrl = getResourcePdhUrl(res, pdh);
829 // Passing a pdh always returns a relative wb2 collection url
830 const pdhWbPath = getNavUrl(pdhUrl, auth);
831 return pdhUrl && pdhWbPath ? (
832 <Tooltip title={"View collection in Workbench"}>
835 className={classes.keepLink}
845 const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
846 const keepUrl = getKeepUrl(res, pdh);
847 const keepUrlParts = keepUrl ? keepUrl.split("/") : [];
848 const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : "";
850 const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
851 return keepUrlPathNav ? (
852 <Tooltip title={"View in keep-web"}>
854 className={classes.keepLink}
855 href={keepUrlPathNav}
857 rel="noopener noreferrer"
867 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
868 let keepUrl = getKeepUrl(file, pdh);
869 return getInlineFileUrl(
870 `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
871 auth.config.keepWebServiceUrl,
872 auth.config.keepWebInlineServiceUrl
876 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
877 const keepUrl = getKeepUrl(file, pdh);
878 return getInlineFileUrl(
879 `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
880 auth.config.keepWebServiceUrl,
881 auth.config.keepWebInlineServiceUrl
885 const isFileImage = (basename?: string): boolean => {
886 return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
889 const isFileUrl = (location?: string): boolean =>
890 !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
892 const normalizeDirectoryLocation = (directory: Directory): Directory => {
893 if (!directory.location) {
898 location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
902 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
903 if (isExternalValue(directory)) {
904 return { display: <UnsupportedValue /> };
907 const normalizedDirectory = normalizeDirectoryLocation(directory);
912 res={normalizedDirectory}
919 res={normalizedDirectory}
926 const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
927 if (isExternalValue(file)) {
928 return { display: <UnsupportedValue /> };
931 if (isFileUrl(file.location)) {
945 const resourcePdh = getResourcePdhUrl(file, pdh);
955 imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
957 resourcePdh !== mainFilePdh ? (
969 const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
971 export const EmptyValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>);
973 const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>);
975 const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
978 label={"Cannot display value"}
982 const ImagePlaceholder = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
983 <span className={classes.imagePlaceholder}>