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",
},
],