diff --git a/packages/core/components/ChoiceGroup/ChoiceGroup.module.css b/packages/core/components/ChoiceGroup/ChoiceGroup.module.css index 63d12b99a..8cdd99e0f 100644 --- a/packages/core/components/ChoiceGroup/ChoiceGroup.module.css +++ b/packages/core/components/ChoiceGroup/ChoiceGroup.module.css @@ -1,4 +1,3 @@ - .choice-group input:disabled + label { cursor: not-allowed; opacity: 0.5; @@ -9,9 +8,9 @@ } .choice-group label > span { - font-size: var(--s-paragraph-size); + font-size: var(--l-paragraph-size); margin-right: 5px; - margin-top: 2px; + margin-top: 1px; padding-left: 24px !important; } @@ -24,8 +23,8 @@ border-color: var(--aqua); width: 16px; height: 16px; - top: 2.1px; - left: 2.1px; + top: 2px; + left: 2px; } .choice-group label::after { @@ -43,5 +42,5 @@ } .choice-group > div > div > div:not(:first-child) { - margin-left: 6px; + margin-left: var(--margin); } diff --git a/packages/core/components/ChoiceGroup/index.tsx b/packages/core/components/ChoiceGroup/index.tsx index 891b13d55..38244be1a 100644 --- a/packages/core/components/ChoiceGroup/index.tsx +++ b/packages/core/components/ChoiceGroup/index.tsx @@ -1,6 +1,8 @@ import { ChoiceGroup, IChoiceGroupOption } from "@fluentui/react"; import * as React from "react"; +import Tooltip from "../Tooltip"; + import styles from "./ChoiceGroup.module.css"; interface Props { @@ -22,7 +24,21 @@ export default function BaseChoiceGroup(props: Props) { { + return { + ...option, + onRenderField: (optionProps, defaultRender) => { + if (optionProps && defaultRender) { + return ( + + {defaultRender(optionProps) || <>} + + ); + } + return <>; + }, + }; + })} onChange={props.onChange} styles={{ root: styles.choiceGroup }} /> diff --git a/packages/core/components/ComboBox/ComboBox.module.css b/packages/core/components/ComboBox/ComboBox.module.css new file mode 100644 index 000000000..a1074965f --- /dev/null +++ b/packages/core/components/ComboBox/ComboBox.module.css @@ -0,0 +1,116 @@ +.combo-box { + border: 1px solid var(--border-color); + border-radius: var(--small-border-radius); +} + +.combo-box, .combo-box > :is(div, input), .combo-box :is(button, i) { + background-color: var(--secondary-background-color) !important; + color: var(--secondary-text-color) !important; +} + +.combo-box:focus, .combo-box:focus-visible { + outline: none; +} + +.combo-box:hover > button, .combo-box:hover > button i { + color: var(--highlight-text-color) !important; +} + +.combo-box > *::placeholder { + color: var(--secondary-text-color) !important; + font-style: italic; +} + +.combo-box::after { + border: unset; +} + +.combo-box:focus-within, .combo-box:focus, .combo-box:active { + border: 1px solid var(--aqua); +} + +.combo-box-caret { + position: absolute; + right: 0; +} + +.combo-box-label { + color: var(--primary-text-color) !important; + font-size: var(--l-paragraph-size); +} + +.combo-box-callout { + background-color: var(--primary-background-color); + border-radius: var(--small-border-radius); + box-shadow: var(--box-shadow); + padding: 8px 0; + max-height: 300px !important; +} + +.combo-box-callout > div { + background-color: var(--primary-background-color); + border-radius: 0; +} + +.combo-box-callout::after { + background-image: linear-gradient(transparent, var(--primary-background-color)); + content: " "; + display: block; + height: 30px; + pointer-events: none; + /* Adjusted for height - footer height */ + width: 100%; + z-index: 10; + position: absolute; + bottom: 5px; +} + +.combo-box-item :is(input, button, label){ + color: var(--primary-text-color); +} + +.combo-box-item button:disabled, .combo-box-item-disabled { + color: var(--primary-text-color); + opacity: 0.5; + cursor: not-allowed; +} + +.combo-box-item-disabled > div:hover, +.combo-box-item-disabled > div:hover :is(input, label) { + background-color: var(--primary-background-color) !important; + color: unset !important; +} + +.combo-box-item button:not(:disabled):hover, +.combo-box-item > div:hover, +.combo-box-item > div:hover :is(input, label) { + background-color: var(--highlight-background-color); + color: var(--highlight-text-color); +} + +.combo-box-callout button:not(:disabled):active, +.combo-box-callout button:not(:disabled):active:hover, +.combo-box-callout button:not(:disabled):focus, +.combo-box-item > div:active, +.combo-box-item > div:active:hover, +.combo-box-item > div:focus, +.combo-box-item > div:active :is(input, label), +.combo-box-item > div:active:hover :is(input, label), +.combo-box-item > div:focus :is(input, label) { + background-color: var(--secondary-dark); + color: var(--highlight-text-color); +} + +/* Remove border and background from checkbox for multiselect */ +.combo-box-item > div :is(i, div) { + border: none !important; + background: none !important; +} + +.combo-box-item > div:hover :is(i, div) { + color: var(--highlight-text-color) !important; +} + +.options-container { + padding-bottom: var(--margin); +} \ No newline at end of file diff --git a/packages/core/components/ComboBox/index.tsx b/packages/core/components/ComboBox/index.tsx new file mode 100644 index 000000000..6f6468e9c --- /dev/null +++ b/packages/core/components/ComboBox/index.tsx @@ -0,0 +1,94 @@ +import { ComboBox, IComboBoxOption, IRenderFunction, ISelectableOption } from "@fluentui/react"; +import classNames from "classnames"; +import Fuse from "fuse.js"; +import * as React from "react"; + +import styles from "./ComboBox.module.css"; + +const FUZZY_SEARCH_OPTIONS = { + // which keys to search on + keys: [{ name: "text", weight: 1.0 }], + + // return resulting matches sorted + shouldSort: true, + + // arbitrarily tuned; 0.0 requires a perfect match, 1.0 would match anything + threshold: 0.3, +}; + +interface Props { + className?: string; + selectedKey?: string; + disabled?: boolean; + label: string; + multiSelect?: boolean; + options: IComboBoxOption[]; + placeholder: string; + useComboBoxAsMenuWidth?: boolean; + onChange?: (option: IComboBoxOption | undefined, value?: string | undefined) => void; +} + +/** + * Custom styled wrapper for default fluentui component + */ +export default function BaseComboBox(props: Props) { + const { options, label, placeholder } = props; + + const [searchValue, setSearchValue] = React.useState(""); + + // Fuse logic borrowed from the ListPicker component + const fuse = React.useMemo(() => new Fuse(options, FUZZY_SEARCH_OPTIONS), [options]); + const filteredOptions = React.useMemo(() => { + const filteredRows = searchValue ? fuse.search(searchValue) : options; + return filteredRows.sort((a, b) => { + // If disabled, sort to the bottom + return a.disabled === b.disabled ? 0 : a.disabled ? 1 : -1; + }); + }, [options, searchValue, fuse]); + + const onRenderItem = ( + itemProps: ISelectableOption | undefined, + defaultRender: IRenderFunction | undefined + ): JSX.Element => { + if (itemProps && defaultRender) { + return ( + + {defaultRender(itemProps)} + + ); + } + return <>; + }; + + return ( + props.onChange?.(option, value)} + onItemClick={(_, option) => props.onChange?.(option)} + onInputValueChange={(value) => { + setSearchValue(value || ""); + }} + onRenderItem={(props, defaultRender) => onRenderItem(props, defaultRender)} + styles={{ + root: styles.comboBox, + label: styles.comboBoxLabel, + callout: styles.comboBoxCallout, + optionsContainer: styles.optionsContainer, + }} + useComboBoxAsMenuWidth={props?.useComboBoxAsMenuWidth} + /> + ); +} diff --git a/packages/core/components/EditMetadata/EditMetadata.module.css b/packages/core/components/EditMetadata/EditMetadata.module.css new file mode 100644 index 000000000..4c4c0c93e --- /dev/null +++ b/packages/core/components/EditMetadata/EditMetadata.module.css @@ -0,0 +1,122 @@ +.combo-box { + width: 295px; + padding-bottom: var(--margin); +} + +.choice-group { + padding-bottom: var(--margin); +} + +.chips { + padding-left: 5px; +} + +.footer { + margin-top: 20px; + width: 100%; +} + +.footer-align-right { + width: 100%; + display: flex; +} + +.footer-align-right > * { + margin-left: 10px; + width: min-content; +} + +.footer-align-right > *:first-child{ + margin-left: auto; +} + +.primary-button:disabled { + background-color: var(--border-color); + color: var(--primary-text-color) +} + +.submit-icon { + align-items: center; + border-radius: var(--small-border-radius); + cursor: pointer; + display: flex; + padding: 0 0.5em; +} + +.submit-icon.disabled { + cursor: default; + opacity: 0.8; +} + +.submit-icon:hover:not(.disabled), .submit-icon:focus:not(.disabled) { + background-color: var(--highlight-background-color); + color: var(--highlight-text-color); +} + +.selected-option-container { + display: flex; + margin-left: var(--margin); +} + +.selected-option { + align-items: center; + display: flex; + margin: 0; + height: 100%; + overflow: hidden; + text-overflow: ellipsis; + width: min-content; + white-space: nowrap; +} + +.selected-option-button { + color: var(--primary-text-color); + margin-left: 10px; +} + +.selected-option-button:hover { + background-color: unset; + color: var(--highlight-text-color); +} + +.text-field { + padding-bottom: var(--margin); + max-width: 300px;; +} + +.text-field > div > label, .text-field > div > label::after { + color: var(--secondary-text-color) !important; + font-size: var(--l-paragraph-size); +} + +.text-field :is(input) { + background-color: var(--secondary-background-color) !important; + color: var(--secondary-text-color) !important; + border-radius: 4px; +} + +.text-field > div:focus, .text-field div:focus-visible, .text-field:focus-visible, .text-field > div:active { + outline: none; +} + +.text-field > div > div { + border-radius: 4px; + border: 1px solid var(--border-color); + background-color: var(--secondary-background-color) !important; + color: var(--secondary-text-color) !important; +} + +.text-field > div > div::after { + border: 1px solid var(--aqua); + border-radius: 4px; + outline: none; +} + +.text-field > div > div > *::placeholder { + color: var(--secondary-text-color); + font-style: italic; +} + +.text-field > div > div:hover { + border: 1px solid var(--border-color) +} diff --git a/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx b/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx new file mode 100644 index 000000000..4078ba837 --- /dev/null +++ b/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx @@ -0,0 +1,96 @@ +import { IComboBoxOption } from "@fluentui/react"; +import classNames from "classnames"; +import * as React from "react"; + +import MetadataDetails, { ValueCountItem } from "./MetadataDetails"; +import { PrimaryButton, SecondaryButton } from "../Buttons"; +import ComboBox from "../ComboBox"; + +import styles from "./EditMetadata.module.css"; + +interface ExistingAnnotationProps { + onDismiss: () => void; + annotationValueMap: Map | undefined; + annotationOptions: { key: string; text: string }[]; + selectedFileCount: number; +} + +/** + * Component for selecting an existing annotation + * and then entering values for the selected files + */ +export default function ExistingAnnotationPathway(props: ExistingAnnotationProps) { + const [newValues, setNewValues] = React.useState(); + const [valueCount, setValueCount] = React.useState(); + + const onSelectMetadataField = ( + option: IComboBoxOption | undefined, + value: string | undefined + ) => { + let valueMap: ValueCountItem[] = []; + // FluentUI's combobox doesn't always register the entered value as an option, + // so we need to be able to check both + const selectedFieldName = option?.text || value; + if (!selectedFieldName) return; + // Track how many values we've seen, since some files may not have a value for this field + let totalValueCount = 0; + if (props?.annotationValueMap?.has(selectedFieldName)) { + const fieldValueToOccurenceMap = props.annotationValueMap.get(selectedFieldName); + valueMap = Object.keys(fieldValueToOccurenceMap).map((fieldName) => { + totalValueCount += fieldValueToOccurenceMap[fieldName]; + return { + value: fieldName, + fileCount: fieldValueToOccurenceMap[fieldName], + }; + }); + } + // If some or all of the files don't have values for this annotation, + // they won't be in the annotation map + if (totalValueCount < props.selectedFileCount) { + valueMap = [ + { + value: undefined, + fileCount: props.selectedFileCount - totalValueCount, + }, + ...valueMap, + ]; + } + setValueCount(valueMap); + }; + + function onSubmit() { + // TO DO: endpoint logic is in progress on a different branch + props.onDismiss(); + } + + return ( + <> + + {valueCount && ( + setNewValues(value)} + items={valueCount || []} + /> + )} +
+ + {valueCount && ( + + )} +
+ + ); +} diff --git a/packages/core/components/EditMetadata/MetadataDetails.module.css b/packages/core/components/EditMetadata/MetadataDetails.module.css new file mode 100644 index 000000000..35a8a2c65 --- /dev/null +++ b/packages/core/components/EditMetadata/MetadataDetails.module.css @@ -0,0 +1,64 @@ +.wrapper { + padding: var(--margin); + margin: var(--margin) 0; + border: 1px solid var(--border-color); + border-radius: var(--small-border-radius); +} + +.details-header > div, .details-header > div:hover, .details-header span { + background-color: var(--primary-background-color); + color: var(--secondary-text-color); + font-size: var(--xs-paragraph-size); + height: 36px; +} + +.details-header > div { + padding-top: 5px; + border-bottom: 1px solid var(--border-color); +} + +.details-header > div > div:hover { + background-color: unset; +} + +.column-right-align span, .column-right-align-cell { + display: flex; + justify-content: flex-end; +} + +.table-row { + position: relative; + cursor: default; +} + +.table-row > div:first-child { + color: var(--secondary-text-color); + background-color: unset; + font-size: var(--l-paragraph-size); + font-weight: 400; +} + +.table-row > div:hover:first-child, .table-row > div:hover { + color: var(--secondary-text-color); +} + +.table-title { + padding-bottom: 5px +} + +.stack { + width: 100%; +} + +.stack-item-left { + padding-top: 5px; + width: 300px; +} + +.stack-item-center { + padding-bottom: 14px; +} + +.stack-item-right { + width: 275px; +} diff --git a/packages/core/components/EditMetadata/MetadataDetails.tsx b/packages/core/components/EditMetadata/MetadataDetails.tsx new file mode 100644 index 000000000..c3c30c57f --- /dev/null +++ b/packages/core/components/EditMetadata/MetadataDetails.tsx @@ -0,0 +1,115 @@ +import { + DetailsList, + IColumn, + Icon, + IDetailsRowProps, + IRenderFunction, + SelectionMode, + Stack, + StackItem, + TextField, +} from "@fluentui/react"; +import * as React from "react"; + +import rootStyles from "./EditMetadata.module.css"; +import styles from "./MetadataDetails.module.css"; + +export interface ValueCountItem { + value: string | undefined; + fileCount: number; +} + +interface DetailsListProps { + items: ValueCountItem[]; + onChange: (value: string | undefined) => void; + newValues?: string; +} + +/** + * Component that displays a table of the current values for the selected annotation + * and provides an field for user to input new values. + * Used by both the new & existing annotation pathways + */ +export default function EditMetadataDetailsList(props: DetailsListProps) { + const { items } = props; + const renderRow = ( + rowProps: IDetailsRowProps | undefined, + defaultRender: IRenderFunction | undefined + ): JSX.Element => { + if (rowProps && defaultRender) { + return {defaultRender(rowProps)}; + } + return <>; + }; + + function renderItemColumn( + item: ValueCountItem, + _: number | undefined, + column: IColumn | undefined + ) { + const fieldContent = item[column?.fieldName as keyof ValueCountItem] as string; + if (!fieldContent) return "[No value] (blank)"; + if (column?.fieldName === "fileCount") { + return
{fieldContent}
; + } + return fieldContent; + } + + return ( +
+ + +

Existing values

+ renderRow(props, defaultRender)} + onRenderItemColumn={renderItemColumn} + /> +
+ + + + + {/* TODO: Display different entry types depending on datatype of annotation */} + + e.currentTarget.value && props.onChange(e.currentTarget.value) + } + placeholder="Value(s)" + defaultValue={props.newValues} + /> + +
+
+ ); +} diff --git a/packages/core/components/EditMetadata/NewAnnotationPathway.tsx b/packages/core/components/EditMetadata/NewAnnotationPathway.tsx new file mode 100644 index 000000000..a5e0b9c5e --- /dev/null +++ b/packages/core/components/EditMetadata/NewAnnotationPathway.tsx @@ -0,0 +1,180 @@ +import { IComboBoxOption, IconButton, Stack, StackItem, TextField } from "@fluentui/react"; +import classNames from "classnames"; +import * as React from "react"; + +import MetadataDetails, { ValueCountItem } from "./MetadataDetails"; +import { PrimaryButton, SecondaryButton } from "../Buttons"; +import ComboBox from "../ComboBox"; +import Tooltip from "../Tooltip"; +import { AnnotationType } from "../../entity/AnnotationFormatter"; + +import styles from "./EditMetadata.module.css"; + +enum EditStep { + PASSWORD = 0, // Placeholder + CREATE_FIELD = 1, + EDIT_FILES = 2, +} + +interface NewAnnotationProps { + onDismiss: () => void; + selectedFileCount: number; +} + +/** + * Component for submitting a new annotation + * and then entering values for the selected files + */ +export default function NewAnnotationPathway(props: NewAnnotationProps) { + const [step, setStep] = React.useState(EditStep.CREATE_FIELD); + const [newValues, setNewValues] = React.useState(); + const [newFieldName, setNewFieldName] = React.useState(""); + const [newFieldDataType, setNewFieldDataType] = React.useState(); + const [newDropdownOption, setNewDropdownOption] = React.useState(""); + const [dropdownOptions, setDropdownOptions] = React.useState([]); + + const addDropdownChip = (evt: React.FormEvent) => { + evt.preventDefault(); + if ( + newDropdownOption && + !dropdownOptions.filter((opt) => opt.key === newDropdownOption).length + ) { + const newOptionAsIComboBox: IComboBoxOption = { + key: newDropdownOption, + text: newDropdownOption, + }; + setDropdownOptions([...dropdownOptions, newOptionAsIComboBox]); + setNewDropdownOption(""); + } + }; + + const removeDropdownChip = (optionToRemove: IComboBoxOption) => { + setDropdownOptions(dropdownOptions.filter((opt) => opt !== optionToRemove)); + }; + + function onSubmit() { + // TO DO: endpoint logic is in progress on a different branch + props.onDismiss(); + } + + return ( + <> + {/* TO DO: Prevent user from entering a name that collides with existing annotation */} + setNewFieldName(newValue || "")} + placeholder="Add a new field name..." + value={newFieldName} + /> + {step === EditStep.CREATE_FIELD && ( + <> + { + return { + key: `datatype-${type}`, + text: type, + }; + })} + useComboBoxAsMenuWidth + onChange={(option) => setNewFieldDataType(option?.text || "")} + /> + {newFieldDataType === AnnotationType.DROPDOWN && ( + <> +
+ setNewDropdownOption(newValue || "")} + placeholder="Type an option name..." + iconProps={{ + className: classNames(styles.submitIcon), + iconName: "ReturnKey", + onClick: newDropdownOption ? addDropdownChip : undefined, + }} + value={newDropdownOption} + /> + +
+ {dropdownOptions.map((option) => ( +
+ +

{option.text}

+
+ removeDropdownChip(option)} + /> +
+ ))} +
+ + )} + + )} + {step === EditStep.EDIT_FILES && ( + setNewValues(value)} + items={[ + { + value: undefined, + fileCount: props.selectedFileCount, + } as ValueCountItem, + ]} + /> + )} +
+ + + {step === EditStep.EDIT_FILES && ( + setStep(EditStep.CREATE_FIELD)} + /> + )} + + + + {step === EditStep.CREATE_FIELD && ( + setStep(EditStep.EDIT_FILES)} + /> + )} + {step === EditStep.EDIT_FILES && ( + + )} + + +
+ + ); +} diff --git a/packages/core/components/EditMetadata/index.tsx b/packages/core/components/EditMetadata/index.tsx new file mode 100644 index 000000000..5abf4bd35 --- /dev/null +++ b/packages/core/components/EditMetadata/index.tsx @@ -0,0 +1,104 @@ +import * as React from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import ExistingAnnotationPathway from "./ExistingAnnotationPathway"; +import NewAnnotationPathway from "./NewAnnotationPathway"; +import ChoiceGroup from "../ChoiceGroup"; +import { TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../constants"; +import { interaction, metadata, selection } from "../../state"; + +import styles from "./EditMetadata.module.css"; + +enum EditMetadataPathway { + EXISTING = "existing", + NEW = "new", +} + +/** + * Form that acts as a wrapper for both metadata editing pathways (new vs existing annotations). + * Performs all necessary state selections on render and passes data as props to child components + */ +export default function EditMetadataForm() { + const dispatch = useDispatch(); + const onDismiss = () => { + dispatch(interaction.actions.hideVisibleModal()); + }; + const fileSelection = useSelector(selection.selectors.getFileSelection); + const fileCount = fileSelection.count(); + // Don't allow users to edit top level annotations (e.g., File Name) + const annotationOptions = useSelector(metadata.selectors.getSortedAnnotations) + .filter((annotation) => !TOP_LEVEL_FILE_ANNOTATION_NAMES.includes(annotation.name)) + .map((annotation) => { + return { + key: annotation.name, + text: annotation.displayName, + }; + }); + const [editPathway, setEditPathway] = React.useState( + EditMetadataPathway.EXISTING + ); + const [annotationValueMap, setAnnotationValueMap] = React.useState>(); + + React.useEffect(() => { + fileSelection.fetchAllDetails().then((fileDetails) => { + const annotationMapping = new Map(); + // Group details by annotation with a count for each value + fileDetails.forEach((file) => { + file.annotations.map((annotation) => { + // For now, if a file has multiple values for an annotation it should be considered a distinct set + const joinedValues = annotation.values.join(", "); + if (!annotationMapping.has(annotation.name)) { + annotationMapping.set(annotation.name, { [joinedValues]: 1 }); + } else { + const existing = annotationMapping.get(annotation.name); + annotationMapping.set(annotation.name, { + ...existing, + [joinedValues]: existing?.[joinedValues] + ? existing[joinedValues] + 1 + : 1, + }); + } + }); + }); + setAnnotationValueMap(annotationMapping); + }); + }, [fileSelection]); + + return ( + <> + + setEditPathway( + (option?.key as EditMetadataPathway) || EditMetadataPathway.EXISTING + ) + } + options={[ + { + key: EditMetadataPathway.EXISTING, + text: "Existing field", + title: "Choose a field that already exists in the data source(s).", + }, + { + key: EditMetadataPathway.NEW, + text: "New field", + title: "Create a new field that doesn't yet exist in the data source(s).", + }, + ]} + /> +
+ {editPathway === EditMetadataPathway.EXISTING ? ( + + ) : ( + + )} +
+ + ); +} diff --git a/packages/core/components/Modal/EditMetadata/index.tsx b/packages/core/components/Modal/EditMetadata/index.tsx new file mode 100644 index 000000000..95bb95c89 --- /dev/null +++ b/packages/core/components/Modal/EditMetadata/index.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; +import { useSelector } from "react-redux"; + +import { ModalProps } from ".."; +import BaseModal from "../BaseModal"; +import EditMetadataForm from "../../EditMetadata"; +import { selection } from "../../../state"; +import FileSelection from "../../../entity/FileSelection"; + +/** + * Dialog to display workflow for editing metadata for selected files + */ +export default function EditMetadata({ onDismiss }: ModalProps) { + const fileSelection = useSelector( + selection.selectors.getFileSelection, + FileSelection.selectionsAreEqual + ); + const totalFilesSelected = fileSelection.count(); + const filesSelectedCountString = `(${totalFilesSelected} file${ + totalFilesSelected === 1 ? "" : "s" + })`; + + return ( + } + onDismiss={onDismiss} + title={`Edit Metadata ${filesSelectedCountString}`} + /> + ); +} diff --git a/packages/core/components/Modal/index.tsx b/packages/core/components/Modal/index.tsx index e05a4bc65..05de95eb8 100644 --- a/packages/core/components/Modal/index.tsx +++ b/packages/core/components/Modal/index.tsx @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from "react-redux"; import { interaction } from "../../state"; import CodeSnippet from "./CodeSnippet"; import DataSource from "./DataSource"; +import EditMetadata from "./EditMetadata"; import MetadataManifest from "./MetadataManifest"; import SmallScreenWarning from "./SmallScreenWarning"; @@ -14,8 +15,9 @@ export interface ModalProps { export enum ModalType { CodeSnippet = 1, DataSource = 2, - MetadataManifest = 3, - SmallScreenWarning = 4, + EditMetadata = 3, + MetadataManifest = 4, + SmallScreenWarning = 5, } /** @@ -34,6 +36,8 @@ export default function Modal() { return ; case ModalType.DataSource: return ; + case ModalType.EditMetadata: + return ; case ModalType.MetadataManifest: return ; case ModalType.SmallScreenWarning: diff --git a/packages/core/entity/AnnotationFormatter/index.ts b/packages/core/entity/AnnotationFormatter/index.ts index 58320f99d..f17c6edf0 100644 --- a/packages/core/entity/AnnotationFormatter/index.ts +++ b/packages/core/entity/AnnotationFormatter/index.ts @@ -12,6 +12,7 @@ export enum AnnotationType { STRING = "Text", BOOLEAN = "YesNo", DURATION = "Duration", + DROPDOWN = "Dropdown", } export interface AnnotationFormatter { diff --git a/packages/core/hooks/useFileAccessContextMenu.ts b/packages/core/hooks/useFileAccessContextMenu.ts index cd79b3bbd..73c72c341 100644 --- a/packages/core/hooks/useFileAccessContextMenu.ts +++ b/packages/core/hooks/useFileAccessContextMenu.ts @@ -3,6 +3,7 @@ import * as React from "react"; import { useDispatch, useSelector } from "react-redux"; import useOpenWithMenuItems from "./useOpenWithMenuItems"; +import { ModalType } from "../components/Modal"; import FileDetail from "../entity/FileDetail"; import FileFilter from "../entity/FileFilter"; import { interaction, selection } from "../state"; @@ -135,6 +136,24 @@ export default (filters?: FileFilter[], onDismiss?: () => void) => { ], }, }, + ...(isQueryingAicsFms + ? [ + { + key: "edit", + text: "Edit metadata", + title: "Edit metadata of selected files", + disabled: !filters && fileSelection.count() === 0, + iconProps: { + iconName: "Edit", + }, + onClick() { + dispatch( + interaction.actions.setVisibleModal(ModalType.EditMetadata) + ); + }, + }, + ] + : []), { key: "download", text: "Download",