diff --git a/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx b/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx index e04194df..70b146e4 100644 --- a/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx +++ b/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx @@ -1,11 +1,13 @@ import { IComboBoxOption } from "@fluentui/react"; import classNames from "classnames"; import * as React from "react"; +import { useDispatch } from "react-redux"; import MetadataDetails, { ValueCountItem } from "./MetadataDetails"; import { PrimaryButton, SecondaryButton } from "../Buttons"; import ComboBox from "../ComboBox"; import { AnnotationType } from "../../entity/AnnotationFormatter"; +import { interaction } from "../../state"; import styles from "./EditMetadata.module.css"; @@ -21,6 +23,7 @@ interface ExistingAnnotationProps { * and then entering values for the selected files */ export default function ExistingAnnotationPathway(props: ExistingAnnotationProps) { + const dispatch = useDispatch(); const [newValues, setNewValues] = React.useState(); const [valueCount, setValueCount] = React.useState(); const [selectedAnnotation, setSelectedAnnotation] = React.useState(); @@ -68,7 +71,9 @@ export default function ExistingAnnotationPathway(props: ExistingAnnotationProps }; function onSubmit() { - // TO DO: endpoint logic is in progress on a different branch + if (selectedAnnotation && newValues?.trim()) { + dispatch(interaction.actions.editFiles({ [selectedAnnotation]: [newValues.trim()] })); + } props.onDismiss(); } @@ -94,7 +99,7 @@ export default function ExistingAnnotationPathway(props: ExistingAnnotationProps {valueCount && ( 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: "edit", + text: "Edit metadata", + title: "Edit metadata for selected files", + disabled: !filters && fileSelection.count() === 0, + iconProps: { + iconName: "Edit", + }, + onClick() { + dispatch(interaction.actions.setVisibleModal(ModalType.EditMetadata)); + }, + }, ...(isQueryingAicsFms && !isOnWeb ? [ { diff --git a/packages/core/services/DatabaseService/index.ts b/packages/core/services/DatabaseService/index.ts index c7d29697..8fc8b886 100644 --- a/packages/core/services/DatabaseService/index.ts +++ b/packages/core/services/DatabaseService/index.ts @@ -53,7 +53,7 @@ export default abstract class DatabaseService { _uri: string | File ): Promise; - protected abstract execute(_sql: string): Promise; + public abstract execute(_sql: string): Promise; private static columnTypeToAnnotationType(columnType: string): string { switch (columnType) { diff --git a/packages/core/services/FileService/DatabaseFileService/index.ts b/packages/core/services/FileService/DatabaseFileService/index.ts index 4ec842bc..b2fe55b5 100644 --- a/packages/core/services/FileService/DatabaseFileService/index.ts +++ b/packages/core/services/FileService/DatabaseFileService/index.ts @@ -1,6 +1,11 @@ import { isEmpty, isNil, uniqueId } from "lodash"; -import FileService, { GetFilesRequest, SelectionAggregationResult, Selection } from ".."; +import FileService, { + GetFilesRequest, + SelectionAggregationResult, + Selection, + AnnotationNameToValuesMap, +} from ".."; import DatabaseService from "../../DatabaseService"; import DatabaseServiceNoop from "../../DatabaseService/DatabaseServiceNoop"; import FileDownloadService, { DownloadResolution, DownloadResult } from "../../FileDownloadService"; @@ -184,4 +189,17 @@ export default class DatabaseFileService implements FileService { uniqueId() ); } + + public editFile(fileId: string, annotations: AnnotationNameToValuesMap): Promise { + const tableName = this.dataSourceNames.sort().join(", "); + const columnAssignments = Object.entries(annotations).map( + ([name, values]) => `"${name}" = '${values.join(DatabaseService.LIST_DELIMITER)}'` + ); + const sql = `\ + UPDATE '${tableName}' \ + SET ${columnAssignments.join(", ")} \ + WHERE ${DatabaseService.HIDDEN_UID_ANNOTATION} = '${fileId}'; \ + `; + return this.databaseService.execute(sql); + } } diff --git a/packages/core/services/FileService/FileServiceNoop.ts b/packages/core/services/FileService/FileServiceNoop.ts index 7063fa21..bf268427 100644 --- a/packages/core/services/FileService/FileServiceNoop.ts +++ b/packages/core/services/FileService/FileServiceNoop.ts @@ -15,11 +15,11 @@ export default class FileServiceNoop implements FileService { return Promise.resolve([]); } - public getFilesAsBuffer(): Promise { - return Promise.resolve(new Uint8Array()); - } - public download(): Promise { return Promise.resolve({ downloadRequestId: "", resolution: DownloadResolution.CANCELLED }); } + + public editFile(): Promise { + return Promise.resolve(); + } } diff --git a/packages/core/services/FileService/HttpFileService/index.ts b/packages/core/services/FileService/HttpFileService/index.ts index ba29e972..007d75f6 100644 --- a/packages/core/services/FileService/HttpFileService/index.ts +++ b/packages/core/services/FileService/HttpFileService/index.ts @@ -1,9 +1,15 @@ import { compact, join, uniqueId } from "lodash"; -import FileService, { GetFilesRequest, SelectionAggregationResult, Selection } from ".."; +import FileService, { + GetFilesRequest, + SelectionAggregationResult, + Selection, + AnnotationNameToValuesMap, +} from ".."; import FileDownloadService, { DownloadResult } from "../../FileDownloadService"; import FileDownloadServiceNoop from "../../FileDownloadService/FileDownloadServiceNoop"; import HttpServiceBase, { ConnectionConfig } from "../../HttpServiceBase"; +import Annotation from "../../../entity/Annotation"; import FileSelection from "../../../entity/FileSelection"; import FileSet from "../../../entity/FileSet"; import FileDetail, { FmsFile } from "../../../entity/FileDetail"; @@ -24,6 +30,7 @@ export default class HttpFileService extends HttpServiceBase implements FileServ private static readonly ENDPOINT_VERSION = "3.0"; public static readonly BASE_FILES_URL = `file-explorer-service/${HttpFileService.ENDPOINT_VERSION}/files`; public static readonly BASE_FILE_COUNT_URL = `${HttpFileService.BASE_FILES_URL}/count`; + public static readonly BASE_FILE_EDIT_URL = `metadata-management-service/1.0/filemetadata`; public static readonly BASE_FILE_CACHE_URL = `fss2/${HttpFileService.CACHE_ENDPOINT_VERSION}/file/cache`; public static readonly SELECTION_AGGREGATE_URL = `${HttpFileService.BASE_FILES_URL}/selection/aggregate`; private static readonly CSV_ENDPOINT_VERSION = "2.0"; @@ -132,6 +139,25 @@ export default class HttpFileService extends HttpServiceBase implements FileServ ); } + public async editFile( + fileId: string, + annotationNameToValuesMap: AnnotationNameToValuesMap, + annotationNameToAnnotationMap?: Record + ): Promise { + const requestUrl = `${this.metadataManagementServiceBaseURl}/${HttpFileService.BASE_FILE_EDIT_URL}/${fileId}`; + const annotations = Object.entries(annotationNameToValuesMap).map(([name, values]) => { + const annotationId = annotationNameToAnnotationMap?.[name].id; + if (!annotationId) { + throw new Error( + `Unable to edit file. Failed to find annotation id for annotation ${name}` + ); + } + return { annotationId, values }; + }); + const requestBody = JSON.stringify({ customMetadata: { annotations } }); + await this.put(requestUrl, requestBody); + } + /** * Cache a list of files to NAS cache (VAST) by sending their IDs to FSS. */ diff --git a/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts b/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts index adec881d..202339f7 100644 --- a/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts +++ b/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts @@ -2,7 +2,7 @@ import { createMockHttpClient } from "@aics/redux-utils"; import { expect } from "chai"; import HttpFileService from ".."; -import { FESBaseUrl, LoadBalancerBaseUrl } from "../../../../constants"; +import { FESBaseUrl, LoadBalancerBaseUrl, MMSBaseUrl } from "../../../../constants"; import FileSelection from "../../../../entity/FileSelection"; import FileSet from "../../../../entity/FileSet"; import NumericRange from "../../../../entity/NumericRange"; @@ -11,6 +11,7 @@ import FileDownloadServiceNoop from "../../../FileDownloadService/FileDownloadSe describe("HttpFileService", () => { const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; const loadBalancerBaseUrl = LoadBalancerBaseUrl.TEST; + const metadataManagementServiceBaseURl = MMSBaseUrl.TEST; const fileIds = ["abc123", "def456", "ghi789", "jkl012"]; const files = fileIds.map((file_id) => ({ file_id, @@ -46,6 +47,34 @@ describe("HttpFileService", () => { }); }); + describe("editFile", () => { + const httpClient = createMockHttpClient([ + { + when: () => true, + respondWith: {}, + }, + ]); + + it("fails if unable to find id of annotation", async () => { + // Arrange + const httpFileService = new HttpFileService({ + metadataManagementServiceBaseURl, + httpClient, + downloadService: new FileDownloadServiceNoop(), + }); + + // Act / Assert + try { + await httpFileService.editFile("file_id", { ["Color"]: ["red"] }); + expect(false, "Expected to throw").to.be.true; + } catch (e) { + expect((e as Error).message).to.equal( + "Unable to edit file. Failed to find annotation id for annotation Color" + ); + } + }); + }); + describe("getAggregateInformation", () => { const totalFileSize = 12424114; const totalFileCount = 7; diff --git a/packages/core/services/FileService/index.ts b/packages/core/services/FileService/index.ts index 9b5a5347..bde31a20 100644 --- a/packages/core/services/FileService/index.ts +++ b/packages/core/services/FileService/index.ts @@ -1,4 +1,6 @@ +import { AnnotationValue } from "../AnnotationService"; import { DownloadResult } from "../FileDownloadService"; +import Annotation from "../../entity/Annotation"; import FileDetail from "../../entity/FileDetail"; import FileSelection from "../../entity/FileSelection"; import FileSet from "../../entity/FileSet"; @@ -38,6 +40,10 @@ export interface Selection { include?: string[]; } +export interface AnnotationNameToValuesMap { + [name: string]: AnnotationValue[]; +} + export default interface FileService { fileExplorerServiceBaseUrl?: string; download( @@ -45,7 +51,12 @@ export default interface FileService { selections: Selection[], format: "csv" | "json" | "parquet" ): Promise; - getCountOfMatchingFiles(fileSet: FileSet): Promise; + editFile( + fileId: string, + annotations: AnnotationNameToValuesMap, + annotationNameToAnnotationMap?: Record + ): Promise; getAggregateInformation(fileSelection: FileSelection): Promise; + getCountOfMatchingFiles(fileSet: FileSet): Promise; getFiles(request: GetFilesRequest): Promise; } diff --git a/packages/core/services/HttpServiceBase/index.ts b/packages/core/services/HttpServiceBase/index.ts index 220ec42a..7b781c16 100644 --- a/packages/core/services/HttpServiceBase/index.ts +++ b/packages/core/services/HttpServiceBase/index.ts @@ -252,6 +252,30 @@ export default class HttpServiceBase { return new RestServiceResponse(response.data); } + public async put(url: string, body: string): Promise> { + const encodedUrl = HttpServiceBase.encodeURI(url); + const config = { headers: { "Content-Type": "application/json" } }; + + let response; + try { + // if this fails, bubble up exception + response = await retry.execute(() => this.httpClient.put(encodedUrl, body, config)); + } catch (err) { + // Specific errors about the failure from services will be in this path + if (axios.isAxiosError(err) && err?.response?.data?.message) { + throw new Error(JSON.stringify(err.response.data.message)); + } + throw err; + } + + if (response.status >= 400 || response.data === undefined) { + // by default axios will reject if does not satisfy: status >= 200 && status < 300 + throw new Error(`Request for ${encodedUrl} failed`); + } + + return new RestServiceResponse(response.data); + } + public async patch(url: string, body: string): Promise> { const encodedUrl = HttpServiceBase.encodeURI(url); const config = { headers: { "Content-Type": "application/json" } }; diff --git a/packages/core/state/interaction/actions.ts b/packages/core/state/interaction/actions.ts index 40ac7bec..35fe81b0 100644 --- a/packages/core/state/interaction/actions.ts +++ b/packages/core/state/interaction/actions.ts @@ -4,6 +4,7 @@ import { uniqueId } from "lodash"; import { ContextMenuItem, PositionReference } from "../../components/ContextMenu"; import FileFilter from "../../entity/FileFilter"; import { ModalType } from "../../components/Modal"; +import { AnnotationValue } from "../../services/AnnotationService"; import { UserSelectedApplication } from "../../services/PersistentConfigService"; import FileDetail from "../../entity/FileDetail"; import { Source } from "../../entity/FileExplorerURL"; @@ -265,6 +266,32 @@ export const initializeApp = (payload: { environment: string }) => ({ payload, }); +/** + * Edit the currently selected files with the given metadata + */ +export const EDIT_FILES = makeConstant(STATE_BRANCH_NAME, "edit-files"); + +export interface EditFilesAction { + type: string; + payload: { + annotations: { [name: string]: AnnotationValue[] }; + filters?: FileFilter[]; + }; +} + +export function editFiles( + annotations: { [name: string]: AnnotationValue[] }, + filters?: FileFilter[] +): EditFilesAction { + return { + type: EDIT_FILES, + payload: { + annotations, + filters, + }, + }; +} + /** * PROCESS AND STATUS RELATED ENUMS, INTERFACES, ETC. */ diff --git a/packages/core/state/interaction/logics.ts b/packages/core/state/interaction/logics.ts index a29987f6..c6306276 100644 --- a/packages/core/state/interaction/logics.ts +++ b/packages/core/state/interaction/logics.ts @@ -1,4 +1,4 @@ -import { isEmpty, sumBy, throttle, uniq, uniqueId } from "lodash"; +import { chunk, isEmpty, noop, sumBy, throttle, uniq, uniqueId } from "lodash"; import { AnyAction } from "redux"; import { createLogic } from "redux-logic"; @@ -33,6 +33,8 @@ import { hideVisibleModal, CopyFilesAction, COPY_FILES, + EDIT_FILES, + EditFilesAction, } from "./actions"; import * as interactionSelectors from "./selectors"; import { DownloadResolution, FileInfo } from "../../services/FileDownloadService"; @@ -501,6 +503,111 @@ const openWithLogic = createLogic({ type: OPEN_WITH, }); +/** + * Interceptor responsible for translating an EDIT_FILES action into a progress tracked + * series of edits on the files currently selected. + */ +const editFilesLogic = createLogic({ + async process(deps: ReduxLogicDeps, dispatch, done) { + const fileService = interactionSelectors.getFileService(deps.getState()); + const fileSelection = selection.selectors.getFileSelection(deps.getState()); + const sortColumn = selection.selectors.getSortColumn(deps.getState()); + const annotationNameToAnnotationMap = metadata.selectors.getAnnotationNameToAnnotationMap( + deps.getState() + ); + const { + payload: { annotations, filters }, + } = deps.action as EditFilesAction; + + // Gather up the files for the files selected currently + // if filters is present then actual "selected" files + // are the ones that match the filters, this happens when + // editing a whole folder for example + let filesSelected; + if (filters) { + const fileSet = new FileSet({ + filters, + fileService, + sort: sortColumn, + }); + const totalFileCount = await fileSet.fetchTotalCount(); + filesSelected = await fileSet.fetchFileRange(0, totalFileCount); + } else { + filesSelected = await fileSelection.fetchAllDetails(); + } + + // Break files into batches of 10 File IDs + const fileIds = filesSelected.map((file) => file.uid); + const batches = chunk(fileIds, 10); + + // Dispatch an event to alert the user of the start of the process + const editRequestId = uniqueId(); + const editProcessMsg = "Editing files in progress."; + dispatch(processStart(editRequestId, editProcessMsg, noop)); + + // Track the total number of files edited + let totalFileEdited = 0; + + // Throttled progress dispatcher + const onProgress = throttle(() => { + dispatch( + processProgress( + editRequestId, + totalFileEdited / fileIds.length, + editProcessMsg, + noop + ) + ); + }, 1000); + + try { + // Begin editing files in batches + for (const batch of batches) { + // Asynchronously begin the edit for each file in the batch + const promises = batch.map( + (fileId) => + new Promise(async (resolve, reject) => { + fileService + .editFile(fileId, annotations, annotationNameToAnnotationMap) + .then((_) => { + totalFileEdited += 1; + onProgress(); + resolve(); + }) + .catch((err) => reject(err)); + // try { + // await fileService.editFile( + // fileId, + // annotations, + // annotationNameToAnnotationMap + // ); + // totalFileEdited += 1; + // onProgress(); + // resolve(); + // } catch (err) { + // reject(err); + // } + }) + ); + + // Await the results of this batch + await Promise.all(promises); + } + dispatch(refresh); // Sync state to pull updated files + dispatch(processSuccess(editRequestId, "Successfully edited files.")); + } catch (err) { + // Dispatch an event to alert the user of the failure + const errorMsg = `Failed to finish editing files, some may have been edited. Details:
${ + err instanceof Error ? err.message : err + }`; + dispatch(processError(editRequestId, errorMsg)); + } finally { + done(); + } + }, + type: EDIT_FILES, +}); + /** * Interceptor responsible for responding to a SHOW_CONTEXT_MENU action and ensuring the previous * context menu is dismissed gracefully. @@ -667,6 +774,7 @@ const copyFilesLogic = createLogic({ export default [ initializeApp, downloadManifest, + editFilesLogic, cancelFileDownloadLogic, promptForNewExecutable, openWithDefault, diff --git a/packages/core/state/interaction/test/logics.test.ts b/packages/core/state/interaction/test/logics.test.ts index f9dc58c1..29f26e52 100644 --- a/packages/core/state/interaction/test/logics.test.ts +++ b/packages/core/state/interaction/test/logics.test.ts @@ -1,9 +1,14 @@ -import { configureMockStore, mergeState, createMockHttpClient } from "@aics/redux-utils"; +import { + configureMockStore, + mergeState, + createMockHttpClient, + ResponseStub, +} from "@aics/redux-utils"; import { expect } from "chai"; -import { get as _get } from "lodash"; +import { get as _get, noop } from "lodash"; import { createSandbox } from "sinon"; -import { initialState, interaction } from "../.."; +import { initialState, interaction, reduxLogics } from "../.."; import { downloadManifest, ProcessStatus, @@ -17,6 +22,7 @@ import { promptForNewExecutable, openWithDefault, downloadFiles, + editFiles, } from "../actions"; import { ExecutableEnvCancellationToken, @@ -24,7 +30,7 @@ import { } from "../../../services/ExecutionEnvService"; import ExecutionEnvServiceNoop from "../../../services/ExecutionEnvService/ExecutionEnvServiceNoop"; import interactionLogics from "../logics"; -import { FESBaseUrl } from "../../../constants"; +import { FESBaseUrl, MMSBaseUrl } from "../../../constants"; import Annotation from "../../../entity/Annotation"; import AnnotationName from "../../../entity/Annotation/AnnotationName"; import { AnnotationType } from "../../../entity/AnnotationFormatter"; @@ -650,6 +656,254 @@ describe("Interaction logics", () => { }); }); + describe("editFilesLogic", () => { + const sandbox = createSandbox(); + const files = []; + const fileKinds = ["PNG", "TIFF"]; + const metadataManagementServiceBaseURl = MMSBaseUrl.TEST; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; + for (let i = 0; i <= 100; i++) { + files.push({ + file_path: `/allen/file_${i}.ext`, + file_id: `file_${i}`, + annotations: [ + { + name: AnnotationName.KIND, + values: fileKinds, + }, + { + name: "Cell Line", + values: ["AICS-10", "AICS-12"], + }, + ], + }); + } + const mockAnnotations = [ + new Annotation({ + annotationDisplayName: AnnotationName.KIND, + annotationName: AnnotationName.KIND, + description: "", + type: "Text", + annotationId: 0, + }), + new Annotation({ + annotationDisplayName: "Cell Line", + annotationName: "Cell Line", + description: "", + type: "Text", + annotationId: 1, + }), + ]; + + const responseStubs: ResponseStub[] = [ + { + when: (config) => + _get(config, "url", "").includes(HttpFileService.BASE_FILE_COUNT_URL), + respondWith: { + data: { data: [files.length] }, + }, + }, + { + when: (config) => _get(config, "url", "").includes(HttpFileService.BASE_FILES_URL), + respondWith: { + data: { data: files }, + }, + }, + ]; + const mockHttpClient = createMockHttpClient(responseStubs); + class TestDownloadService extends FileDownloadService { + isFileSystemAccessible = false; + getDefaultDownloadDirectory() { + return Promise.reject(); + } + prepareHttpResourceForDownload() { + return Promise.reject(); + } + download(_fileInfo: FileInfo) { + return Promise.reject(); + } + cancelActiveRequest() { + noop(); + } + } + + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl, + httpClient: mockHttpClient, + }); + const downloadService = new TestDownloadService({ + fileExplorerServiceBaseUrl, + metadataManagementServiceBaseURl, + }); + const fileService = new HttpFileService({ + metadataManagementServiceBaseURl, + fileExplorerServiceBaseUrl, + httpClient: mockHttpClient, + downloadService, + }); + const fakeSelection = new FileSelection().select({ + fileSet: new FileSet({ fileService }), + index: new NumericRange(0, 100), + sortOrder: 0, + }); + + before(() => { + sandbox.stub(interaction.selectors, "getFileService").returns(fileService); + sandbox.stub(interaction.selectors, "getAnnotationService").returns(annotationService); + }); + afterEach(() => { + sandbox.resetHistory(); + }); + after(() => { + sandbox.restore(); + }); + + it("edits 'folder' when filter specified'", async () => { + // Arrange + const state = mergeState(initialState, { + interaction: { + platformDependentServices: { + fileDownloadService: downloadService, + }, + }, + metadata: { + annotations: mockAnnotations, + }, + }); + const { store, logicMiddleware, actions } = configureMockStore({ + state, + logics: reduxLogics, + }); + + // Act + store.dispatch( + editFiles({ "Cell Line": ["AICS-12"] }, [ + new FileFilter(AnnotationName.KIND, "PNG"), + ]) + ); + await logicMiddleware.whenComplete(); + + // Assert + expect( + actions.includesMatch({ + type: SET_STATUS, + payload: { + data: { + status: ProcessStatus.SUCCEEDED, + }, + }, + }) + ).to.be.true; + + // sanity-check: make certain this isn't evergreen + expect( + actions.includesMatch({ + type: SET_STATUS, + payload: { + data: { + status: ProcessStatus.ERROR, + }, + }, + }) + ).to.be.false; + }); + + it("edits selected files when no filter specified", async () => { + // Arrange + const state = mergeState(initialState, { + selection: { + fileSelection: fakeSelection, + }, + metadata: { + annotations: mockAnnotations, + }, + interaction: { + platformDependentServices: { + fileDownloadService: downloadService, + }, + }, + }); + const { store, logicMiddleware, actions } = configureMockStore({ + state, + logics: reduxLogics, + }); + + // Act + store.dispatch(editFiles({ "Cell Line": ["AICS-12"] })); + await logicMiddleware.whenComplete(); + // Assert + expect( + actions.includesMatch({ + type: SET_STATUS, + payload: { + data: { + status: ProcessStatus.SUCCEEDED, + }, + }, + }) + ).to.be.true; + + // sanity-check: make certain this isn't evergreen + expect( + actions.includesMatch({ + type: SET_STATUS, + payload: { + data: { + status: ProcessStatus.ERROR, + }, + }, + }) + ).to.be.false; + }); + + it("alerts user to failure editing", async () => { + // Arrange + const state = mergeState(initialState, { + selection: { + fileSelection: fakeSelection, + }, + interaction: { + platformDependentServices: { + fileDownloadService: downloadService, + }, + }, + }); + const { store, logicMiddleware, actions } = configureMockStore({ + state, + logics: interactionLogics, + }); + + // Act + // Try to edit an annotation we don't recognize + store.dispatch(editFiles({ "Nonexistent Annotation": ["AICS-12"] })); + await logicMiddleware.whenComplete(); + + // Assert + expect( + actions.includesMatch({ + type: SET_STATUS, + payload: { + data: { + status: ProcessStatus.ERROR, + }, + }, + }) + ).to.be.true; + + // sanity-check: make certain this isn't evergreen + expect( + actions.includesMatch({ + type: SET_STATUS, + payload: { + data: { + status: ProcessStatus.SUCCEEDED, + }, + }, + }) + ).to.be.false; + }); + }); + describe("cancelFileDownloadLogic", () => { it("marks the failure of a download cancellation (on error)", async () => { // arrange diff --git a/packages/desktop/src/services/DatabaseServiceElectron.ts b/packages/desktop/src/services/DatabaseServiceElectron.ts index 87efd70e..2b0d8424 100644 --- a/packages/desktop/src/services/DatabaseServiceElectron.ts +++ b/packages/desktop/src/services/DatabaseServiceElectron.ts @@ -135,7 +135,7 @@ export default class DatabaseServiceElectron extends DatabaseService { } } - protected async execute(sql: string): Promise { + public async execute(sql: string): Promise { return new Promise((resolve, reject) => { try { this.database.exec(sql, (err: any) => { diff --git a/packages/web/src/services/DatabaseServiceWeb.ts b/packages/web/src/services/DatabaseServiceWeb.ts index 4e437be3..f453f529 100644 --- a/packages/web/src/services/DatabaseServiceWeb.ts +++ b/packages/web/src/services/DatabaseServiceWeb.ts @@ -111,7 +111,7 @@ export default class DatabaseServiceWeb extends DatabaseService { } } - protected async execute(sql: string): Promise { + public async execute(sql: string): Promise { if (!this.database) { throw new Error("Database failed to initialize"); }