diff --git a/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx b/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx index 705b24ee..d640e2fa 100644 --- a/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx +++ b/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx @@ -7,6 +7,7 @@ import { createSandbox } from "sinon"; import AnnotationFilterForm from ".."; import Annotation from "../../../entity/Annotation"; +import { AnnotationType } from "../../../entity/AnnotationFormatter"; import FileFilter from "../../../entity/FileFilter"; import { initialState, reducer, reduxLogics, interaction, selection } from "../../../state"; import HttpAnnotationService from "../../../services/AnnotationService/HttpAnnotationService"; @@ -20,7 +21,7 @@ describe("", () => { annotationDisplayName: "Foo", annotationName: "foo", description: "", - type: "Text", + type: AnnotationType.STRING, }); const sandbox = createSandbox(); @@ -169,7 +170,7 @@ describe("", () => { annotationDisplayName: "Foo", annotationName: "foo", description: "", - type: "YesNo", + type: AnnotationType.BOOLEAN, }); const responseStub = { @@ -268,7 +269,7 @@ describe("", () => { annotationDisplayName: "Foo", annotationName: "foo", description: "", - type: "Number", + type: AnnotationType.NUMBER, }); const sandbox = createSandbox(); @@ -323,7 +324,7 @@ describe("", () => { annotationDisplayName: "Foo", annotationName: "foo", description: "", - type: "Duration", + type: AnnotationType.DURATION, }); const sandbox = createSandbox(); diff --git a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx index 5fcb53cd..9afd0293 100644 --- a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx +++ b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx @@ -22,6 +22,7 @@ import { createSandbox } from "sinon"; import { FESBaseUrl, TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants"; import Annotation from "../../../entity/Annotation"; import AnnotationName from "../../../entity/Annotation/AnnotationName"; +import { AnnotationType } from "../../../entity/AnnotationFormatter"; import { FmsFileAnnotation } from "../../../services/FileService"; import FileFilter from "../../../entity/FileFilter"; import FileDownloadServiceNoop from "../../../services/FileDownloadService/FileDownloadServiceNoop"; @@ -44,13 +45,13 @@ describe("", () => { annotationDisplayName: "Foo", annotationName: "foo", description: "", - type: "Text", + type: AnnotationType.STRING, }); const barAnnotation = new Annotation({ annotationDisplayName: "Bar", annotationName: "bar", description: "", - type: "Text", + type: AnnotationType.STRING, }); const baseDisplayAnnotations = TOP_LEVEL_FILE_ANNOTATIONS.filter( diff --git a/packages/core/components/EditMetadata/EditMetadata.module.css b/packages/core/components/EditMetadata/EditMetadata.module.css index 68512067..d6c86c78 100644 --- a/packages/core/components/EditMetadata/EditMetadata.module.css +++ b/packages/core/components/EditMetadata/EditMetadata.module.css @@ -11,6 +11,24 @@ padding-left: 5px; } +.error-message { + color: var(--error-status-text-color); + line-height: 1.15; +} + +.status-message { + color: var(--info-status-text-color); + display: flex; +} + +.spinner { + margin: 0 0.5em; +} + +.spinner > div { + border-color: var(--info-status-background-color) var(--info-status-text-color) var(--info-status-text-color); +} + .footer { margin-top: 20px; width: 100%; @@ -89,7 +107,7 @@ font-size: var(--l-paragraph-size); } -.text-field :is(input) { +.text-field :is(input), .text-field :is(textarea) { background-color: var(--secondary-background-color) !important; color: var(--secondary-text-color) !important; border-radius: 4px; diff --git a/packages/core/components/EditMetadata/NewAnnotationPathway.tsx b/packages/core/components/EditMetadata/NewAnnotationPathway.tsx index 12b05a72..152cf9e9 100644 --- a/packages/core/components/EditMetadata/NewAnnotationPathway.tsx +++ b/packages/core/components/EditMetadata/NewAnnotationPathway.tsx @@ -1,12 +1,25 @@ -import { IComboBoxOption, IconButton, Stack, StackItem, TextField } from "@fluentui/react"; +import { + IComboBoxOption, + IconButton, + Spinner, + SpinnerSize, + Stack, + StackItem, + TextField, +} from "@fluentui/react"; import classNames from "classnames"; import * as React from "react"; +import { useDispatch, useSelector } from "react-redux"; import MetadataDetails, { ValueCountItem } from "./MetadataDetails"; import { PrimaryButton, SecondaryButton } from "../Buttons"; import ComboBox from "../ComboBox"; import Tooltip from "../Tooltip"; +import Annotation from "../../entity/Annotation"; import { AnnotationType } from "../../entity/AnnotationFormatter"; +import { AnnotationValue } from "../../services/AnnotationService"; +import { interaction, metadata } from "../../state"; +import { ProcessStatus } from "../../state/interaction/actions"; import styles from "./EditMetadata.module.css"; @@ -18,21 +31,96 @@ enum EditStep { interface NewAnnotationProps { onDismiss: () => void; - onUnsavedChanges: () => void; + setHasUnsavedChanges: (arg: boolean) => void; selectedFileCount: number; } +// Simplified version of status message +interface AnnotationStatus { + status: ProcessStatus; + message: string | undefined; +} + /** * Component for submitting a new annotation * and then entering values for the selected files */ export default function NewAnnotationPathway(props: NewAnnotationProps) { + const dispatch = useDispatch(); + // Destructure to prevent unnecessary useEffect triggers + const { onDismiss, setHasUnsavedChanges } = props; + const [step, setStep] = React.useState(EditStep.CREATE_FIELD); - const [newValues, setNewValues] = React.useState(); + const [newValues, setNewValues] = React.useState(); const [newFieldName, setNewFieldName] = React.useState(""); - const [newFieldDataType, setNewFieldDataType] = React.useState(); + const [newFieldDescription, setNewFieldDescription] = React.useState(""); + const [newFieldDataType, setNewFieldDataType] = React.useState( + AnnotationType.STRING + ); const [newDropdownOption, setNewDropdownOption] = React.useState(""); const [dropdownOptions, setDropdownOptions] = React.useState([]); + const [submissionStatus, setSubmissionStatus] = React.useState(); + + const statuses = useSelector(interaction.selectors.getProcessStatuses); + const annotationCreationStatus = React.useMemo( + () => statuses.find((status) => status.processId === newFieldName), + [newFieldName, statuses] + ); + // Check for updates to the annotation submission status + React.useEffect(() => { + const checkForStatusUpdates = async () => { + const currentStatus = annotationCreationStatus?.data?.status; + switch (currentStatus) { + case ProcessStatus.ERROR: + case ProcessStatus.STARTED: + case ProcessStatus.PROGRESS: + setSubmissionStatus({ + status: currentStatus, + message: annotationCreationStatus?.data?.msg, + }); + return; + case ProcessStatus.SUCCEEDED: + if (newFieldName && newValues) { + try { + dispatch( + interaction.actions.editFiles({ [newFieldName]: [newValues] }) + ); + } catch (e) { + setSubmissionStatus({ + status: ProcessStatus.ERROR, + message: `Failed to create annotation: ${e}`, + }); + } finally { + setHasUnsavedChanges(false); + onDismiss(); + } + } + default: + return; + } + }; + checkForStatusUpdates(); + }, [ + annotationCreationStatus, + dispatch, + setHasUnsavedChanges, + newFieldName, + newValues, + onDismiss, + ]); + + const onChangeAlphanumericField = ( + e: React.FormEvent, + newValue: string | undefined + ) => { + const regex = /^[\w\-\s]+$/g; + // Restricts character entry to alphanumeric + if (newValue && !regex.test(newValue)) { + e.preventDefault(); + } else { + setNewFieldName(newValue || ""); + } + }; const addDropdownChip = (evt: React.FormEvent) => { evt.preventDefault(); @@ -53,17 +141,34 @@ export default function NewAnnotationPathway(props: NewAnnotationProps) { setDropdownOptions(dropdownOptions.filter((opt) => opt !== optionToRemove)); }; - function onSubmit() { - // TO DO: endpoint logic is in progress on a different branch - props.onDismiss(); - } - - // TO DO: determine when to submit the new annotation post req function onCreateNewAnnotation() { - props?.onUnsavedChanges(); + setHasUnsavedChanges(true); setStep(EditStep.EDIT_FILES); } + function onSubmit() { + if (!newFieldName || !newValues) { + setSubmissionStatus({ + status: ProcessStatus.ERROR, + message: `Missing ${!newFieldName ? "field name" : "values for file"}`, + }); + return; + } + const annotation = new Annotation({ + annotationDisplayName: newFieldName, + annotationName: newFieldName, + description: newFieldDescription, + type: newFieldDataType, + }); + // File editing step occurs after dispatch is processed and status is updated + dispatch( + metadata.actions.createAnnotation( + annotation, + dropdownOptions.map((opt) => opt.text) + ) + ); + } + return ( <> {/* TO DO: Prevent user from entering a name that collides with existing annotation */} @@ -71,12 +176,21 @@ export default function NewAnnotationPathway(props: NewAnnotationProps) { required label="New metadata field name" className={styles.textField} - onChange={(_, newValue) => setNewFieldName(newValue || "")} + onChange={(ev, newValue) => onChangeAlphanumericField(ev, newValue)} placeholder="Add a new field name..." value={newFieldName} /> {step === EditStep.CREATE_FIELD && ( <> + setNewFieldDescription(newValue || "")} + placeholder="Add a short description of the new field..." + value={newFieldDescription} + /> - setNewFieldDataType((option?.key as AnnotationType) || "") + setNewFieldDataType( + (option?.key as AnnotationType) || AnnotationType.STRING + ) } /> {newFieldDataType === AnnotationType.DROPDOWN && ( @@ -132,17 +248,38 @@ export default function NewAnnotationPathway(props: NewAnnotationProps) { )} {step === EditStep.EDIT_FILES && ( - setNewValues(value)} - /> + <> + {newFieldDescription.trim() && ( + <> + Description: {newFieldDescription} + + )} + setNewValues(value)} + /> + {submissionStatus && ( +
+ {submissionStatus.status === ProcessStatus.STARTED && ( + + )} + {submissionStatus?.message} +
+ )} + )}
diff --git a/packages/core/components/EditMetadata/index.tsx b/packages/core/components/EditMetadata/index.tsx index 5e424f3e..488dc888 100644 --- a/packages/core/components/EditMetadata/index.tsx +++ b/packages/core/components/EditMetadata/index.tsx @@ -17,7 +17,7 @@ enum EditMetadataPathway { interface EditMetadataProps { className?: string; onDismiss: () => void; - onUnsavedChanges: (hasUnsavedChanges: boolean) => void; + setHasUnsavedChanges: (hasUnsavedChanges: boolean) => void; } /** @@ -101,7 +101,7 @@ export default function EditMetadataForm(props: EditMetadataProps) { ) : ( props.onUnsavedChanges(true)} + setHasUnsavedChanges={(arg) => props.setHasUnsavedChanges(arg)} selectedFileCount={fileCount} /> )} diff --git a/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx b/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx index 57f9c002..e4b823dc 100644 --- a/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx +++ b/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx @@ -6,6 +6,7 @@ import { Provider } from "react-redux"; import * as sinon from "sinon"; import Annotation from "../../../entity/Annotation"; +import { AnnotationType } from "../../../entity/AnnotationFormatter"; import FileDetail from "../../../entity/FileDetail"; import FileSet from "../../../entity/FileSet"; import { initialState } from "../../../state"; @@ -18,7 +19,7 @@ describe("", () => { annotationDisplayName: "Name", annotationName: "file_name", description: "name of file", - type: "Text", + type: AnnotationType.STRING, }); function makeItemData() { diff --git a/packages/core/components/Modal/EditMetadata/index.tsx b/packages/core/components/Modal/EditMetadata/index.tsx index c9b97025..d4be1537 100644 --- a/packages/core/components/Modal/EditMetadata/index.tsx +++ b/packages/core/components/Modal/EditMetadata/index.tsx @@ -39,7 +39,7 @@ export default function EditMetadata({ onDismiss }: ModalProps) {

diff --git a/packages/core/components/Modal/MetadataManifest/test/MetadataManifest.test.tsx b/packages/core/components/Modal/MetadataManifest/test/MetadataManifest.test.tsx index c0fed64a..6981cdb4 100644 --- a/packages/core/components/Modal/MetadataManifest/test/MetadataManifest.test.tsx +++ b/packages/core/components/Modal/MetadataManifest/test/MetadataManifest.test.tsx @@ -14,6 +14,7 @@ import { createSandbox } from "sinon"; import Modal, { ModalType } from "../.."; import { FESBaseUrl } from "../../../../constants"; import Annotation from "../../../../entity/Annotation"; +import { AnnotationType } from "../../../../entity/AnnotationFormatter"; import FileFilter from "../../../../entity/FileFilter"; import { initialState, interaction, reduxLogics } from "../../../../state"; import HttpFileService from "../../../../services/FileService/HttpFileService"; @@ -82,7 +83,7 @@ describe("", () => { annotationDisplayName: "Cell Line", annotationName: "Cell Line", description: "test", - type: "text", + type: AnnotationType.STRING, }), ], }, @@ -127,7 +128,7 @@ describe("", () => { annotationDisplayName: c, annotationName: c, description: "test", - type: "text", + type: AnnotationType.STRING, }) ), }, diff --git a/packages/core/entity/Annotation/index.ts b/packages/core/entity/Annotation/index.ts index ba5839aa..3532f07b 100644 --- a/packages/core/entity/Annotation/index.ts +++ b/packages/core/entity/Annotation/index.ts @@ -1,7 +1,10 @@ import { get as _get, sortBy } from "lodash"; import AnnotationName from "./AnnotationName"; -import annotationFormatterFactory, { AnnotationFormatter } from "../AnnotationFormatter"; +import annotationFormatterFactory, { + AnnotationFormatter, + AnnotationType, +} from "../AnnotationFormatter"; import FileDetail from "../FileDetail"; import { AnnotationValue } from "../../services/AnnotationService"; @@ -14,11 +17,21 @@ export interface AnnotationResponse { annotationDisplayName: string; annotationName: string; description: string; - type: string; + type: AnnotationType; isOpenFileLink?: boolean; units?: string; } +/** + * MMS queries return a different JSON structure than FES + */ +export interface AnnotationResponseMms { + annotationId: number; + annotationTypeId: number; + description: "string"; + name: string; +} + /** * Representation of an annotation available for filtering, grouping, or sorting files from FMS. */ @@ -60,7 +73,7 @@ export default class Annotation { return this.annotation.annotationName; } - public get type(): string { + public get type(): string | AnnotationType { return this.annotation.type; } diff --git a/packages/core/entity/Annotation/mocks.ts b/packages/core/entity/Annotation/mocks.ts index 46064f07..ad4c67d0 100644 --- a/packages/core/entity/Annotation/mocks.ts +++ b/packages/core/entity/Annotation/mocks.ts @@ -1,38 +1,40 @@ +import { AnnotationType } from "../AnnotationFormatter"; + export const annotationsJson = [ { annotationDisplayName: "Date created", annotationName: "date_created", description: "Date and time file was created", - type: "Date/Time", + type: AnnotationType.DATETIME, }, { annotationDisplayName: "Cell line", annotationName: "cell_line", description: "AICS cell line", - type: "Text", + type: AnnotationType.STRING, }, { annotationDisplayName: "Cells are dead", annotationName: "cell_dead", description: "Does this field contain dead cells", - type: "Yes/No", + type: AnnotationType.BOOLEAN, }, { annotationDisplayName: "Is matrigel hard?", annotationName: "matrigel_hardened", description: "Whether or not matrigel is hard.", - type: "Yes/No", + type: AnnotationType.BOOLEAN, }, { annotationDisplayName: "Objective", annotationName: "objective", description: "Imaging objective", - type: "Number", + type: AnnotationType.NUMBER, }, { annotationName: "Local File Path", annotationDisplayName: "Local File Path", description: "Path to file in on-premises storage.", - type: "Text", + type: AnnotationType.STRING, }, ]; diff --git a/packages/core/entity/AnnotationFormatter/index.ts b/packages/core/entity/AnnotationFormatter/index.ts index f17c6edf..15002aaa 100644 --- a/packages/core/entity/AnnotationFormatter/index.ts +++ b/packages/core/entity/AnnotationFormatter/index.ts @@ -15,6 +15,18 @@ export enum AnnotationType { DROPDOWN = "Dropdown", } +// ID table source via Labkey server: executeQuery.view?schemaName=filemetadata&query.queryName=AnnotationType +export const AnnotationTypeIdMap = { + [AnnotationType.STRING]: 1, + [AnnotationType.NUMBER]: 2, + [AnnotationType.BOOLEAN]: 3, + [AnnotationType.DATETIME]: 4, + [AnnotationType.DROPDOWN]: 5, + // [AnnotationType.LOOKUP]: 6, // Not currently supported + [AnnotationType.DATE]: 7, + [AnnotationType.DURATION]: 8, +}; + export interface AnnotationFormatter { displayValue(value: any, unit?: string): string; valueOf(value: any): string | number | boolean; diff --git a/packages/core/services/AnnotationService/DatabaseAnnotationService/index.ts b/packages/core/services/AnnotationService/DatabaseAnnotationService/index.ts index b3b2270f..06524aca 100644 --- a/packages/core/services/AnnotationService/DatabaseAnnotationService/index.ts +++ b/packages/core/services/AnnotationService/DatabaseAnnotationService/index.ts @@ -140,4 +140,13 @@ export default class DatabaseAnnotationService implements AnnotationService { }, [] as string[]) .filter((annotation) => !annotationSet.has(annotation)); } + + public createAnnotation(annotation: Annotation): Promise { + const tableName = this.dataSourceNames.sort().join(DatabaseService.LIST_DELIMITER); + return this.databaseService.addNewColumn( + tableName, + annotation.name, + annotation.description + ); + } } diff --git a/packages/core/services/AnnotationService/HttpAnnotationService/index.ts b/packages/core/services/AnnotationService/HttpAnnotationService/index.ts index 0fdb9ff1..ab63f673 100644 --- a/packages/core/services/AnnotationService/HttpAnnotationService/index.ts +++ b/packages/core/services/AnnotationService/HttpAnnotationService/index.ts @@ -2,7 +2,8 @@ import { map } from "lodash"; import AnnotationService, { AnnotationValue } from ".."; import HttpServiceBase from "../../HttpServiceBase"; -import Annotation, { AnnotationResponse } from "../../../entity/Annotation"; +import Annotation, { AnnotationResponse, AnnotationResponseMms } from "../../../entity/Annotation"; +import { AnnotationType, AnnotationTypeIdMap } from "../../../entity/AnnotationFormatter"; import FileFilter from "../../../entity/FileFilter"; import { TOP_LEVEL_FILE_ANNOTATIONS, TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../../constants"; @@ -25,6 +26,7 @@ export default class HttpAnnotationService extends HttpServiceBase implements An public static readonly BASE_ANNOTATION_HIERARCHY_ROOT_URL = `${HttpAnnotationService.BASE_ANNOTATION_URL}/hierarchy/root`; public static readonly BASE_ANNOTATION_HIERARCHY_UNDER_PATH_URL = `${HttpAnnotationService.BASE_ANNOTATION_URL}/hierarchy/under-path`; public static readonly BASE_AVAILABLE_ANNOTATIONS_UNDER_HIERARCHY = `${HttpAnnotationService.BASE_ANNOTATION_URL}/hierarchy/available`; + public static readonly BASE_MMS_ANNOTATION_URL = `metadata-management-service/1.0/annotation/`; /** * Fetch all annotations. @@ -116,4 +118,28 @@ export default class HttpAnnotationService extends HttpServiceBase implements An private buildQueryParams(param: QueryParam, values: string[]): string { return values.map((value) => `${param}=${value}`).join("&"); } + + /** + * Creates a new annotation via the metadata-management-service + * @param annotation The new annotation to create + * @param annotationOptions If not empty, pre-set options for annotations of type Dropdown + */ + public async createAnnotation( + annotation: Annotation, + annotationOptions: string[] = [] + ): Promise { + const requestUrl = `${this.metadataManagementServiceBaseURl}/${HttpAnnotationService.BASE_MMS_ANNOTATION_URL}`; + const annotationType = annotation.type as AnnotationType; + const requestBody = { + annotationTypeId: AnnotationTypeIdMap[annotationType], + annotationOptions, + description: annotation.description, + name: annotation.name, + }; + const response = await this.post( + requestUrl, + JSON.stringify(requestBody) + ); + return response.data; + } } diff --git a/packages/core/services/AnnotationService/index.ts b/packages/core/services/AnnotationService/index.ts index 2f70902f..198b7cf3 100644 --- a/packages/core/services/AnnotationService/index.ts +++ b/packages/core/services/AnnotationService/index.ts @@ -1,9 +1,13 @@ -import Annotation from "../../entity/Annotation"; +import Annotation, { AnnotationResponseMms } from "../../entity/Annotation"; import FileFilter from "../../entity/FileFilter"; export type AnnotationValue = string | number | boolean | Date; export default interface AnnotationService { + createAnnotation( + annotation: Annotation, + annotationOptions?: string[] + ): Promise; fetchValues(annotation: string): Promise; fetchAnnotations(): Promise; fetchRootHierarchyValues(hierarchy: string[], filters: FileFilter[]): Promise; diff --git a/packages/core/services/DatabaseService/index.ts b/packages/core/services/DatabaseService/index.ts index 8fc8b886..eae037ae 100644 --- a/packages/core/services/DatabaseService/index.ts +++ b/packages/core/services/DatabaseService/index.ts @@ -55,7 +55,7 @@ export default abstract class DatabaseService { public abstract execute(_sql: string): Promise; - private static columnTypeToAnnotationType(columnType: string): string { + private static columnTypeToAnnotationType(columnType: string): AnnotationType { switch (columnType) { case "INTEGER": case "BIGINT": @@ -456,7 +456,7 @@ export default abstract class DatabaseService { annotationNameToTypeMap[row["column_name"]] === DatabaseService.OPEN_FILE_LINK_TYPE ? AnnotationType.STRING - : annotationNameToTypeMap[row["column_name"]] || + : (annotationNameToTypeMap[row["column_name"]] as AnnotationType) || DatabaseService.columnTypeToAnnotationType(row["data_type"]), }) ); @@ -529,4 +529,21 @@ export default abstract class DatabaseService { throw err; } } + + public async addNewColumn( + datasourceName: string, + columnName: string, + description?: string + ): Promise { + await this.execute(`ALTER TABLE "${datasourceName}" ADD COLUMN "${columnName}" VARCHAR;`); + + // Cache is now invalid since we added a column + this.dataSourceToAnnotationsMap.delete(datasourceName); + + if (description?.trim() && this.existingDataSources.has(this.SOURCE_METADATA_TABLE)) { + await this + .execute(`INSERT INTO "${this.SOURCE_METADATA_TABLE}" ("Column Name", "Description") + VALUES ('${columnName}', '${description}');`); + } + } } diff --git a/packages/core/state/interaction/logics.ts b/packages/core/state/interaction/logics.ts index c6306276..33b05d3a 100644 --- a/packages/core/state/interaction/logics.ts +++ b/packages/core/state/interaction/logics.ts @@ -575,18 +575,6 @@ const editFilesLogic = createLogic({ resolve(); }) .catch((err) => reject(err)); - // try { - // await fileService.editFile( - // fileId, - // annotations, - // annotationNameToAnnotationMap - // ); - // totalFileEdited += 1; - // onProgress(); - // resolve(); - // } catch (err) { - // reject(err); - // } }) ); diff --git a/packages/core/state/interaction/test/logics.test.ts b/packages/core/state/interaction/test/logics.test.ts index 88668aa1..a4e4098b 100644 --- a/packages/core/state/interaction/test/logics.test.ts +++ b/packages/core/state/interaction/test/logics.test.ts @@ -707,14 +707,14 @@ describe("Interaction logics", () => { annotationDisplayName: AnnotationName.KIND, annotationName: AnnotationName.KIND, description: "", - type: "Text", + type: AnnotationType.STRING, annotationId: 0, }), new Annotation({ annotationDisplayName: "Cell Line", annotationName: "Cell Line", description: "", - type: "Text", + type: AnnotationType.STRING, annotationId: 1, }), ]; diff --git a/packages/core/state/metadata/actions.ts b/packages/core/state/metadata/actions.ts index 5075af55..24ce683c 100644 --- a/packages/core/state/metadata/actions.ts +++ b/packages/core/state/metadata/actions.ts @@ -1,6 +1,6 @@ import { makeConstant } from "@aics/redux-utils"; -import Annotation from "../../entity/Annotation"; +import Annotation, { AnnotationResponseMms } from "../../entity/Annotation"; import { DataSource } from "../../services/DataSourceService"; const STATE_BRANCH_NAME = "metadata"; @@ -42,6 +42,31 @@ export function requestAnnotations(): RequestAnnotationAction { }; } +/** + * CREATE_ANNOTATION + * + * Intention to create a new annotation in the data service that will become available for grouping, filtering, and sorting files. + */ +export const CREATE_ANNOTATION = makeConstant(STATE_BRANCH_NAME, "create-annotation"); + +export interface CreateAnnotationAction { + payload: { + annotation: Annotation; + annotationOptions?: string[]; + }; + type: string; +} + +export function createAnnotation( + annotation: Annotation, + annotationOptions?: string[] +): CreateAnnotationAction { + return { + payload: { annotation, annotationOptions }, + type: CREATE_ANNOTATION, + }; +} + /** * RECEIVE_DATA_SOURCES * @@ -125,3 +150,23 @@ export function receiveDatasetManifest(name: string, uri: string): ReceiveDatase type: RECEIVE_DATASET_MANIFEST, }; } + +/** + * STORE_NEW_ANNOTATION + * Temporarily cache a newly created annotation before it is ingested to FES + */ +export const STORE_NEW_ANNOTATION = makeConstant(STATE_BRANCH_NAME, "store-new-annotation"); + +export interface StoreNewAnnotationAction { + payload: { + annotation: AnnotationResponseMms; + }; + type: string; +} + +export function storeNewAnnotation(annotation: AnnotationResponseMms): StoreNewAnnotationAction { + return { + payload: { annotation }, + type: STORE_NEW_ANNOTATION, + }; +} diff --git a/packages/core/state/metadata/logics.ts b/packages/core/state/metadata/logics.ts index b4c0bb1b..a2c4dc14 100644 --- a/packages/core/state/metadata/logics.ts +++ b/packages/core/state/metadata/logics.ts @@ -1,8 +1,9 @@ import { uniqBy } from "lodash"; import { createLogic } from "redux-logic"; -import { interaction, ReduxLogicDeps, selection } from ".."; +import { interaction, metadata, ReduxLogicDeps, selection } from ".."; import { + CREATE_ANNOTATION, RECEIVE_ANNOTATIONS, ReceiveAnnotationAction, receiveAnnotations, @@ -12,9 +13,14 @@ import { REQUEST_DATA_SOURCES, REQUEST_DATASET_MANIFEST, RequestDatasetManifest, + STORE_NEW_ANNOTATION, + storeNewAnnotation, + StoreNewAnnotationAction, } from "./actions"; import * as metadataSelectors from "./selectors"; +import Annotation, { AnnotationResponseMms } from "../../entity/Annotation"; import AnnotationName from "../../entity/Annotation/AnnotationName"; +import { AnnotationType, AnnotationTypeIdMap } from "../../entity/AnnotationFormatter"; import FileSort, { SortOrder } from "../../entity/FileSort"; import HttpAnnotationService from "../../services/AnnotationService/HttpAnnotationService"; @@ -108,6 +114,61 @@ const receiveAnnotationsLogic = createLogic({ type: RECEIVE_ANNOTATIONS, }); +const createNewAnnotationLogic = createLogic({ + async process(deps: ReduxLogicDeps, dispatch, done) { + const { getState, httpClient, action } = deps; + const { annotation, annotationOptions } = action.payload; + const annotationProcessId = annotation.name; + dispatch( + interaction.actions.processStart( + annotationProcessId, + `Creating annotation ${annotation.name}...` + ) + ); + + const annotationService = interaction.selectors.getAnnotationService(getState()); + const applicationVersion = interaction.selectors.getApplicationVersion(getState()); + if (annotationService instanceof HttpAnnotationService) { + if (applicationVersion) { + annotationService.setApplicationVersion(applicationVersion); + } + annotationService.setHttpClient(httpClient); + } + + // HTTP returns the annotation, DB does not + await new Promise((resolve, reject) => { + annotationService + .createAnnotation(annotation, annotationOptions) + .then((res) => { + // For HTTPS annotations, temporarily capture the returned + // annotation metadata so that it can be used to edit file metadata + if (res?.[0].annotationId) { + dispatch(storeNewAnnotation(res?.[0])); + } + + dispatch( + interaction.actions.processSuccess( + annotationProcessId, + `Successfully created annotation ${annotation.name}` + ) + ); + resolve(res); + }) + .catch((err) => { + dispatch( + interaction.actions.processError( + annotationProcessId, + `Failed to create annotation: ${err.message}` + ) + ); + reject(err); + }) + .finally(() => done()); + }); + }, + type: CREATE_ANNOTATION, +}); + /** * Interceptor responsible for turning REQUEST_DATA_SOURCES action into a network call for data source. Outputs * RECEIVE_DATA_SOURCES action. @@ -156,9 +217,38 @@ const requestDatasetManifest = createLogic({ type: REQUEST_DATASET_MANIFEST, }); +/** + * This is a workaround to get new annotations to temporarily show up in the store after creation + * so that they can be used in file metadata editing regardless of whether they've been fully ingested + */ +const storeNewAnnotationLogic = createLogic({ + async process(deps: ReduxLogicDeps, dispatch, done) { + const { + payload: { annotation }, + } = deps.action as StoreNewAnnotationAction; + const annotations = metadata.selectors.getAnnotations(deps.getState()); + const type = + Object.keys(AnnotationTypeIdMap).find( + (key) => AnnotationTypeIdMap[key as AnnotationType] === annotation.annotationTypeId + ) || AnnotationType.STRING; + const newMmsAnnotation = new Annotation({ + annotationName: annotation.name, + annotationDisplayName: annotation.name, + annotationId: annotation.annotationId, + description: annotation.description, + type: type as AnnotationType, + }); + dispatch(receiveAnnotations([...annotations, newMmsAnnotation])); + done(); + }, + type: STORE_NEW_ANNOTATION, +}); + export default [ + createNewAnnotationLogic, requestAnnotations, receiveAnnotationsLogic, requestDataSources, requestDatasetManifest, + storeNewAnnotationLogic, ]; diff --git a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts index 084485a5..de5de647 100644 --- a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts +++ b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts @@ -1,6 +1,7 @@ import { expect } from "chai"; import { PersistedConfigKeys } from "../../../../core/services"; +import { AnnotationType } from "../../../../core/entity/AnnotationFormatter"; import { Environment, RUN_IN_RENDERER } from "../../util/constants"; import PersistentConfigServiceElectron from "../PersistentConfigServiceElectron"; @@ -127,7 +128,7 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { annotationDisplayName: "Foo", annotationName: "foo", description: "foo-long", - type: "string", + type: AnnotationType.STRING, units: "string", }, ],