X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/0019db357afed7f52da38e9c398f3e39ce4eb162..2447ba1d6a912c806ce4bc3392667496bbcea49c:/services/workbench2/src/views/process-panel/process-io-card.tsx diff --git a/services/workbench2/src/views/process-panel/process-io-card.tsx b/services/workbench2/src/views/process-panel/process-io-card.tsx index da4d150a29..6d60b8cf22 100644 --- a/services/workbench2/src/views/process-panel/process-io-card.tsx +++ b/services/workbench2/src/views/process-panel/process-io-card.tsx @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React, { ReactElement, memo, useState } from "react"; +import React, { ReactElement, memo } from "react"; import { Dispatch } from "redux"; import { StyleRulesCallback, @@ -14,8 +14,6 @@ import { CardContent, Tooltip, Typography, - Tabs, - Tab, Table, TableHead, TableBody, @@ -27,7 +25,7 @@ import { CircularProgress, } from "@material-ui/core"; import { ArvadosTheme } from "common/custom-theme"; -import { CloseIcon, ImageIcon, InputIcon, ImageOffIcon, OutputIcon, MaximizeIcon, UnMaximizeIcon, InfoIcon } from "components/icon/icon"; +import { CloseIcon, InputIcon, OutputIcon, MaximizeIcon, UnMaximizeIcon, InfoIcon } from "components/icon/icon"; import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view"; import { BooleanCommandInputParameter, @@ -65,8 +63,12 @@ import { ProcessOutputCollectionFiles } from "./process-output-collection-files" import { Process } from "store/processes/process"; import { navigateTo } from "store/navigation/navigation-action"; import classNames from "classnames"; -import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet"; +import { DefaultVirtualCodeSnippet } from "components/default-code-snippet/default-virtual-code-snippet"; import { KEEP_URL_REGEX } from "models/resource"; +import { FixedSizeList } from 'react-window'; +import AutoSizer from "react-virtualized-auto-sizer"; +import { LinkProps } from "@material-ui/core/Link"; +import { ConditionalTabs } from "components/conditional-tabs/conditional-tabs"; type CssRules = | "card" @@ -76,21 +78,17 @@ type CssRules = | "avatar" | "iconHeader" | "tableWrapper" - | "tableRoot" - | "paramValue" + | "paramTableRoot" + | "paramTableCellText" + | "mountsTableRoot" + | "jsonWrapper" | "keepLink" | "collectionLink" - | "imagePreview" - | "valArray" | "secondaryVal" - | "secondaryRow" | "emptyValue" | "noBorderRow" | "symmetricTabs" - | "imagePlaceholder" - | "rowWithPreview" - | "labelColumn" - | "primaryRow"; + | "wrapTooltip"; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ card: { @@ -108,26 +106,98 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ alignSelf: "flex-start", paddingTop: theme.spacing.unit * 0.5, }, + // Card content content: { - height: `calc(100% - ${theme.spacing.unit * 7}px - ${theme.spacing.unit * 1.5}px)`, + height: `calc(100% - ${theme.spacing.unit * 6}px)`, padding: theme.spacing.unit * 1.0, paddingTop: 0, "&:last-child": { paddingBottom: theme.spacing.unit * 1, }, }, + // Card title title: { overflow: "hidden", paddingTop: theme.spacing.unit * 0.5, color: theme.customs.colors.greyD, fontSize: "1.875rem", }, + // Applies to table tab and collection table content tableWrapper: { height: "auto", - maxHeight: `calc(100% - ${theme.spacing.unit * 3}px)`, + maxHeight: `calc(100% - ${theme.spacing.unit * 6}px)`, overflow: "auto", + // Use flexbox to keep scrolling at the virtual list level + display: "flex", + flexDirection: "column", + alignItems: "stretch", // Stretches output collection to full width + }, - tableRoot: { + + // Param table virtual list styles + paramTableRoot: { + display: "flex", + flexDirection: "column", + overflow: "hidden", + // Flex header + "& thead tr": { + alignItems: "end", + "& th": { + padding: "4px 25px 10px", + }, + }, + "& tbody": { + height: "100vh", // Must be constrained by panel maxHeight + }, + // Flex header/body rows + "& thead tr, & > tbody tr": { + display: "flex", + // Flex header/body cells + "& th, & td": { + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + overflow: "hidden", + }, + // Column width overrides + "& th:nth-of-type(1), & td:nth-of-type(1)": { + flexGrow: 0.7, + }, + "& th:nth-last-of-type(1), & td:nth-last-of-type(1)": { + flexGrow: 2, + }, + }, + // Flex body rows + "& tbody tr": { + height: "40px", + // Flex body cells + "& td": { + padding: "2px 25px 2px", + overflow: "hidden", + display: "flex", + flexDirection: "row", + alignItems: "center", + whiteSpace: "nowrap", + }, + }, + }, + // Param value cell typography styles + paramTableCellText: { + overflow: "hidden", + display: "flex", + // Every cell contents requires a wrapper for the ellipsis + // since adding ellipses to an anchor element parent results in misaligned tooltip + "& a, & span": { + overflow: "hidden", + textOverflow: "ellipsis", + }, + '& pre': { + margin: 0, + overflow: "hidden", + textOverflow: "ellipsis", + }, + }, + mountsTableRoot: { width: "100%", "& thead th": { verticalAlign: "bottom", @@ -137,17 +207,18 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ paddingRight: "25px", }, }, - paramValue: { - display: "flex", - alignItems: "flex-start", - flexDirection: "column", + // JSON tab wrapper + jsonWrapper: { + height: `calc(100% - ${theme.spacing.unit * 6}px)`, }, keepLink: { color: theme.palette.primary.main, textDecoration: "none", + // Overflow wrap for mounts table overflowWrap: "break-word", cursor: "pointer", }, + // Output collection tab link collectionLink: { margin: "10px", "& a": { @@ -157,28 +228,9 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ cursor: "pointer", }, }, - imagePreview: { - maxHeight: "15em", - maxWidth: "15em", - marginBottom: theme.spacing.unit, - }, - valArray: { - display: "flex", - gap: "10px", - flexWrap: "wrap", - "& span": { - display: "inline", - }, - }, secondaryVal: { paddingLeft: "20px", }, - secondaryRow: { - height: "24px", - verticalAlign: "top", - position: "relative", - top: "-4px", - }, emptyValue: { color: theme.customs.colors.grey700, }, @@ -195,27 +247,9 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ flexBasis: "0", }, }, - imagePlaceholder: { - width: "60px", - height: "60px", - display: "flex", - alignItems: "center", - justifyContent: "center", - backgroundColor: "#cecece", - borderRadius: "10px", - }, - rowWithPreview: { - verticalAlign: "bottom", - }, - labelColumn: { - minWidth: "120px", - }, - primaryRow: { - height: "24px", - "& td": { - paddingTop: "2px", - paddingBottom: "2px", - }, + wrapTooltip: { + maxWidth: "600px", + wordWrap: "break-word", }, }); @@ -233,290 +267,206 @@ export interface ProcessIOCardDataProps { forceShowParams?: boolean; } -export interface ProcessIOCardActionProps { - navigateTo: (uuid: string) => void; -} - -const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({ - navigateTo: uuid => dispatch(navigateTo(uuid)), -}); - -type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles & MPVPanelProps; +type ProcessIOCardProps = ProcessIOCardDataProps & WithStyles & MPVPanelProps; export const ProcessIOCard = withStyles(styles)( - connect( - null, - mapDispatchToProps - )( - ({ - classes, - label, - params, - raw, - mounts, - outputUuid, - doHidePanel, - doMaximizePanel, - doUnMaximizePanel, - panelMaximized, - panelName, - process, - navigateTo, - forceShowParams, - }: ProcessIOCardProps) => { - const [mainProcTabState, setMainProcTabState] = useState(0); - const [subProcTabState, setSubProcTabState] = useState(0); - const handleMainProcTabChange = (event: React.MouseEvent, value: number) => { - setMainProcTabState(value); - }; - const handleSubProcTabChange = (event: React.MouseEvent, value: number) => { - setSubProcTabState(value); - }; - - const [showImagePreview, setShowImagePreview] = useState(false); - - const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon; - const mainProcess = !(process && process!.containerRequest.requestingContainerUuid); - const showParamTable = mainProcess || forceShowParams; + ({ + classes, + label, + params, + raw, + mounts, + outputUuid, + doHidePanel, + doMaximizePanel, + doUnMaximizePanel, + panelMaximized, + panelName, + process, + forceShowParams, + }: ProcessIOCardProps) => { + const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon; + const mainProcess = !(process && process!.containerRequest.requestingContainerUuid); + const showParamTable = mainProcess || forceShowParams; + + const loading = raw === null || raw === undefined || params === null; + + const hasRaw = !!(raw && Object.keys(raw).length > 0); + const hasParams = !!(params && params.length > 0); + // isRawLoaded allows subprocess panel to display raw even if it's {} + const isRawLoaded = !!(raw && Object.keys(raw).length >= 0); + + // Subprocess + const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length); + const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid); + // Subprocess should not show loading if hasOutputCollection or hasInputMounts + const subProcessLoading = loading && !hasOutputCollecton && !hasInputMounts; - const loading = raw === null || raw === undefined || params === null; - - const hasRaw = !!(raw && Object.keys(raw).length > 0); - const hasParams = !!(params && params.length > 0); - // isRawLoaded allows subprocess panel to display raw even if it's {} - const isRawLoaded = !!(raw && Object.keys(raw).length >= 0); - - // Subprocess - const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length); - const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid); - // Subprocess should not show loading if hasOutputCollection or hasInputMounts - const subProcessLoading = loading && !hasOutputCollecton && !hasInputMounts; - - return ( - - } - title={ - - {label} - - } - action={ -
- {mainProcess && ( - - { - setShowImagePreview(!showImagePreview); - }} - > - {showImagePreview ? : } - - - )} - {doUnMaximizePanel && panelMaximized && ( - - - - - - )} - {doMaximizePanel && !panelMaximized && ( - - - - - - )} - {doHidePanel && ( - - - - - - )} -
- } - /> - - {showParamTable ? ( - <> - {/* raw is undefined until params are loaded */} - {loading && ( - + } + title={ + + {label} + + } + action={ +
+ {doUnMaximizePanel && panelMaximized && ( + + + + + + )} + {doMaximizePanel && !panelMaximized && ( + + + + + + )} + {doHidePanel && ( + + - - - )} - {/* Once loaded, either raw or params may still be empty - * Raw when all params are empty - * Params when raw is provided by containerRequest properties but workflow mount is absent for preview - */} - {!loading && (hasRaw || hasParams) && ( - <> - - {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */} - {hasParams && } - {!forceShowParams && } - {hasOutputCollecton && } - - {mainProcTabState === 0 && params && hasParams && ( -
- + + + )} +
+ } + /> + + {showParamTable ? ( + <> + {/* raw is undefined until params are loaded */} + {loading && ( + + + + )} + {/* Once loaded, either raw or params may still be empty + * Raw when all params are empty + * Params when raw is provided by containerRequest properties but workflow mount is absent for preview + */} + {!loading && (hasRaw || hasParams) && ( + -
- )} - {(mainProcTabState === 1 || !hasParams) && ( -
- -
- )} - {mainProcTabState === 2 && hasOutputCollecton && ( - <> - {outputUuid && ( - - Output Collection:{" "} - { - navigateTo(outputUuid || ""); - }} - > - {outputUuid} - - - )} - - - )} - - - )} - {!loading && !hasRaw && !hasParams && ( - - - - )} - - ) : ( - // Subprocess - <> - {subProcessLoading ? ( - - - - ) : !subProcessLoading && (hasInputMounts || hasOutputCollecton || isRawLoaded) ? ( - <> - - {hasInputMounts && } - {hasOutputCollecton && } - {isRawLoaded && } - -
- {subProcTabState === 0 && hasInputMounts && } - {subProcTabState === 0 && hasOutputCollecton && ( - <> - {outputUuid && ( - - Output Collection:{" "} - { - navigateTo(outputUuid || ""); - }} - > - {outputUuid} - - - )} - - - )} - {isRawLoaded && (subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && ( -
- -
- )} -
- - ) : ( - - - - )} - - )} -
-
- ); - } - ) + />, + }, + { + show: !forceShowParams, + label: "JSON", + content: , + }, + { + show: hasOutputCollecton, + label: "Collection", + content: , + }, + ]} + /> + )} + {!loading && !hasRaw && !hasParams && ( + + + + )} + + ) : ( + // Subprocess + <> + {subProcessLoading ? ( + + + + ) : !subProcessLoading && (hasInputMounts || hasOutputCollecton || isRawLoaded) ? ( + , + }, + { + show: hasOutputCollecton, + label: "Collection", + content: , + }, + { + show: isRawLoaded, + label: "JSON", + content: , + }, + ]} + /> + ) : ( + + + + )} + + )} + + + ); + } ); export type ProcessIOValue = { @@ -529,132 +479,135 @@ export type ProcessIOValue = { export type ProcessIOParameter = { id: string; label: string; - value: ProcessIOValue[]; + value: ProcessIOValue; }; interface ProcessIOPreviewDataProps { data: ProcessIOParameter[]; - showImagePreview: boolean; valueLabel: string; + hidden?: boolean; } type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles; const ProcessIOPreview = memo( - withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => { + withStyles(styles)(({ data, valueLabel, hidden, classes }: ProcessIOPreviewProps) => { const showLabel = data.some((param: ProcessIOParameter) => param.label); - return ( + + const hasMoreValues = (index: number) => ( + data[index+1] && !isMainRow(data[index+1]) + ); + + const isMainRow = (param: ProcessIOParameter) => ( + param && + ((param.id || param.label) && + !param.value.secondary) + ); + + const RenderRow = ({index, style}) => { + const param = data[index]; + + const rowClasses = { + [classes.noBorderRow]: hasMoreValues(index), + }; + + return + + + + + {param.id} + + + + + {showLabel && + + + + {param.label} + + + + } + + + + + + {/** Collection is an anchor so doesn't require wrapper element */} + {param.value.collection} + + + ; + }; + + return ; }) ); interface ProcessValuePreviewProps { value: ProcessIOValue; - showImagePreview: boolean; } -const ProcessValuePreview = withStyles(styles)(({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles) => ( - - {value.imageUrl && showImagePreview ? ( - Inline Preview - ) : ( - "" - )} - {value.imageUrl && !showImagePreview ? : ""} - {value.display} +const ProcessValuePreview = withStyles(styles)(({ value, classes }: ProcessValuePreviewProps & WithStyles) => ( + + {value.display} )); interface ProcessIORawDataProps { data: ProcessIOParameter[]; + hidden?: boolean; } -const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => ( - - - +const ProcessIORaw = withStyles(styles)(({ data, hidden, classes }: ProcessIORawDataProps & WithStyles) => ( + )); interface ProcessInputMountsDataProps { mounts: InputCollectionMount[]; + hidden?: boolean; } type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles; @@ -662,10 +615,11 @@ type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles ({ auth: state.auth, - }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => ( + }))(({ mounts, hidden, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => ( @@ -694,6 +648,40 @@ const ProcessInputMounts = withStyles(styles)( )) ); +export interface ProcessOutputCollectionActionProps { + navigateTo: (uuid: string) => void; +} + +const mapNavigateToProps = (dispatch: Dispatch): ProcessOutputCollectionActionProps => ({ + navigateTo: uuid => dispatch(navigateTo(uuid)), +}); + +type ProcessOutputCollectionProps = {outputUuid: string | undefined, hidden?: boolean} & ProcessOutputCollectionActionProps & WithStyles; + +const ProcessOutputCollection = withStyles(styles)(connect(null, mapNavigateToProps)(({ outputUuid, hidden, navigateTo, classes }: ProcessOutputCollectionProps) => ( + +))); + type FileWithSecondaryFiles = { secondaryFiles: File[]; }; @@ -703,7 +691,7 @@ export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParam case isPrimitiveOfType(input, CWLType.BOOLEAN): const boolValue = (input as BooleanCommandInputParameter).value; return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0) - ? [{ display: renderPrimitiveValue(boolValue, false) }] + ? [{ display: {renderPrimitiveValue(boolValue, false)} }] : [{ display: }]; case isPrimitiveOfType(input, CWLType.INT): @@ -712,20 +700,20 @@ export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParam return intValue !== undefined && // Missing values are empty array !(Array.isArray(intValue) && intValue.length === 0) - ? [{ display: renderPrimitiveValue(intValue, false) }] + ? [{ display: {renderPrimitiveValue(intValue, false)} }] : [{ display: }]; case isPrimitiveOfType(input, CWLType.FLOAT): case isPrimitiveOfType(input, CWLType.DOUBLE): const floatValue = (input as FloatCommandInputParameter).value; return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0) - ? [{ display: renderPrimitiveValue(floatValue, false) }] + ? [{ display: {renderPrimitiveValue(floatValue, false)} }] : [{ display: }]; case isPrimitiveOfType(input, CWLType.STRING): const stringValue = (input as StringCommandInputParameter).value || undefined; return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0) - ? [{ display: renderPrimitiveValue(stringValue, false) }] + ? [{ display: {renderPrimitiveValue(stringValue, false)} }] : [{ display: }]; case isPrimitiveOfType(input, CWLType.FILE): @@ -746,21 +734,21 @@ export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParam case getEnumType(input) !== null: const enumValue = (input as EnumCommandInputParameter).value; - return enumValue !== undefined && enumValue ? [{ display:
{enumValue}
}] : [{ display: }]; + return enumValue !== undefined && enumValue ? [{ display: {enumValue} }] : [{ display: }]; case isArrayOfType(input, CWLType.STRING): const strArray = (input as StringArrayCommandInputParameter).value || []; - return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))} }] : [{ display: }]; + return strArray.length ? [{ display: {strArray.map(val => renderPrimitiveValue(val, true))} }] : [{ display: }]; case isArrayOfType(input, CWLType.INT): case isArrayOfType(input, CWLType.LONG): const intArray = (input as IntArrayCommandInputParameter).value || []; - return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))} }] : [{ display: }]; + return intArray.length ? [{ display: {intArray.map(val => renderPrimitiveValue(val, true))} }] : [{ display: }]; case isArrayOfType(input, CWLType.FLOAT): case isArrayOfType(input, CWLType.DOUBLE): const floatArray = (input as FloatArrayCommandInputParameter).value || []; - return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))} }] : [{ display: }]; + return floatArray.length ? [{ display: {floatArray.map(val => renderPrimitiveValue(val, true))} }] : [{ display: }]; case isArrayOfType(input, CWLType.FILE): const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || []; @@ -788,6 +776,27 @@ export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParam } }; +interface PrimitiveTooltipProps { + data: boolean | number | string; +} + +const PrimitiveTooltip = (props: React.PropsWithChildren) => ( + +
{props.children}
+
+); + +interface PrimitiveArrayTooltipProps { + data: string[]; +} + +const PrimitiveArrayTooltip = (props: React.PropsWithChildren) => ( + + {props.children} + +); + + const renderPrimitiveValue = (value: any, asChip: boolean) => { const isObject = typeof value === "object"; if (!isObject) { @@ -795,9 +804,10 @@ const renderPrimitiveValue = (value: any, asChip: boolean) => { ) : ( -
{String(value)}
+ <>{String(value)} ); } else { return asChip ? : ; @@ -829,7 +839,7 @@ const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProp // Passing a pdh always returns a relative wb2 collection url const pdhWbPath = getNavUrl(pdhUrl, auth); return pdhUrl && pdhWbPath ? ( - + View collection in Workbench
{pdhUrl}}> + View in keep-web
{keepUrlPath || "/"}}> & React.PropsWithChildren; + +const MuiLinkWithTooltip = withStyles(styles)((props: MuiLinkWithTooltipProps) => ( + + + {props.children} + + +)); + const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => { if (isExternalValue(file)) { return { display: }; @@ -931,12 +951,14 @@ const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, p if (isFileUrl(file.location)) { return { display: ( - {file.location} - + ), secondary, }; @@ -978,9 +1000,3 @@ const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles )); - -const ImagePlaceholder = withStyles(styles)(({ classes }: WithStyles) => ( - - - -));