diff --git a/packages/core/App.module.css b/packages/core/App.module.css index bb447201d..b9de68643 100644 --- a/packages/core/App.module.css +++ b/packages/core/App.module.css @@ -64,6 +64,7 @@ } .center { + flex: auto; height: 100%; margin: var(--margin) 0 var(--margin) var(--margin); width: calc(70% - var(--margin)); diff --git a/packages/core/App.tsx b/packages/core/App.tsx index cf09a7246..1571bc33d 100644 --- a/packages/core/App.tsx +++ b/packages/core/App.tsx @@ -8,6 +8,7 @@ import ContextMenu from "./components/ContextMenu"; import Modal from "./components/Modal"; import DirectoryTree from "./components/DirectoryTree"; import FileDetails from "./components/FileDetails"; +import GlobalActionButtonRow from "./components/GlobalActionButtonRow"; import StatusMessage from "./components/StatusMessage"; import TutorialTooltip from "./components/TutorialTooltip"; import QuerySidebar from "./components/QuerySidebar"; @@ -17,7 +18,6 @@ import { PlatformDependentServices } from "./state/interaction/actions"; import "./styles/global.css"; import styles from "./App.module.css"; -import GlobalActionButtonRow from "./components/GlobalActionButtonRow"; // Used for mousemove listeners when resizing elements via click and drag (eg. File Details pane) export const ROOT_ELEMENT_ID = "root"; diff --git a/packages/core/components/AnnotationFilterForm/AnnotationFilterForm.module.css b/packages/core/components/AnnotationFilterForm/AnnotationFilterForm.module.css new file mode 100644 index 000000000..1fcb6cfc4 --- /dev/null +++ b/packages/core/components/AnnotationFilterForm/AnnotationFilterForm.module.css @@ -0,0 +1,10 @@ +.loading-container > div > div { + border-color: var(--secondary-text-color) var(--secondary-background-color) var(--secondary-background-color); +} + +.loading-container, .picker { + background-color: var(--secondary-background-color); + color: var(--secondary-text-color); + padding: var(--margin); + width: 35vw; +} diff --git a/packages/core/components/AnnotationFilterForm/index.tsx b/packages/core/components/AnnotationFilterForm/index.tsx index 2c2405bf8..a354527c1 100644 --- a/packages/core/components/AnnotationFilterForm/index.tsx +++ b/packages/core/components/AnnotationFilterForm/index.tsx @@ -1,19 +1,22 @@ import { Spinner, SpinnerSize } from "@fluentui/react"; -import { find, isNil } from "lodash"; +import { isNil } from "lodash"; import * as React from "react"; import { useDispatch, useSelector } from "react-redux"; import useAnnotationValues from "./useAnnotationValues"; +import Annotation, { AnnotationName } from "../../entity/Annotation"; import { AnnotationType } from "../../entity/AnnotationFormatter"; import FileFilter from "../../entity/FileFilter"; import ListPicker, { ListItem } from "../ListPicker"; import NumberRangePicker from "../NumberRangePicker"; import SearchBoxForm from "../SearchBoxForm"; import DateRangePicker from "../DateRangePicker"; -import { interaction, metadata, selection } from "../../state"; +import { interaction, selection } from "../../state"; + +import styles from "./AnnotationFilterForm.module.css"; interface AnnotationFilterFormProps { - name: string; + annotation: Annotation; } /** @@ -24,62 +27,48 @@ interface AnnotationFilterFormProps { */ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { const dispatch = useDispatch(); - const annotations = useSelector(metadata.selectors.getSortedAnnotations); - const fileFilters = useSelector(selection.selectors.getFileFilters); + const allFilters = useSelector(selection.selectors.getFileFilters); const annotationService = useSelector(interaction.selectors.getAnnotationService); - // TODO: annotationService throws an error for annotations that aren't in the API const [annotationValues, isLoading, errorMessage] = useAnnotationValues( - props.name, + props.annotation.name, annotationService ); - const annotation = React.useMemo( - () => find(annotations, (annotation) => annotation.name === props.name), - [annotations, props.name] - ); - - const currentValues = React.useMemo( - () => find(fileFilters, (annotation) => annotation.name === props.name), - [props.name, fileFilters] + const filtersForAnnotation = React.useMemo( + () => allFilters.filter((filter) => filter.name === props.annotation.name), + [allFilters, props.annotation] ); const items = React.useMemo(() => { - const appliedFilters = fileFilters - .filter((filter) => filter.name === annotation?.name) - .map((filter) => filter.value); + const appliedFilters = new Set(filtersForAnnotation.map((filter) => filter.value)); return (annotationValues || []).map((value) => ({ - selected: appliedFilters.includes(value), - displayValue: annotation?.getDisplayValue(value) || value, + selected: appliedFilters.has(value), + displayValue: props.annotation.getDisplayValue(value) || value, value, })); - }, [annotation, annotationValues, fileFilters]); + }, [props.annotation, annotationValues, filtersForAnnotation]); const onDeselectAll = () => { - const filters = items.map( - (item) => - new FileFilter( - props.name, - isNil(annotation?.valueOf(item.value)) - ? item.value - : annotation?.valueOf(item.value) - ) - ); - dispatch(selection.actions.removeFileFilter(filters)); + dispatch(selection.actions.removeFileFilter(filtersForAnnotation)); }; const onDeselect = (item: ListItem) => { const fileFilter = new FileFilter( - props.name, - isNil(annotation?.valueOf(item.value)) ? item.value : annotation?.valueOf(item.value) + props.annotation.name, + isNil(props.annotation.valueOf(item.value)) + ? item.value + : props.annotation.valueOf(item.value) ); dispatch(selection.actions.removeFileFilter(fileFilter)); }; const onSelect = (item: ListItem) => { const fileFilter = new FileFilter( - props.name, - isNil(annotation?.valueOf(item.value)) ? item.value : annotation?.valueOf(item.value) + props.annotation.name, + isNil(props.annotation.valueOf(item.value)) + ? item.value + : props.annotation.valueOf(item.value) ); dispatch(selection.actions.addFileFilter(fileFilter)); }; @@ -88,10 +77,10 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { const filters = items.map( (item) => new FileFilter( - props.name, - isNil(annotation?.valueOf(item.value)) + props.annotation.name, + isNil(props.annotation.valueOf(item.value)) ? item.value - : annotation?.valueOf(item.value) + : props.annotation.valueOf(item.value) ) ); dispatch(selection.actions.addFileFilter(filters)); @@ -99,25 +88,30 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { function onSearch(filterValue: string) { if (filterValue && filterValue.trim()) { - const fileFilter = new FileFilter(props.name, filterValue); - if (currentValues) { - dispatch(selection.actions.removeFileFilter(currentValues)); - } - dispatch(selection.actions.addFileFilter(fileFilter)); + dispatch( + selection.actions.setFileFilters([ + ...allFilters.filter((filter) => filter.name !== props.annotation.name), + new FileFilter(props.annotation.name, filterValue), + ]) + ); } } - function onReset() { - if (currentValues) { - dispatch(selection.actions.removeFileFilter(currentValues)); - } + if (isLoading) { + return ( +
+ +
+ ); } - const listPicker = () => { + // Use the checkboxes if values exist and are few enough to reasonably scroll through + if (items.length > 0 && items.length <= 100) { return ( ); - }; - - if (isLoading) { - return ( -
- -
- ); } - const customInput = () => { - switch (annotation?.type) { - case AnnotationType.DATE: - case AnnotationType.DATETIME: - return ( - - ); - case AnnotationType.NUMBER: + switch (props.annotation.type) { + case AnnotationType.DATE: + case AnnotationType.DATETIME: + return ( + + ); + case AnnotationType.NUMBER: + // File size is a special case where we don't have + // the ability to filter by range in the backend yet + // so we'll just let that case fall through to the string below + if (props.annotation.name !== AnnotationName.FILE_SIZE) { return ( ); - case AnnotationType.DURATION: - case AnnotationType.STRING: - // prettier-ignore - default: // FALL-THROUGH - return ( - <> {listPicker()} - ); - } - }; - // Use the checkboxes if values exist and are few enough to reasonably scroll through - if (items.length > 0 && items.length <= 100) { - return <> {listPicker()} ; - } - // Use a search box if the API does not return values to select - // (e.g., it's not an AICS annotation) - else if (items.length === 0 && annotation?.type === AnnotationType.STRING) { - return ( - - ); + } + case AnnotationType.STRING: + return ( + + ); + case AnnotationType.DURATION: + // prettier-ignore + default: // FALL-THROUGH + return ( + + ); } - return <> {customInput()} ; } diff --git a/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx b/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx index edf2aaac9..a34b0272d 100644 --- a/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx +++ b/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx @@ -8,7 +8,7 @@ import { createSandbox } from "sinon"; import AnnotationFilterForm from ".."; import Annotation from "../../../entity/Annotation"; import FileFilter from "../../../entity/FileFilter"; -import { initialState, reducer, reduxLogics, interaction } from "../../../state"; +import { initialState, reducer, reduxLogics, interaction, selection } from "../../../state"; import HttpAnnotationService from "../../../services/AnnotationService/HttpAnnotationService"; describe("", () => { @@ -20,7 +20,6 @@ describe("", () => { description: "", type: "Text", }); - const annotations = [fooAnnotation]; const sandbox = createSandbox(); @@ -43,18 +42,15 @@ describe("", () => { }); sandbox.stub(interaction.selectors, "getAnnotationService").returns(annotationService); - const state = mergeState(initialState, { - metadata: { - annotations, - }, + const { store } = configureMockStore({ + state: initialState, + responseStubs: responseStub, }); - const { store } = configureMockStore({ state, responseStubs: responseStub }); - // act const { findAllByRole } = render( - + ); @@ -85,9 +81,6 @@ describe("", () => { // start with the input selected const state = mergeState(initialState, { - metadata: { - annotations, - }, selection: { filters: [new FileFilter(fooAnnotation.name, "b")], }, @@ -100,31 +93,29 @@ describe("", () => { }); // act - const { getByLabelText } = render( + const { getByText } = render( - + ); + await waitFor(() => expect(getByText("b")).to.not.be.undefined); - // wait a couple render cycles for the async react hook to retrieve the annotation values - await waitFor(() => - // assert that the input is selected - expect(getByLabelText("b").getAttribute("aria-checked")).to.equal("true") - ); + // (sanity-check): Check that the "b" input is selected + expect(selection.selectors.getFileFilters(store.getState())).to.be.lengthOf(1); - // deselect the input - fireEvent.click(getByLabelText("b")); + // Act: Deselect the "False" input + fireEvent.click(getByText("b")); await logicMiddleware.whenComplete(); - // assert that the input is deselected - expect(getByLabelText("b").getAttribute("aria-checked")).to.equal("false"); + // Assert: Check that the "b" input is deselected + expect(selection.selectors.getFileFilters(store.getState())).to.be.lengthOf(0); - // select the input again - fireEvent.click(getByLabelText("b")); + // Act: Reselect the "b" input + fireEvent.click(getByText("b")); await logicMiddleware.whenComplete(); - // assert that the input is selected again - expect(getByLabelText("b").getAttribute("aria-checked")).to.equal("true"); + // Assert: Check that the "False" input is selected again + expect(selection.selectors.getFileFilters(store.getState())).to.be.lengthOf(1); }); it("naturally sorts values", async () => { @@ -142,17 +133,14 @@ describe("", () => { }); sandbox.stub(interaction.selectors, "getAnnotationService").returns(annotationService); - const state = mergeState(initialState, { - metadata: { - annotations, - }, + const { store } = configureMockStore({ + state: initialState, + responseStubs: responseStub, }); - const { store } = configureMockStore({ state, responseStubs: responseStub }); - const { findAllByRole } = render( - + ); @@ -162,10 +150,10 @@ describe("", () => { expect(annotationValueListItems.length).to.equal(4); const expectedOrder = ["AICS-0", "aICs-2", "AICS-24", "aics-32"]; annotationValueListItems.forEach((listItem, index) => { - const { getByLabelText } = within(listItem); + const { getByText } = within(listItem); // getByLabelText will throw if it can't find a matching node - expect(getByLabelText(expectedOrder[index])).to.not.be.undefined; + expect(getByText(expectedOrder[index])).to.not.be.undefined; }); }); }); @@ -178,7 +166,6 @@ describe("", () => { description: "", type: "YesNo", }); - const annotations = [fooAnnotation]; const responseStub = { when: `test/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, @@ -208,16 +195,14 @@ describe("", () => { it("shows all values as unchecked at first", async () => { // Arrange - const state = mergeState(initialState, { - metadata: { - annotations, - }, + const { store } = configureMockStore({ + state: initialState, + responseStubs: responseStub, }); - const { store } = configureMockStore({ state, responseStubs: responseStub }); // Act const { findAllByRole } = render( - + ); @@ -234,9 +219,6 @@ describe("", () => { it("deselects and selects a value", async () => { // Arrange: Start with the "False" input selected const state = mergeState(initialState, { - metadata: { - annotations, - }, selection: { filters: [new FileFilter(fooAnnotation.name, false)], }, @@ -248,30 +230,29 @@ describe("", () => { responseStubs: responseStub, }); // Act - const { getByLabelText } = render( + const { getByText } = render( - + ); - // Wait a couple render cycles for the async react hook to retrieve the annotation values - await waitFor(() => - // Assert: Check that the "False" input is selected - expect(getByLabelText("False").getAttribute("aria-checked")).to.equal("true") - ); + await waitFor(() => expect(getByText("False")).to.not.be.undefined); + + // (sanity-check): Check that the "False" input is selected + expect(selection.selectors.getFileFilters(store.getState())).to.be.lengthOf(1); // Act: Deselect the "False" input - fireEvent.click(getByLabelText("False")); + fireEvent.click(getByText("False")); await logicMiddleware.whenComplete(); // Assert: Check that the "False" input is deselected - expect(getByLabelText("False").getAttribute("aria-checked")).to.equal("false"); + expect(selection.selectors.getFileFilters(store.getState())).to.be.lengthOf(0); // Act: Reselect the "False" input - fireEvent.click(getByLabelText("False")); + fireEvent.click(getByText("False")); await logicMiddleware.whenComplete(); // Assert: Check that the "False" input is selected again - expect(getByLabelText("False").getAttribute("aria-checked")).to.equal("true"); + expect(selection.selectors.getFileFilters(store.getState())).to.be.lengthOf(1); }); }); @@ -282,7 +263,6 @@ describe("", () => { description: "", type: "Number", }); - const annotations = [fooAnnotation]; const sandbox = createSandbox(); @@ -305,17 +285,14 @@ describe("", () => { }); sandbox.stub(interaction.selectors, "getAnnotationService").returns(annotationService); - const state = mergeState(initialState, { - metadata: { - annotations, - }, + const { store } = configureMockStore({ + state: initialState, + responseStubs: responseStub, }); - const { store } = configureMockStore({ state, responseStubs: responseStub }); - const { findAllByRole } = render( - + ); @@ -325,10 +302,10 @@ describe("", () => { expect(annotationValueListItems.length).to.equal(6); const expectedOrder = [-12, 0, 5, 6.3, 8, 10000000000]; annotationValueListItems.forEach((listItem, index) => { - const { getByLabelText } = within(listItem); + const { getByText } = within(listItem); // getByLabelText will throw if it can't find a matching node - expect(getByLabelText(String(expectedOrder[index]))).to.not.be.undefined; + expect(getByText(String(expectedOrder[index]))).to.not.be.undefined; }); }); }); @@ -340,7 +317,6 @@ describe("", () => { description: "", type: "Duration", }); - const annotations = [fooAnnotation]; const sandbox = createSandbox(); @@ -363,17 +339,14 @@ describe("", () => { }); sandbox.stub(interaction.selectors, "getAnnotationService").returns(annotationService); - const state = mergeState(initialState, { - metadata: { - annotations, - }, + const { store } = configureMockStore({ + state: initialState, + responseStubs: responseStub, }); - const { store } = configureMockStore({ state, responseStubs: responseStub }); - const { findAllByRole } = render( - + ); diff --git a/packages/core/components/AnnotationFilterForm/useAnnotationValues.ts b/packages/core/components/AnnotationFilterForm/useAnnotationValues.ts index ec47ecd00..c9646cb8d 100644 --- a/packages/core/components/AnnotationFilterForm/useAnnotationValues.ts +++ b/packages/core/components/AnnotationFilterForm/useAnnotationValues.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { naturalComparator } from "../../util/strings"; import AnnotationService, { AnnotationValue } from "../../services/AnnotationService"; +import { TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../constants"; /** * Custom React hook to accomplish requesting the unique values for annotations. This hook will request values for the given @@ -10,7 +11,7 @@ import AnnotationService, { AnnotationValue } from "../../services/AnnotationSer * If there was an error the third element will contain the message from the error. */ export default function useAnnotationValues( - annotation: string, + annotationName: string, annotationService: AnnotationService ): [AnnotationValue[] | undefined, boolean, string | undefined] { const [annotationValues, setAnnotationValues] = React.useState(); @@ -23,33 +24,36 @@ export default function useAnnotationValues( // annotation to filter on quickly let ignoreResponse = false; - setIsLoading(true); - annotationService - .fetchValues(annotation) - .then((response: AnnotationValue[]) => { - if (!ignoreResponse) { - if (response && response.length > 1) { - setAnnotationValues([...response].sort(naturalComparator)); - return; + // Only fetch values for annotations that are not top level file annotations + if (!TOP_LEVEL_FILE_ANNOTATION_NAMES.includes(annotationName)) { + setIsLoading(true); + annotationService + .fetchValues(annotationName) + .then((response: AnnotationValue[]) => { + if (!ignoreResponse) { + if (response && response.length > 1) { + setAnnotationValues([...response].sort(naturalComparator)); + return; + } + setAnnotationValues(response); } - setAnnotationValues(response); - } - }) - .catch((error) => { - if (!ignoreResponse) { - setErrorMessage(error.message); - } - }) - .finally(() => { - if (!ignoreResponse) { - setIsLoading(false); - } - }); + }) + .catch((error) => { + if (!ignoreResponse) { + setErrorMessage(error.message); + } + }) + .finally(() => { + if (!ignoreResponse) { + setIsLoading(false); + } + }); + } return function cleanup() { ignoreResponse = true; }; - }, [annotation, annotationService]); + }, [annotationName, annotationService]); React.useDebugValue(annotationValues); // display annotationValues in React DevTools when this hook is inspected return [annotationValues, isLoading, errorMessage]; diff --git a/packages/core/components/AnnotationPicker/index.tsx b/packages/core/components/AnnotationPicker/index.tsx index 520838c60..a8c6b70a5 100644 --- a/packages/core/components/AnnotationPicker/index.tsx +++ b/packages/core/components/AnnotationPicker/index.tsx @@ -2,10 +2,12 @@ import * as React from "react"; import { useSelector } from "react-redux"; import ListPicker, { ListItem } from "../ListPicker"; +import { TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../constants"; import Annotation from "../../entity/Annotation"; import { metadata, selection } from "../../state"; interface Props { + disabledTopLevelAnnotations?: boolean; hasSelectAllCapability?: boolean; disableUnavailableAnnotations?: boolean; className?: string; @@ -29,15 +31,21 @@ export default function AnnotationPicker(props: Props) { selection.selectors.getAvailableAnnotationsForHierarchyLoading ); - const items = annotations.map((annotation) => ({ - selected: props.selections.includes(annotation), - disabled: unavailableAnnotations.includes(annotation), - loading: areAvailableAnnotationLoading, - description: annotation.description, - data: annotation, - value: annotation.name, - displayValue: annotation.displayName, - })); + const items = annotations + .filter( + (annotation) => + !props.disabledTopLevelAnnotations || + !TOP_LEVEL_FILE_ANNOTATION_NAMES.includes(annotation.name) + ) + .map((annotation) => ({ + selected: props.selections.includes(annotation), + disabled: unavailableAnnotations.includes(annotation), + loading: areAvailableAnnotationLoading, + description: annotation.description, + data: annotation, + value: annotation.name, + displayValue: annotation.displayName, + })); const removeSelection = (item: ListItem) => { props.setSelections(props.selections.filter((annotation) => annotation !== item.data)); diff --git a/packages/core/components/DateRangePicker/DateRangePicker.module.css b/packages/core/components/DateRangePicker/DateRangePicker.module.css index 5462fa4fe..54c889e45 100644 --- a/packages/core/components/DateRangePicker/DateRangePicker.module.css +++ b/packages/core/components/DateRangePicker/DateRangePicker.module.css @@ -1,23 +1,32 @@ -.filter-input, .date-range-box { - border: none; - flex: auto; +.clear-button, .clear-button i { + background-color: var(--primary-background-color); + color: var(--primary-text-color); } -.date-range-box { - display: flex; +.clear-button:hover, .clear-button:hover i { + background-color: var(--highlight-background-color); + color: var(--highlight-text-color); } -.date-range-box { - max-height: 32px; - overflow: hidden; +.date-range-container { + display: flex; + justify-content: space-between; } .date-range-separator { + align-items: center; display: flex; font-size: small; - padding: 0 2px; + margin: 0 2px; +} + +.filter-input div { + background-color: var(--primary-background-color); + border: none; + color: var(--primary-text-color); } -.date-range-separator i { - margin: auto; +.title { + margin: 0; + padding-bottom: var(--margin); } diff --git a/packages/core/components/DateRangePicker/index.tsx b/packages/core/components/DateRangePicker/index.tsx index e766a551e..a34557ad1 100644 --- a/packages/core/components/DateRangePicker/index.tsx +++ b/packages/core/components/DateRangePicker/index.tsx @@ -6,6 +6,8 @@ import { extractDatesFromRangeOperatorFilterString } from "../../entity/Annotati import styles from "./DateRangePicker.module.css"; interface DateRangePickerProps { + className?: string; + title?: string; onSearch: (filterValue: string) => void; onReset: () => void; currentRange: FileFilter | undefined; @@ -62,32 +64,36 @@ export default function DateRangePicker(props: DateRangePickerProps) { endDate.setDate(endDate.getDate() - 1); } return ( - - (v ? onDateRangeSelection(v, null) : onReset())} - value={extractDateFromDateString(startDate?.toISOString())} - /> -
- +
+

{props.title}

+
+ (v ? onDateRangeSelection(v, null) : onReset())} + value={extractDateFromDateString(startDate?.toISOString())} + /> +
+ +
+ (v ? onDateRangeSelection(null, v) : onReset())} + value={extractDateFromDateString(endDate?.toISOString())} + /> +
- (v ? onDateRangeSelection(null, v) : onReset())} - value={extractDateFromDateString(endDate?.toISOString())} - /> - - +
); } diff --git a/packages/core/components/DateRangePicker/test/DateRangePicker.test.tsx b/packages/core/components/DateRangePicker/test/DateRangePicker.test.tsx index 67bf08d74..cd65081c4 100644 --- a/packages/core/components/DateRangePicker/test/DateRangePicker.test.tsx +++ b/packages/core/components/DateRangePicker/test/DateRangePicker.test.tsx @@ -51,7 +51,7 @@ describe("", () => { expect(getByText("Wed Mar 20 2024")).to.exist; }); - it("renders a 'Clear' button if given a callback", () => { + it("renders a 'Reset' button if given a callback", () => { // Arrange const onSearch = noop; const onReset = sinon.spy(); @@ -63,7 +63,7 @@ describe("", () => { // Hit reset expect(onReset.called).to.equal(false); - fireEvent.click(getByTitle("Clear")); + fireEvent.click(getByTitle("Reset")); expect(onReset.called).to.equal(true); }); }); diff --git a/packages/core/components/DirectoryTree/selectors.ts b/packages/core/components/DirectoryTree/selectors.ts deleted file mode 100644 index b1f36167d..000000000 --- a/packages/core/components/DirectoryTree/selectors.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createSelector } from "reselect"; - -import { selection } from "../../state"; - -export const getHierarchy = createSelector( - [selection.selectors.getAnnotationHierarchy], - (annotationHierarchy) => { - return annotationHierarchy.map((annotation) => annotation.name); - } -); diff --git a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx index fd505619a..809f0b760 100644 --- a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx +++ b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx @@ -50,7 +50,6 @@ describe("", () => { description: "", type: "Text", }); - const annotations = [fooAnnotation, barAnnotation]; const baseUrl = "http://test-aics.corp.alleninstitute.org"; const baseDisplayAnnotations = TOP_LEVEL_FILE_ANNOTATIONS.filter( @@ -61,7 +60,7 @@ describe("", () => { fileExplorerServiceBaseUrl: baseUrl, }, selection: { - annotationHierarchy: annotations, + annotationHierarchy: [fooAnnotation.name, barAnnotation.name], displayAnnotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation], }, }); @@ -352,7 +351,7 @@ describe("", () => { fileExplorerServiceBaseUrl: baseUrl, }, selection: { - annotationHierarchy: [fooAnnotation], + annotationHierarchy: [fooAnnotation.name], displayAnnotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation], }, }); diff --git a/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx b/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx index 17a153ed6..bc9aab0b5 100644 --- a/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx +++ b/packages/core/components/DirectoryTree/useDirectoryHierarchy.tsx @@ -15,7 +15,6 @@ import FileList from "../FileList"; import FileFilter from "../../entity/FileFilter"; import FileSet from "../../entity/FileSet"; import { ValueError } from "../../errors"; -import * as directoryTreeSelectors from "./selectors"; import { interaction, metadata, selection } from "../../state"; import { naturalComparator } from "../../util/strings"; @@ -102,7 +101,7 @@ const useDirectoryHierarchy = ( DEFAULTS ); const annotations = useSelector(metadata.selectors.getAnnotations); - const hierarchy = useSelector(directoryTreeSelectors.getHierarchy); + const hierarchy = useSelector(selection.selectors.getAnnotationHierarchy); const annotationService = useSelector(interaction.selectors.getAnnotationService); const fileService = useSelector(interaction.selectors.getFileService); const selectedFileFilters = useSelector(selection.selectors.getFileFilters); diff --git a/packages/core/components/EmptyFileListMessage/index.tsx b/packages/core/components/EmptyFileListMessage/index.tsx index b35c1f4cc..9d08d805c 100644 --- a/packages/core/components/EmptyFileListMessage/index.tsx +++ b/packages/core/components/EmptyFileListMessage/index.tsx @@ -4,11 +4,12 @@ import * as React from "react"; import { useSelector } from "react-redux"; import FilterList from "./FilterList"; -import { selection } from "../../state"; +import { metadata, selection } from "../../state"; import styles from "./EmptyFileListMessage.module.css"; export default function EmptyFileListMessage() { + const annotations = useSelector(metadata.selectors.getAnnotations); const annotationHierarchy = useSelector(selection.selectors.getAnnotationHierarchy); const groupedByFilterName = useSelector(selection.selectors.getGroupedByFilterName); @@ -41,16 +42,24 @@ export default function EmptyFileListMessage() { {" "} with annotation {annotationHierarchy.length === 1 ? "" : "s "} - {map(annotationHierarchy, (annotation, index) => ( - - {index > 0 - ? index === annotationHierarchy.length - 1 - ? " and " - : ", " - : " "} - {annotation.displayName} - - ))} + {map(annotationHierarchy, (annotationName, index) => { + const annotation = annotations.find( + (a) => a.name === annotationName + ); + return ( + + {index > 0 + ? index === annotationHierarchy.length - 1 + ? " and " + : ", " + : " "} + {annotation?.displayName} + + ); + })} )}{" "} diff --git a/packages/core/components/FileDetails/FileAnnotationList.tsx b/packages/core/components/FileDetails/FileAnnotationList.tsx index 14e949220..aca7d277a 100644 --- a/packages/core/components/FileDetails/FileAnnotationList.tsx +++ b/packages/core/components/FileDetails/FileAnnotationList.tsx @@ -92,7 +92,7 @@ export default function FileAnnotationList(props: FileAnnotationListProps) { ); diff --git a/packages/core/components/FileDetails/FileAnnotationRow.module.css b/packages/core/components/FileDetails/FileAnnotationRow.module.css index f56ff7b0f..8904927de 100644 --- a/packages/core/components/FileDetails/FileAnnotationRow.module.css +++ b/packages/core/components/FileDetails/FileAnnotationRow.module.css @@ -17,11 +17,15 @@ .cell { height: auto; - padding: 3px; + padding: 3px 3px 3px 0; overflow-wrap: anywhere; white-space: normal; } +.cell:first-of-type { + padding-left: 0; +} + .key { /* flex child */ flex: 1 1 40%; diff --git a/packages/core/components/FileDetails/FileDetails.module.css b/packages/core/components/FileDetails/FileDetails.module.css index 781f8904c..9a06c4875 100644 --- a/packages/core/components/FileDetails/FileDetails.module.css +++ b/packages/core/components/FileDetails/FileDetails.module.css @@ -14,7 +14,7 @@ overflow: auto; display: flex; justify-content: center; - z-index: 1111111111; + z-index: 1001; } .expandable::after { @@ -85,7 +85,7 @@ position: relative; width: 100%; - height: calc(100% - var(--window-buttons-height) - var(--pagination-height) - 28px); + height: calc(100% - var(--window-buttons-height) - var(--pagination-height)); } .overflow-container { @@ -165,12 +165,13 @@ .file-name { padding: var(--margin); text-align: center; + user-select: text; word-break: break-all; } -.file-name-divider { - border-color: var(--secondary-text-color); - width: 25%; +.title { + font-size: large; + margin: 0 0 0 var(--margin); } .icon-button span { @@ -183,5 +184,5 @@ } .icon-button i, .icon-button:hover i { - color: var(--primary-text-color); + color: var(--primary-text-color) !important; } diff --git a/packages/core/components/FileDetails/OpenFileButton.tsx b/packages/core/components/FileDetails/OpenFileButton.tsx index 7e66c6569..b505315c0 100644 --- a/packages/core/components/FileDetails/OpenFileButton.tsx +++ b/packages/core/components/FileDetails/OpenFileButton.tsx @@ -84,6 +84,9 @@ export default function OpenFileButton(props: Props) { className: styles.buttonMenu, items: openMenuItems, }} + iconProps={{ + iconName: "OpenInNewWindow", + }} text="Open" /> ); diff --git a/packages/core/components/FileDetails/index.tsx b/packages/core/components/FileDetails/index.tsx index 0dbd4988b..a93f7942f 100644 --- a/packages/core/components/FileDetails/index.tsx +++ b/packages/core/components/FileDetails/index.tsx @@ -162,7 +162,7 @@ export default function FileDetails(props: Props) { />

{fileDetails?.name}

-
+

Information

", () => { describe("file path representation", () => { @@ -54,9 +55,9 @@ describe("", () => { // Assert [ - "File path (Canonical)", + "File Path (Canonical)", canonicalFilePath, - "File path (Local)", + "File Path (Local)", expectedLocalPath, ].forEach(async (cellText) => { expect(await findByText(cellText)).to.not.be.undefined; @@ -73,6 +74,9 @@ describe("", () => { const { store } = configureMockStore({ state: mergeState(initialState, { + metadata: { + annotations: TOP_LEVEL_FILE_ANNOTATIONS, + }, interaction: { platformDependentServices: { executionEnvService: new FakeExecutionEnvService(), @@ -100,8 +104,8 @@ describe("", () => { ); // Assert - expect(() => getByText("File path (Local)")).to.throw(); - ["File path (Canonical)", filePath].forEach((cellText) => { + expect(() => getByText("File Path (Local)")).to.throw(); + ["File Path (Canonical)", filePath].forEach((cellText) => { expect(getByText(cellText)).to.not.be.undefined; }); }); diff --git a/packages/core/components/FileDetails/test/OpenFileButton.test.tsx b/packages/core/components/FileDetails/test/OpenFileButton.test.tsx index 43ed5018f..05ca8286d 100644 --- a/packages/core/components/FileDetails/test/OpenFileButton.test.tsx +++ b/packages/core/components/FileDetails/test/OpenFileButton.test.tsx @@ -1,4 +1,4 @@ -import { configureMockStore } from "@aics/redux-utils"; +import { configureMockStore, mergeState } from "@aics/redux-utils"; import { fireEvent, render } from "@testing-library/react"; import { expect } from "chai"; import * as React from "react"; @@ -6,12 +6,25 @@ import { Provider } from "react-redux"; import { initialState, interaction } from "../../../state"; import FileDetail from "../../../entity/FileDetail"; + import OpenFileButton from "../OpenFileButton"; describe("", () => { it("opens file in app", () => { // Arrange - const { store, actions } = configureMockStore({ state: initialState }); + const appName = "MyApp"; + const appPath = `/home/some/path/${appName}.exe`; + const state = mergeState(initialState, { + interaction: { + userSelectedApplications: [ + { + defaultFileKinds: [], + filePath: appPath, + }, + ], + }, + }); + const { store, actions } = configureMockStore({ state }); const fileDetails = new FileDetail({ file_path: "/allen/some/path/MyFile.txt", file_id: "abc123", @@ -32,12 +45,15 @@ describe("", () => { // Act fireEvent.click(getByText("Open")); + // Execution service will attempt to find name however we have the Noop service + // in place so it will just return this for any request for a file name + fireEvent.click(getByText("ExecutionEnvServiceNoop::getFilename")); // Assert expect(actions.list.length).to.equal(1); expect( actions.includesMatch({ - type: interaction.actions.OPEN_WITH_DEFAULT, + type: interaction.actions.OPEN_WITH, }) ).to.be.true; }); diff --git a/packages/core/components/FileList/ColumnPicker.tsx b/packages/core/components/FileList/ColumnPicker.tsx index 7a09e65f8..17cd5f0e0 100644 --- a/packages/core/components/FileList/ColumnPicker.tsx +++ b/packages/core/components/FileList/ColumnPicker.tsx @@ -5,7 +5,7 @@ import AnnotationPicker from "../AnnotationPicker"; import { selection } from "../../state"; /** - * TODO + * Picker for selecting which columns to display in the file list. */ export default function ColumnPicker() { const dispatch = useDispatch(); diff --git a/packages/core/components/FileList/LazilyRenderedRow.tsx b/packages/core/components/FileList/LazilyRenderedRow.tsx index 98b4cfb62..f3415670a 100644 --- a/packages/core/components/FileList/LazilyRenderedRow.tsx +++ b/packages/core/components/FileList/LazilyRenderedRow.tsx @@ -69,7 +69,6 @@ export default function LazilyRenderedRow(props: LazilyRenderedRowProps) { })} rowIdentifier={{ index, id: file.id }} onSelect={onSelect} - data-testid="asdsadasdsadsad" /> ); } diff --git a/packages/core/components/FileList/LazilyRenderedThumbnail.module.css b/packages/core/components/FileList/LazilyRenderedThumbnail.module.css index af17ece9b..1101cd42c 100644 --- a/packages/core/components/FileList/LazilyRenderedThumbnail.module.css +++ b/packages/core/components/FileList/LazilyRenderedThumbnail.module.css @@ -7,15 +7,27 @@ text-align: center; } +.selected { + background-color: var(--highlight-background-color); +} + +.focused { + border: 1px solid var(--highlight-text-color); +} + .thumbnail-wrapper { padding: 10px } -.selected { - background-color: #d4e3fc; +.thumbnail-wrapper > div { + display: flex; + flex-direction: column; } -.focused { - margin: 0; - border: 2px solid #669bf4; -} \ No newline at end of file +.thumbnail-wrapper > div:not(.focused) { + margin: 1px; +} + +.thumbnail-wrapper > div > img { + margin: 0 auto; +} diff --git a/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx b/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx index 94795ee47..14b1f7c36 100644 --- a/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx +++ b/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx @@ -5,13 +5,12 @@ import * as React from "react"; import { Provider } from "react-redux"; import * as sinon from "sinon"; -import LazilyRenderedRow from "../LazilyRenderedRow"; import Annotation from "../../../entity/Annotation"; import FileDetail from "../../../entity/FileDetail"; import FileSet from "../../../entity/FileSet"; import { initialState } from "../../../state"; -import styles from "../LazilyRenderedRow.module.css"; +import LazilyRenderedRow from "../LazilyRenderedRow"; describe("", () => { const fileNameAnnotation = new Annotation({ @@ -67,37 +66,4 @@ describe("", () => { // Assert expect(getByText("my_image.czi")).to.not.equal(null); }); - - describe("Dynamic styling", () => { - [true, false].forEach((shouldDisplaySmallFont) => { - it(`Has${ - shouldDisplaySmallFont ? "" : " no" - } small font style when shouldDisplaySmallFont is ${shouldDisplaySmallFont}`, () => { - // Arrange - const { store } = configureMockStore({ - state: mergeState(initialState, { - metadata: { - annotations: [fileNameAnnotation], - }, - selection: { - displayAnnotations: [fileNameAnnotation], - shouldDisplaySmallFont, - }, - }), - }); - - // Act - const { getByTestId } = render( - - - - ); - - // Assert - expect( - getByTestId("non-resizeable-cell-test-id").classList.contains(styles.smallFont) - ).to.equal(shouldDisplaySmallFont); - }); - }); - }); }); diff --git a/packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx b/packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx index f24454774..17a20ad41 100644 --- a/packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx +++ b/packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx @@ -32,7 +32,6 @@ describe("", () => { file_name: "my_image9.jpg", file_path: "some/path/to/my_image9.jpg", file_size: 1, - thumbnail: "", uploaded: new Date().toISOString(), }); } @@ -43,7 +42,6 @@ describe("", () => { file_name: "my_image25.czi", file_path: "some/path/to/my_image25.czi", file_size: 1, - thumbnail: "", uploaded: new Date().toISOString(), }); } diff --git a/packages/core/components/FileThumbnail/test/FileThumbnail.test.tsx b/packages/core/components/FileThumbnail/test/FileThumbnail.test.tsx new file mode 100644 index 000000000..fc724844f --- /dev/null +++ b/packages/core/components/FileThumbnail/test/FileThumbnail.test.tsx @@ -0,0 +1,47 @@ +import { configureMockStore, mergeState } from "@aics/redux-utils"; +import { render } from "@testing-library/react"; +import { expect } from "chai"; +import * as React from "react"; +import { Provider } from "react-redux"; + +import { initialState } from "../../../state"; + +import FileThumbnail from ".."; + +describe("", () => { + it("renders thumbnail one is specified", () => { + // Arrange + const state = mergeState(initialState, {}); + const { store } = configureMockStore({ state }); + + // Act + const { getByRole } = render( + + + + ); + + // Assert + // Also checking for proper row/col indexing + const thumbnail = getByRole("img"); + expect(thumbnail.getAttribute("src")).to.include("some/path/to/my_image0.jpg"); + }); + + it("renders svg as thumbnail if no URI is specified", () => { + // Arrange + const { store } = configureMockStore({ state: initialState }); + + // Act + const { queryByRole } = render( + + + + ); + + // Assert + // Also confirms proper row/col indexing + expect(".no-thumbnail").to.exist; + expect(".svg").to.exist; + expect(queryByRole("img")).not.to.exist; + }); +}); diff --git a/packages/core/components/ListPicker/ListPicker.module.css b/packages/core/components/ListPicker/ListPicker.module.css index 4c3f4b2b9..9954ae5cf 100644 --- a/packages/core/components/ListPicker/ListPicker.module.css +++ b/packages/core/components/ListPicker/ListPicker.module.css @@ -1,7 +1,7 @@ .container { --spacing: 0.5rem; --footer-height: 15px; - --header-height: 90px; + --header-height: 110px; background-color: var(--secondary-background-color); color: var(--secondary-text-color); @@ -16,6 +16,10 @@ width: 100%; } +.header > h3 { + margin: 0 0 var(--spacing) 0; +} + .buttons { backdrop-filter: blur(1px); display: flex; @@ -36,7 +40,7 @@ max-width: 100%; position: absolute; width: 100%; - z-index: 11 + z-index: 10 } .footer { @@ -63,8 +67,8 @@ } .action-button:hover:not(.disabled), .action-button:hover:not(.disabled) i { - background-color: var(--highlight-background-color); - color: var(--highlight-text-color); + background-color: var(--highlight-background-color) !important; + color: var(--highlight-text-color) !important; cursor: pointer; } @@ -110,12 +114,17 @@ width: 100%; } -.item-container:hover:not(.disabled), .selected { +.item-container:hover:not(.disabled) { background-color: var(--highlight-background-color); border: none; color: var(--highlight-text-color); } +.selected { + background-color: var(--primary-background-color); + color: var(--primary-text-color); +} + .item-container, .item-container label { cursor: pointer; } diff --git a/packages/core/components/ListPicker/index.tsx b/packages/core/components/ListPicker/index.tsx index d3e7ee7bd..bfee0a7aa 100644 --- a/packages/core/components/ListPicker/index.tsx +++ b/packages/core/components/ListPicker/index.tsx @@ -31,6 +31,7 @@ interface ListPickerProps { errorMessage?: string; items: ListItem[]; loading?: boolean; + title?: string; onDeselect: (item: ListItem) => void; onDeselectAll: () => void; onSelect: (item: ListItem) => void; @@ -123,6 +124,7 @@ export default function ListPicker(props: ListPickerProps) { data-is-focusable="true" >
+ {props.title &&

{props.title}

} ", () => { // Arrange const onSelect = sinon.spy(); const onDeselect = sinon.spy(); - const items: ListItem[] = ["foo", "bar"].map((val, idx) => ({ - selected: idx % 2 === 0, + const items: ListItem[] = ["foo", "bar"].map((val) => ({ + selected: val === "bar", displayValue: val, value: val, })); // Act / Assert - const { getByRole, getAllByRole } = render( + const { getAllByRole, getByText } = render( ", () => { /> ); - // Should render both list items - expect(getAllByRole("checkbox").length).to.equal(2); - - // One should be checked, the other shouldn't be - expect(getAllByRole("checkbox", { checked: false }).length).to.equal(1); - expect(getAllByRole("checkbox", { checked: true }).length).to.equal(1); + // Should render both list items + a reset button + expect(getAllByRole("button")).to.be.lengthOf(3); // Trigger selection for the item that isn't selected - fireEvent.click(getByRole("checkbox", { checked: false })); - expect(onDeselect.called).to.equal(false); - expect(onSelect.called).to.equal(true); + fireEvent.click(getByText("foo")); + expect(onDeselect.called).to.be.false; + expect(onSelect.called).to.be.true; // Trigger deselect for the item that is selected // (reset spies first to isolate assertions) onDeselect.resetHistory(); onSelect.resetHistory(); - fireEvent.click(getByRole("checkbox", { checked: true })); - expect(onDeselect.called).to.equal(true); - expect(onSelect.called).to.equal(false); + fireEvent.click(getByText("bar")); + expect(onDeselect.called).to.be.true; + expect(onSelect.called).to.be.false; }); it("renders a search box that filters the rendered list items", () => { @@ -59,7 +55,7 @@ describe("", () => { })); // Act / Assert - const { getAllByRole, getByRole } = render( + const { getByText, getByRole } = render( ", () => { ); // Should render both list items - expect(getAllByRole("checkbox").length).to.equal(2); + expect(getByText("foo")).to.be.not.be.undefined; + expect(getByText("bar")).to.be.not.be.undefined; // Trigger a search fireEvent.change(getByRole("searchbox"), { @@ -77,7 +74,8 @@ describe("", () => { value: "foo", }, }); - expect(getAllByRole("checkbox").length).to.equal(1); + expect(getByText("foo")).to.be.not.be.undefined; + expect(() => getByText("bar")).to.throw(); }); it("Renders a 'Reset' button that deselects entire selection", () => { @@ -92,7 +90,7 @@ describe("", () => { })); // Act / Assert - const { getAllByRole, getByText } = render( + const { getByText } = render( ", () => { /> ); - // Should render both list items as initially selected - expect(getAllByRole("checkbox", { checked: true }).length).to.equal(2); - // Hit reset expect(onDeselectAll.called).to.equal(false); fireEvent.click(getByText("Reset")); expect(onDeselectAll.called).to.equal(true); }); + it("Unable to select 'Reset' button if no items selected", () => { + // Arrange + const onSelect = noop; + const onDeselect = noop; + const onDeselectAll = sinon.spy(); + const items: ListItem[] = ["foo", "bar"].map((val) => ({ + selected: false, // start with all items selected + displayValue: val, + value: val, + })); + + // Act / Assert + const { getByText } = render( + + ); + + // Hit reset + expect(onDeselectAll.called).to.be.false; + fireEvent.click(getByText("Reset")); + expect(onDeselectAll.called).to.be.false; + }); + it("Renders a 'Select All' button if given a callback for selecting all items", () => { // Arrange const onSelectAll = sinon.spy(); const items: ListItem[] = ["foo", "bar"].map((val) => ({ - selected: true, // start with all items selected + selected: false, // start with all items selected displayValue: val, value: val, })); - const { getAllByRole, getByText } = render( + const { getByText } = render( ", () => { ); // (sanity-check) - expect(getAllByRole("checkbox", { checked: true }).length).to.equal(2); expect(onSelectAll.called).to.be.false; // Act @@ -139,6 +160,34 @@ describe("", () => { expect(onSelectAll.called).to.be.true; }); + it("Unable to select 'Select All' button if all items selected", () => { + // Arrange + const onSelectAll = sinon.spy(); + const items: ListItem[] = ["foo", "bar"].map((val) => ({ + selected: true, // start with all items selected + displayValue: val, + value: val, + })); + const { getByText } = render( + + ); + + // (sanity-check) + expect(onSelectAll.called).to.be.false; + + // Act + fireEvent.click(getByText("Select All")); + + // Assert + expect(onSelectAll.called).to.be.false; + }); + it("Displays count of items", () => { // Arrange const items: ListItem[] = ["foo", "bar"].map((val) => ({ diff --git a/packages/core/components/Modal/PythonSnippet/PythonSnippet.module.css b/packages/core/components/Modal/CodeSnippet/CodeSnippet.module.css similarity index 100% rename from packages/core/components/Modal/PythonSnippet/PythonSnippet.module.css rename to packages/core/components/Modal/CodeSnippet/CodeSnippet.module.css diff --git a/packages/core/components/Modal/PythonSnippet/index.tsx b/packages/core/components/Modal/CodeSnippet/index.tsx similarity index 89% rename from packages/core/components/Modal/PythonSnippet/index.tsx rename to packages/core/components/Modal/CodeSnippet/index.tsx index 4c2f7cf41..87f31ec53 100644 --- a/packages/core/components/Modal/PythonSnippet/index.tsx +++ b/packages/core/components/Modal/CodeSnippet/index.tsx @@ -7,17 +7,17 @@ import { ModalProps } from ".."; import { interaction } from "../../../state"; import BaseModal from "../BaseModal"; -import styles from "./PythonSnippet.module.css"; +import styles from "./CodeSnippet.module.css"; const COPY_ICON = { iconName: "copy" }; /** - * Dialog meant to show the user a Python snippet + * Dialog meant to show the user a Code snippet */ -export default function PythonSnippet({ onDismiss }: ModalProps) { +export default function CodeSnippet({ onDismiss }: ModalProps) { const pythonSnippet = useSelector(interaction.selectors.getPythonSnippet); - const code = pythonSnippet && pythonSnippet.code; - const setup = pythonSnippet && pythonSnippet.setup; + const code = pythonSnippet?.code; + const setup = pythonSnippet?.setup; const [isSetupCopied, setSetupCopied] = React.useState(false); const [isCodeCopied, setCodeCopied] = React.useState(false); @@ -85,5 +85,5 @@ export default function PythonSnippet({ onDismiss }: ModalProps) { ); - return ; + return ; } diff --git a/packages/core/components/Modal/PythonSnippet/test/PythonSnippet.test.tsx b/packages/core/components/Modal/CodeSnippet/test/CodeSnippet.test.tsx similarity index 86% rename from packages/core/components/Modal/PythonSnippet/test/PythonSnippet.test.tsx rename to packages/core/components/Modal/CodeSnippet/test/CodeSnippet.test.tsx index 76d5b1c38..700fe0692 100644 --- a/packages/core/components/Modal/PythonSnippet/test/PythonSnippet.test.tsx +++ b/packages/core/components/Modal/CodeSnippet/test/CodeSnippet.test.tsx @@ -7,10 +7,10 @@ import { Provider } from "react-redux"; import Modal, { ModalType } from "../.."; import { initialState } from "../../../../state"; -describe("", () => { +describe("", () => { const visibleDialogState = mergeState(initialState, { interaction: { - visibleModal: ModalType.PythonSnippet, + visibleModal: ModalType.CodeSnippet, }, }); @@ -30,8 +30,8 @@ describe("", () => { it("displays snippet when present in state", async () => { // Arrange - const setup = "pip install aicsfiles"; - const code = "my_files = querier.query()"; + const setup = "pip install pandas"; + const code = "TODO"; const { store } = configureMockStore({ state: visibleDialogState }); const { findByText } = render( diff --git a/packages/core/components/Modal/CsvManifest/CsvManifest.module.css b/packages/core/components/Modal/CsvManifest/CsvManifest.module.css index 4fdfe3e14..65d3f518b 100644 --- a/packages/core/components/Modal/CsvManifest/CsvManifest.module.css +++ b/packages/core/components/Modal/CsvManifest/CsvManifest.module.css @@ -4,7 +4,11 @@ color: var(--primary-text-color); } -.download-button:hover, .download-button:active { +.disabled { + opacity: 0.5; +} + +.download-button:hover:not(.disabled), .download-button:active:not(.disabled) { border: none; background-color: var(--highlight-background-color); color: var(--highlight-text-color); diff --git a/packages/core/components/Modal/CsvManifest/index.tsx b/packages/core/components/Modal/CsvManifest/index.tsx index 01e1928a4..48997e341 100644 --- a/packages/core/components/Modal/CsvManifest/index.tsx +++ b/packages/core/components/Modal/CsvManifest/index.tsx @@ -1,17 +1,15 @@ import { PrimaryButton } from "@fluentui/react"; -import { isEmpty } from "lodash"; import * as React from "react"; import { useDispatch, useSelector } from "react-redux"; import { ModalProps } from ".."; import BaseModal from "../BaseModal"; import AnnotationPicker from "../../AnnotationPicker"; -import { TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants"; -import Annotation from "../../../entity/Annotation"; import * as modalSelectors from "../selectors"; import { interaction } from "../../../state"; import styles from "./CsvManifest.module.css"; +import classNames from "classnames"; /** * Modal overlay for selecting columns to be included in a CSV manifest download of @@ -23,10 +21,8 @@ export default function CsvManifest({ onDismiss }: ModalProps) { const annotationsPreviouslySelected = useSelector( modalSelectors.getAnnotationsPreviouslySelected ); - const [selectedAnnotations, setSelectedAnnotations] = React.useState(() => - isEmpty(annotationsPreviouslySelected) - ? [...TOP_LEVEL_FILE_ANNOTATIONS] - : annotationsPreviouslySelected + const [selectedAnnotations, setSelectedAnnotations] = React.useState( + annotationsPreviouslySelected ); const onDownload = () => { @@ -51,7 +47,9 @@ export default function CsvManifest({ onDismiss }: ModalProps) { body={body} footer={ ", () => { const state = mergeState(visibleDialogState, { interaction: { + csvColumns: ["Cell Line"], fileFiltersForVisibleModal: [new FileFilter("Cell Line", "AICS-11")], platformDependentServices: { fileDownloadService: new TestDownloadService(), }, }, + metadata: { + annotations: [ + new Annotation({ + annotationDisplayName: "Cell Line", + annotationName: "Cell Line", + description: "test", + type: "text", + }), + ], + }, }); const { store, logicMiddleware, actions } = configureMockStore({ @@ -132,21 +142,6 @@ describe("", () => { }); describe("column list", () => { - it("has default columns when none were previousuly saved", async () => { - // Arrange - const { store } = configureMockStore({ state: visibleDialogState }); - const { getByText } = render( - - - - ); - - // Assert - TOP_LEVEL_FILE_ANNOTATIONS.forEach((annotation) => { - expect(getByText(annotation.displayName)).to.exist; - }); - }); - it("has pre-saved columns when some were previousuly saved", async () => { // Arrange const preSavedColumns = ["Cas9", "Cell Line", "Donor Plasmid"]; diff --git a/packages/core/components/Modal/index.tsx b/packages/core/components/Modal/index.tsx index fb3c601bb..049f205fe 100644 --- a/packages/core/components/Modal/index.tsx +++ b/packages/core/components/Modal/index.tsx @@ -2,9 +2,9 @@ import React from "react"; import { useDispatch, useSelector } from "react-redux"; import { interaction } from "../../state"; +import CodeSnippet from "./CodeSnippet"; import CsvManifest from "./CsvManifest"; import DataSourcePrompt from "./DataSourcePrompt"; -import PythonSnippet from "./PythonSnippet"; export interface ModalProps { onDismiss: () => void; @@ -13,7 +13,7 @@ export interface ModalProps { export enum ModalType { CsvManifest = 1, DataSourcePrompt = 2, - PythonSnippet = 3, + CodeSnippet = 3, } /** @@ -32,8 +32,8 @@ export default function Modal() { return ; case ModalType.DataSourcePrompt: return ; - case ModalType.PythonSnippet: - return ; + case ModalType.CodeSnippet: + return ; default: return null; } diff --git a/packages/core/components/Modal/selectors.ts b/packages/core/components/Modal/selectors.ts index 2acbeb325..2ec2bead2 100644 --- a/packages/core/components/Modal/selectors.ts +++ b/packages/core/components/Modal/selectors.ts @@ -9,9 +9,6 @@ import * as metadataSelectors from "../../state/metadata/selectors"; */ export const getAnnotationsPreviouslySelected = createSelector( [interactionSelectors.getCsvColumns, metadataSelectors.getAnnotations], - (annotationDisplayNames, annotations) => { - return annotations.filter((annotation) => - annotationDisplayNames?.includes(annotation.displayName) - ); - } + (annotationDisplayNames, annotations) => + annotations.filter((annotation) => annotationDisplayNames?.includes(annotation.displayName)) ); diff --git a/packages/core/components/NumberRangePicker/NumberRangePicker.module.css b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css index 18e386ed4..45acbab0b 100644 --- a/packages/core/components/NumberRangePicker/NumberRangePicker.module.css +++ b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css @@ -1,57 +1,62 @@ -.container { - --spacing: 0.5rem; - - overflow: auto; - max-height: 300px; - padding: 0 var(--spacing) 0 var(--spacing); - margin-top: var(--spacing); -} - -.header { - position: sticky; - top: 0; - padding-bottom: var(--spacing); - width: 100%; - background: rgba(255, 255, 255, 0.85); -} - .buttons { clear: both; display: flex; + justify-content: center; + padding-top: var(--margin); } .footer { - bottom: 0; - padding-bottom: var(--spacing); - position: sticky; width: 100%; } .footer > h6 { - background-color: white; - border-radius: 5px; - color: grey; - font-size: 12px; font-weight: normal; - margin: 0 0 0 auto; - width: fit-content; + margin: var(--margin) 0 0 0; +} + +.action-button, .action-button i { + background-color: var(--primary-background-color) !important; + color: var(--primary-text-color) !important; } -.action-button { - color: steelblue; +.action-button:hover:not(.disabled), .action-button:hover:not(.disabled) i { + background-color: var(--highlight-background-color) !important; + color: var(--highlight-text-color) !important; cursor: pointer; - display: flex; - margin: 0.5rem auto 0; } .action-button.disabled { - color: grey; cursor: not-allowed; + opacity: 0.5; +} + +.reset-button-container { + margin-top: 2px; +} + +.inputs { + display: flex; + justify-content: space-between; + +} + +.input-field > input { + background-color: var(--primary-background-color); + border: none; + color: var(--primary-text-color); + outline: none; +} + +.range-seperator { + align-items: center; + display: flex; + font-size: small; + margin: 0 2px; } -.input-field { - float: left; - margin-left: 10px; +.title { + margin: 0; + padding-bottom: var(--margin); } label,input{ diff --git a/packages/core/components/NumberRangePicker/index.tsx b/packages/core/components/NumberRangePicker/index.tsx index 35327d788..03648087c 100644 --- a/packages/core/components/NumberRangePicker/index.tsx +++ b/packages/core/components/NumberRangePicker/index.tsx @@ -1,6 +1,7 @@ -import { ActionButton, Spinner, SpinnerSize } from "@fluentui/react"; +import { ActionButton, Icon, IconButton, Spinner, SpinnerSize } from "@fluentui/react"; import classNames from "classnames"; import * as React from "react"; + import FileFilter from "../../entity/FileFilter"; import { extractValuesFromRangeOperatorFilterString } from "../../entity/AnnotationFormatter/number-formatter"; import { AnnotationValue } from "../../services/AnnotationService"; @@ -13,8 +14,10 @@ export interface ListItem { } interface NumberRangePickerProps { + className?: string; disabled?: boolean; errorMessage?: string; + title?: string; items: ListItem[]; loading?: boolean; onSearch: (filterValue: string) => void; @@ -53,13 +56,6 @@ export default function NumberRangePicker(props: NumberRangePickerProps) { setSearchMaxValue(""); } - function onSelectFullRange() { - setSearchMinValue(overallMin); - setSearchMaxValue(overallMax); - // Plus 1 here to ensure that actual max is not excluded for full range - onSearch(`RANGE(${overallMin},${(Number(overallMax) + 1).toString()})`); - } - const onSubmitRange = () => { const { minValue: oldMinValue, @@ -84,47 +80,69 @@ export default function NumberRangePicker(props: NumberRangePickerProps) { }; if (errorMessage) { - return
Whoops! Encountered an error: {errorMessage}
; + return ( +
+ Whoops! Encountered an error: {errorMessage} +
+ ); } if (loading) { return ( -
+
); } + console.log(overallMin, overallMax); return ( -
+
+

{units ? `${props.title} (in ${units})` : props.title}

-
- - - {units} -
-
- - - {units} +
+
+ + +
+
+ +
+
+ + +
+
+ +
- Submit range - - {onSelectFullRange && ( - - Select Full Range - - )} - - Reset + Submit Range
-
- Full range available: {overallMin}, {overallMax} -
+ {overallMin && overallMax && ( +
+ Full range available: {overallMin}, {overallMax} +
+ )}
); diff --git a/packages/core/components/NumberRangePicker/test/NumberRangePicker.test.tsx b/packages/core/components/NumberRangePicker/test/NumberRangePicker.test.tsx index e0957318a..89f65fc90 100644 --- a/packages/core/components/NumberRangePicker/test/NumberRangePicker.test.tsx +++ b/packages/core/components/NumberRangePicker/test/NumberRangePicker.test.tsx @@ -66,7 +66,7 @@ describe("", () => { })); // Act / Assert - const { getByText } = render( + const { getByTitle } = render( ", () => { // Hit reset expect(onReset.called).to.equal(false); - fireEvent.click(getByText("Reset")); + fireEvent.click(getByTitle("Reset filter")); expect(onReset.called).to.equal(true); // Should clear min and max values @@ -89,46 +89,6 @@ describe("", () => { expect(screen.getByTitle(/^Max/).value).to.equal(""); }); - it("renders a 'Select Full Range' button that updates min and max values", () => { - // Arrange - const items: ListItem[] = ["0", "20"].map((val) => ({ - selected: true, // start with all items selected - displayValue: val, - value: val, - })); - const { getByTitle, getByText } = render( - - ); - - // Enter values - fireEvent.change(getByTitle(/^Min/), { - target: { - value: 5, - }, - }); - fireEvent.change(getByTitle(/^Max/), { - target: { - value: 10, - }, - }); - - // Sanity check - expect(screen.getByTitle(/^Min/).value).to.equal("5"); - expect(screen.getByTitle(/^Max/).value).to.equal("10"); - - // Act - fireEvent.click(getByText("Select Full Range")); - - // Assert - expect(screen.getByTitle(/^Min/).value).to.equal("0"); - expect(screen.getByTitle(/^Max/).value).to.equal("20"); - }); - it("displays available min and max of items", () => { // Arrange const items: ListItem[] = ["-2", "1", "20", "42"].map((val) => ({ diff --git a/packages/core/components/QueryPart/QueryDataSource.tsx b/packages/core/components/QueryPart/QueryDataSource.tsx new file mode 100644 index 000000000..a8482b28f --- /dev/null +++ b/packages/core/components/QueryPart/QueryDataSource.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; + +import QueryPart from "."; +import { Collection } from "../../entity/FileExplorerURL"; + +interface Props { + dataSources: (Collection | undefined)[]; +} + +/** + * Component responsible for rendering the "Filter" part of the query + */ +export default function QueryDataSource(props: Props) { + return ( +
TODO: To be implemented in another ticket
} + rows={props.dataSources.map((dataSource) => { + // Undefined data source must mean we are querying AICS FMS + // we should have a more sentinal value for this + if (!dataSource) { + return { + id: "AICS FMS", + title: "AICS FMS", + }; + } + + return { + id: `${dataSource.name} ${dataSource.version}`, + title: dataSource.name, + }; + })} + /> + ); +} diff --git a/packages/core/components/QueryPart/QueryFilter.tsx b/packages/core/components/QueryPart/QueryFilter.tsx new file mode 100644 index 000000000..5f9b2c5f8 --- /dev/null +++ b/packages/core/components/QueryPart/QueryFilter.tsx @@ -0,0 +1,64 @@ +import { map } from "lodash"; +import * as React from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import QueryPart from "."; +import AnnotationPicker from "../AnnotationPicker"; +import AnnotationFilterForm from "../AnnotationFilterForm"; +import Tutorial from "../../entity/Tutorial"; +import FileFilter from "../../entity/FileFilter"; +import { metadata, selection } from "../../state"; +import Annotation from "../../entity/Annotation"; + +interface Props { + filters: FileFilter[]; +} + +/** + * Component responsible for rendering the "Filter" part of the query + */ +export default function QueryFilter(props: Props) { + const dispatch = useDispatch(); + + const annotations = useSelector(metadata.selectors.getSortedAnnotations); + const filtersGroupedByName = useSelector(selection.selectors.getGroupedByFilterName); + + return ( + + dispatch( + selection.actions.removeFileFilter( + props.filters.filter((filter) => filter.name === annotation) + ) + ) + } + onRenderAddMenuList={() => ( + ( + + )} + selections={annotations.filter((annotation) => + props.filters.some((f) => f.name === annotation.name) + )} + setSelections={() => dispatch(selection.actions.setFileFilters([]))} + /> + )} + onRenderEditMenuList={(item) => { + const annotation = annotations.find((a) => a.name === item.id); + return ; + }} + rows={Object.entries(filtersGroupedByName).map(([annotationName, filters]) => { + const operator = filters.length > 1 ? "ONE OF" : "EQUALS"; + const valueDisplay = map(filters, (filter) => filter.displayValue).join(", "); + return { + id: filters[0].name, + title: `${annotationName} ${operator} ${valueDisplay}`, + }; + })} + /> + ); +} diff --git a/packages/core/components/QueryPart/QueryGroup.tsx b/packages/core/components/QueryPart/QueryGroup.tsx new file mode 100644 index 000000000..421a975db --- /dev/null +++ b/packages/core/components/QueryPart/QueryGroup.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import QueryPart from "."; +import AnnotationPicker from "../AnnotationPicker"; +import Tutorial from "../../entity/Tutorial"; +import { metadata, selection } from "../../state"; +import Annotation from "../../entity/Annotation"; + +interface Props { + groups: string[]; +} + +/** + * Component responsible for rendering the "Group" part of the query + */ +export default function QueryGroup(props: Props) { + const dispatch = useDispatch(); + + const annotations = useSelector(metadata.selectors.getSortedAnnotations); + + const selectedAnnotations = props.groups + .map((annotationName) => + annotations.find((annotation) => annotation.name === annotationName) + ) + .filter((a) => !!a) as Annotation[]; + + const onDelete = (annotationName: string) => { + dispatch(selection.actions.removeFromAnnotationHierarchy(annotationName)); + }; + + const onReorder = (annotationName: string, destinationIndex: number) => { + dispatch(selection.actions.reorderAnnotationHierarchy(annotationName, destinationIndex)); + }; + + return ( + ( + { + dispatch( + selection.actions.setAnnotationHierarchy(annotations.map((a) => a.name)) + ); + }} + /> + )} + rows={selectedAnnotations.map((annotation) => ({ + id: annotation.name, + title: annotation.displayName, + }))} + /> + ); +} diff --git a/packages/core/components/QuerySidebar/QueryPart.module.css b/packages/core/components/QueryPart/QueryPart.module.css similarity index 88% rename from packages/core/components/QuerySidebar/QueryPart.module.css rename to packages/core/components/QueryPart/QueryPart.module.css index e552e61d4..e9cfd872c 100644 --- a/packages/core/components/QuerySidebar/QueryPart.module.css +++ b/packages/core/components/QueryPart/QueryPart.module.css @@ -3,7 +3,7 @@ background-color: var(--primary-sidebar-color); border: none; padding: 0 4px; - width: 100px; + width: 100%; } .add-button:hover, .add-button:hover button { @@ -12,6 +12,10 @@ border: none; } +.add-button > span > span { + text-align: left; +} + .add-button-container > * { margin: 5px 0; width: 125px diff --git a/packages/core/components/QuerySidebar/QueryPartRow.module.css b/packages/core/components/QueryPart/QueryPartRow.module.css similarity index 100% rename from packages/core/components/QuerySidebar/QueryPartRow.module.css rename to packages/core/components/QueryPart/QueryPartRow.module.css diff --git a/packages/core/components/QuerySidebar/QueryPartRow.tsx b/packages/core/components/QueryPart/QueryPartRow.tsx similarity index 100% rename from packages/core/components/QuerySidebar/QueryPartRow.tsx rename to packages/core/components/QueryPart/QueryPartRow.tsx diff --git a/packages/core/components/QueryPart/QuerySort.tsx b/packages/core/components/QueryPart/QuerySort.tsx new file mode 100644 index 000000000..ccc2a66fe --- /dev/null +++ b/packages/core/components/QueryPart/QuerySort.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import QueryPart from "."; +import AnnotationPicker from "../AnnotationPicker"; +import { metadata, selection } from "../../state"; +import FileSort, { SortOrder } from "../../entity/FileSort"; +import Tutorial from "../../entity/Tutorial"; + +interface Props { + sort?: FileSort; +} + +/** + * Component responsible for rendering the "Sort" part of the query + */ +export default function QuerySort(props: Props) { + const dispatch = useDispatch(); + + const annotations = useSelector(metadata.selectors.getSortedAnnotations); + + return ( + dispatch(selection.actions.setSortColumn())} + rows={ + props.sort + ? [ + { + id: props.sort.annotationName, + title: `${props.sort.annotationName} (${props.sort.order})`, + }, + ] + : [] + } + onRenderAddMenuList={() => ( + annotation.name === props.sort?.annotationName + )} + setSelections={(annotations) => { + const newAnnotation = annotations.filter( + (annotation) => annotation.name !== props.sort?.annotationName + )?.[0].name; + dispatch( + selection.actions.setSortColumn( + newAnnotation + ? new FileSort(newAnnotation, SortOrder.DESC) + : undefined + ) + ); + }} + /> + )} + /> + ); +} diff --git a/packages/core/components/QuerySidebar/QueryPart.tsx b/packages/core/components/QueryPart/index.tsx similarity index 100% rename from packages/core/components/QuerySidebar/QueryPart.tsx rename to packages/core/components/QueryPart/index.tsx diff --git a/packages/core/components/QuerySidebar/Query.module.css b/packages/core/components/QuerySidebar/Query.module.css index 8777aaedd..8f20aef5a 100644 --- a/packages/core/components/QuerySidebar/Query.module.css +++ b/packages/core/components/QuerySidebar/Query.module.css @@ -34,35 +34,12 @@ display: flex; } -.divider, .small-divider { +.divider { border-color: var(--secondary-text-color); margin: 0 auto; opacity: 0.5; } -.small-divider { - width: 35%; -} - -.data-source-container { - margin: 8px 0 8px 10px; -} - -.data-source-header { - display: flex; - margin-bottom: 6px; -} - -.data-source-header > h5 { - margin: 0 0 0 10px; - padding-top: 2px; -} - -.data-source-container > p { - font-size: smaller; - margin-top: 4px; -} - .title { background-color: var(--primary-sidebar-color); color: var(--primary-sidebar-text-color); diff --git a/packages/core/components/QuerySidebar/Query.tsx b/packages/core/components/QuerySidebar/Query.tsx index b04c60c78..dc29b46c0 100644 --- a/packages/core/components/QuerySidebar/Query.tsx +++ b/packages/core/components/QuerySidebar/Query.tsx @@ -1,21 +1,18 @@ -import { Icon, IconButton, TextField } from "@fluentui/react"; +import { IconButton, TextField } from "@fluentui/react"; import classNames from "classnames"; -import { map } from "lodash"; import * as React from "react"; import { useDispatch, useSelector } from "react-redux"; -import QueryPart from "./QueryPart"; -import AnnotationPicker from "../AnnotationPicker"; import QueryFooter from "./QueryFooter"; -import Tutorial from "../../entity/Tutorial"; +import QueryDataSource from "../QueryPart/QueryDataSource"; +import QueryFilter from "../QueryPart/QueryFilter"; +import QueryGroup from "../QueryPart/QueryGroup"; +import QuerySort from "../QueryPart/QuerySort"; import FileExplorerURL from "../../entity/FileExplorerURL"; -import FileSort, { SortOrder } from "../../entity/FileSort"; import { metadata, selection } from "../../state"; import { Query as QueryType } from "../../state/selection/actions"; import styles from "./Query.module.css"; -import AnnotationFilterForm from "../AnnotationFilterForm"; -import { QueryPartRowItem } from "./QueryPartRow"; interface QueryProps { isSelected: boolean; @@ -31,7 +28,6 @@ export default function Query(props: QueryProps) { const queries = useSelector(selection.selectors.getQueries); const annotations = useSelector(metadata.selectors.getSortedAnnotations); const currentGlobalURL = useSelector(selection.selectors.getEncodedFileExplorerUrl); - const filtersGroupedByName = useSelector(selection.selectors.getGroupedByFilterName); const [isExpanded, setIsExpanded] = React.useState(false); React.useEffect(() => { @@ -41,9 +37,9 @@ export default function Query(props: QueryProps) { const decodedURL = React.useMemo( () => props.isSelected - ? FileExplorerURL.decode(currentGlobalURL, annotations) - : FileExplorerURL.decode(props.query.url, annotations), - [props.query.url, currentGlobalURL, annotations, props.isSelected] + ? FileExplorerURL.decode(currentGlobalURL) + : FileExplorerURL.decode(props.query.url), + [props.query.url, currentGlobalURL, props.isSelected] ); const onQueryUpdate = (updatedQuery: QueryType) => { @@ -93,7 +89,11 @@ export default function Query(props: QueryProps) {

Groupings:{" "} {decodedURL.hierarchy - .map((annotation) => annotation.displayName) + .map( + (a) => + annotations.find((annotation) => annotation.name === a) + ?.displayName || a + ) .join(", ")}

)} @@ -141,120 +141,10 @@ export default function Query(props: QueryProps) { />

-
-
- -
Data Source
-
-

{dataSourceName}

-
-
- - dispatch(selection.actions.removeFromAnnotationHierarchy(annotation)) - } - onReorder={(annotation, destinationIndex) => - dispatch( - selection.actions.reorderAnnotationHierarchy( - annotation, - destinationIndex - ) - ) - } - onRenderAddMenuList={() => ( - { - dispatch(selection.actions.setAnnotationHierarchy(annotations)); - }} - /> - )} - rows={decodedURL.hierarchy.map((annotation) => ({ - id: annotation.name, - title: annotation.displayName, - }))} - /> -
- - dispatch( - selection.actions.removeFileFilter( - decodedURL.filters.filter((filter) => filter.name === annotation) - ) - ) - } - onRenderAddMenuList={() => ( - ( - - )} - selections={annotations.filter((annotation) => - decodedURL.filters.some((f) => f.name === annotation.name) - )} - setSelections={() => selection.actions.setFileFilters([])} - /> - )} - onRenderEditMenuList={(item: QueryPartRowItem) => ( - - )} - rows={Object.entries(filtersGroupedByName).map(([annotation, filters]) => { - const operator = filters.length > 1 ? "ONE OF" : "EQUALS"; - const valueDisplay = map(filters, (filter) => filter.displayValue).join( - ", " - ); - return { - id: annotation, - title: `${annotation} ${operator} ${valueDisplay}`, - }; - })} - /> -
- dispatch(selection.actions.setSortColumn())} - rows={ - decodedURL.sortColumn - ? [ - { - id: decodedURL.sortColumn.annotationName, - title: `${decodedURL.sortColumn.annotationName} (${decodedURL.sortColumn.order})`, - }, - ] - : [] - } - onRenderAddMenuList={() => ( - - annotation.name === decodedURL.sortColumn?.annotationName - )} - setSelections={(annotations) => { - const newAnnotation = annotations.filter( - (annotation) => - annotation.name !== decodedURL.sortColumn?.annotationName - )?.[0].name; - dispatch( - selection.actions.setSortColumn( - newAnnotation - ? new FileSort(newAnnotation, SortOrder.DESC) - : undefined - ) - ); - }} - /> - )} - /> + + + +
1} diff --git a/packages/core/components/QuerySidebar/QueryFooter.tsx b/packages/core/components/QuerySidebar/QueryFooter.tsx index 19333a828..ad7ee9615 100644 --- a/packages/core/components/QuerySidebar/QueryFooter.tsx +++ b/packages/core/components/QuerySidebar/QueryFooter.tsx @@ -35,11 +35,11 @@ export default function QueryFooter(props: Props) { }; const shareQueryOptions: IContextualMenuItem[] = [ { - key: "Python Snippet", - text: "Python Snippet", + key: "Code Snippet", + text: "Code Snippet", iconProps: { iconName: "Code" }, onClick: () => { - dispatch(interaction.actions.setVisibleModal(ModalType.PythonSnippet)); + dispatch(interaction.actions.setVisibleModal(ModalType.CodeSnippet)); }, }, { diff --git a/packages/core/components/QuerySidebar/QuerySidebar.module.css b/packages/core/components/QuerySidebar/QuerySidebar.module.css index 1e1e21c3f..4bdfd88a4 100644 --- a/packages/core/components/QuerySidebar/QuerySidebar.module.css +++ b/packages/core/components/QuerySidebar/QuerySidebar.module.css @@ -94,7 +94,7 @@ max-width: var(--query-sidebar-max-width); position: absolute; width: calc(0.3 * 80% - var(--margin)); - z-index: 1111; + z-index: 2; } .minimize-bar { diff --git a/packages/core/components/QuerySidebar/index.tsx b/packages/core/components/QuerySidebar/index.tsx index a29f21955..3d5b7ea4d 100644 --- a/packages/core/components/QuerySidebar/index.tsx +++ b/packages/core/components/QuerySidebar/index.tsx @@ -33,10 +33,19 @@ export default function QuerySidebar(props: QuerySidebarProps) { const collections = useSelector(metadata.selectors.getCollections); const selectedQuery = useSelector(selection.selectors.getSelectedQuery); - const isAICSEmployee = true; // TODO: Add trigger somewhere on app startup + // TODO: Add trigger somewhere on app startup before releasing to public + const isAICSEmployee = true; const [isExpanded, setIsExpanded] = React.useState(true); + // Default to first query in array if none selected yet some available + // this is primarily useful for when loading queries from persisted state + React.useEffect(() => { + if (queries.length && !selectedQuery) { + dispatch(selection.actions.changeQuery(queries[0])); + } + }, [queries, selectedQuery, dispatch]); + const helpMenuOptions = React.useMemo(() => HELP_OPTIONS(dispatch), [dispatch]); const addQueryOptions = React.useMemo(() => { const onEnterURL = (evt: React.FormEvent) => { diff --git a/packages/core/components/SearchBoxForm/SearchBoxForm.module.css b/packages/core/components/SearchBoxForm/SearchBoxForm.module.css index a8b60ea04..d2612f863 100644 --- a/packages/core/components/SearchBoxForm/SearchBoxForm.module.css +++ b/packages/core/components/SearchBoxForm/SearchBoxForm.module.css @@ -1,21 +1,18 @@ -.search-box-input { - border: none; - flex: auto; +.search-box-input, .search-box-input > :is(div, input), .search-box-input :is(button, i) { + background-color: var(--primary-background-color) !important; + color: var(--primary-text-color) !important; } -.container { - --spacing: 0.5rem; - - overflow: auto; - max-height: 300px; - padding: 0 var(--spacing) 0 var(--spacing); - margin-top: var(--spacing); +.search-box-input > div:hover > button, .search-box-input > div:hover i { + background-color: var(--primary-background-color) !important; + color: var(--primary-text-color) !important; } -.header { - position: sticky; - top: 0; - padding-bottom: var(--spacing); - width: 100%; - background: rgba(255, 255, 255, 0.85); +.search-box-input::after { + display: none; } + +.title { + margin: 0; + padding-bottom: var(--margin); +} \ No newline at end of file diff --git a/packages/core/components/SearchBoxForm/index.tsx b/packages/core/components/SearchBoxForm/index.tsx index 6a83c93c3..b632b8174 100644 --- a/packages/core/components/SearchBoxForm/index.tsx +++ b/packages/core/components/SearchBoxForm/index.tsx @@ -1,9 +1,13 @@ -import * as React from "react"; import { SearchBox } from "@fluentui/react"; +import * as React from "react"; + import FileFilter from "../../entity/FileFilter"; + import styles from "./SearchBoxForm.module.css"; interface SearchBoxFormProps { + className?: string; + title?: string; onSearch: (filterValue: string) => void; onReset: () => void; fieldName: string; @@ -36,18 +40,17 @@ export default function SearchBoxForm(props: SearchBoxFormProps) { } return ( -
-
- -
+
+

{props.title}

+
); } diff --git a/packages/core/constants/index.ts b/packages/core/constants/index.ts index 1b04d2b53..15a7f9a93 100644 --- a/packages/core/constants/index.ts +++ b/packages/core/constants/index.ts @@ -56,5 +56,3 @@ export const THUMBNAIL_SIZE_TO_NUM_COLUMNS = { LARGE: 5, SMALL: 10, }; - -export const RENDERABLE_IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".gif"]; diff --git a/packages/core/entity/Annotation/test/annotation.test.ts b/packages/core/entity/Annotation/test/annotation.test.ts index f127531fe..ee0681c32 100644 --- a/packages/core/entity/Annotation/test/annotation.test.ts +++ b/packages/core/entity/Annotation/test/annotation.test.ts @@ -3,13 +3,14 @@ import { compact, find } from "lodash"; import Annotation, { AnnotationName } from "../"; import { AnnotationType } from "../../AnnotationFormatter"; +import dateTimeFormatter from "../../AnnotationFormatter/date-time-formatter"; import { TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants"; import FileDetail from "../../FileDetail"; describe("Annotation", () => { const annotationResponse = Object.freeze({ annotationDisplayName: "Date uploaded", - annotationName: "someDateAnnotation", + annotationName: AnnotationName.UPLOADED, description: "Date the file was uploaded", type: AnnotationType.DATETIME, }); @@ -63,6 +64,7 @@ describe("Annotation", () => { describe("extractFromFile", () => { it("gets the display value for a top-level annotation it represents from a given FmsFile", () => { + const uploaded = new Date().toISOString(); const fmsFile = new FileDetail({ annotations: [ { @@ -76,14 +78,23 @@ describe("Annotation", () => { file_size: 1, thumbnail: "https://s3-us-west-2.amazonaws.com/production.imsc-visual-essay.allencell.org/assets/Cell-grid-images-144ppi/ACTB_Interphase.png", - uploaded: new Date().toISOString(), + uploaded, }); const annotation = new Annotation(annotationResponse); - expect(annotation.extractFromFile(fmsFile)).to.equal("5/17/2019, 12:43:55 AM"); + expect(annotation.extractFromFile(fmsFile)).to.equal( + dateTimeFormatter.displayValue(uploaded) + ); }); it("gets the display value for a non-top-level annotation it represents from a given FmsFile", () => { + const someDateAnnotation = { + annotationDisplayName: "Some Date", + annotationName: "someDateAnnotation", + description: "Some date", + type: AnnotationType.DATETIME, + }; + const fmsFile = new FileDetail({ annotations: [ { @@ -100,11 +111,18 @@ describe("Annotation", () => { uploaded: new Date().toISOString(), }); - const annotation = new Annotation(annotationResponse); + const annotation = new Annotation(someDateAnnotation); expect(annotation.extractFromFile(fmsFile)).to.equal("5/17/2019, 12:43:55 AM"); }); it("returns a MISSING_VALUE sentinel if the given FmsFile does not have the annotation", () => { + const missingAnnotation = { + annotationDisplayName: "Nothing Here", + annotationName: "Nothing Here", + description: "Nothing Here", + type: AnnotationType.STRING, + }; + const fmsFile = new FileDetail({ annotations: [ { @@ -129,7 +147,7 @@ describe("Annotation", () => { uploaded: new Date().toISOString(), }); - const annotation = new Annotation(annotationResponse); + const annotation = new Annotation(missingAnnotation); expect(annotation.extractFromFile(fmsFile)).to.equal(Annotation.MISSING_VALUE); }); }); diff --git a/packages/core/entity/FileDetail/index.ts b/packages/core/entity/FileDetail/index.ts index b95c093d2..0b3794b9f 100644 --- a/packages/core/entity/FileDetail/index.ts +++ b/packages/core/entity/FileDetail/index.ts @@ -1,6 +1,7 @@ -import { RENDERABLE_IMAGE_FORMATS } from "../../constants"; import { FmsFileAnnotation } from "../../services/FileService"; +const RENDERABLE_IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".gif"]; + /** * Expected JSON response of a file detail returned from the query service. Example: * { @@ -135,9 +136,8 @@ export default class FileDetail { // If no thumbnail present try to render the file itself as the thumbnail if (!thumbnailPath) { - const fileExtension = this.name.toLowerCase(); const isFileRenderableImage = RENDERABLE_IMAGE_FORMATS.some((format) => - fileExtension.endsWith(format) + this.name.toLowerCase().endsWith(format) ); if (isFileRenderableImage) { thumbnailPath = this.path; @@ -149,6 +149,6 @@ export default class FileDetail { if (thumbnailPath?.startsWith("/allen")) { return `http://aics.corp.alleninstitute.org/labkey/fmsfiles/image${thumbnailPath}`; } - return this.thumbnail; + return thumbnailPath; } } diff --git a/packages/core/entity/FileExplorerURL/index.ts b/packages/core/entity/FileExplorerURL/index.ts index 998f105a9..06ce0da0b 100644 --- a/packages/core/entity/FileExplorerURL/index.ts +++ b/packages/core/entity/FileExplorerURL/index.ts @@ -1,13 +1,13 @@ import { isObject } from "lodash"; -import Annotation, { AnnotationName } from "../Annotation"; +import { AnnotationName } from "../Annotation"; import FileFilter, { FileFilterJson } from "../FileFilter"; import FileFolder from "../FileFolder"; import { AnnotationValue } from "../../services/AnnotationService"; import { ValueError } from "../../errors"; import FileSort, { SortOrder } from "../FileSort"; -interface Collection { +export interface Collection { name: string; version?: number; uri?: string; @@ -15,7 +15,7 @@ interface Collection { // Components of the application state this captures export interface FileExplorerURLComponents { - hierarchy: Annotation[]; + hierarchy: string[]; collection?: Collection; filters: FileFilter[]; openFolders: FileFolder[]; @@ -74,7 +74,7 @@ export default class FileExplorerURL { * without breaking an existing FileExplorerURL. * */ public static encode(urlComponents: Partial) { - const groupBy = urlComponents.hierarchy?.map((annotation) => annotation.name) || []; + const groupBy = urlComponents.hierarchy?.map((annotation) => annotation) || []; const filters = urlComponents.filters?.map((filter) => filter.toJSON()) || []; const openFolders = urlComponents.openFolders?.map((folder) => folder.fileFolder) || []; const sort = urlComponents.sortColumn @@ -104,7 +104,7 @@ export default class FileExplorerURL { * Decode a previously encoded FileExplorerURL into components that can be rehydrated into the * application state */ - public static decode(encodedURL: string, annotations: Annotation[]): FileExplorerURLComponents { + public static decode(encodedURL: string): FileExplorerURLComponents { const trimmedEncodedURL = encodedURL.trim(); if (!trimmedEncodedURL.startsWith(FileExplorerURL.PROTOCOL)) { throw new ValueError( @@ -140,17 +140,8 @@ export default class FileExplorerURL { } const hierarchyDepth = parsedURL.groupBy.length; - const annotationNameSet = new Set(annotations.map((annotation) => annotation.name)); return { - hierarchy: parsedURL.groupBy.map((annotationName) => { - if (!annotationNameSet.has(annotationName)) { - throw new ValueError( - `Unable to decode FileExplorerURL, couldn't find Annotation(${annotationName})` - ); - } - const matchingAnnotation = annotations.filter((a) => a.name === annotationName)[0]; - return matchingAnnotation; - }), + hierarchy: parsedURL.groupBy, collection: parsedURL.collection, filters: parsedURL.filters.map((filter) => { return new FileFilter(filter.name, filter.value); diff --git a/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts b/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts index db456374f..1dc2a53cd 100644 --- a/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts +++ b/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts @@ -2,7 +2,7 @@ import { expect } from "chai"; import FileExplorerURL, { FileExplorerURLComponents } from ".."; import { Dataset } from "../../../services/DatasetService"; -import Annotation, { AnnotationName } from "../../Annotation"; +import { AnnotationName } from "../../Annotation"; import FileFilter from "../../FileFilter"; import FileFolder from "../../FileFolder"; import FileSort, { SortOrder } from "../../FileSort"; @@ -39,15 +39,7 @@ describe("FileExplorerURL", () => { order: SortOrder.DESC, }; const components: FileExplorerURLComponents = { - hierarchy: expectedAnnotationNames.map( - (annotationName) => - new Annotation({ - annotationName, - annotationDisplayName: "test-display-name", - description: "test-description", - type: "Date", - }) - ), + hierarchy: expectedAnnotationNames, filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)), openFolders: expectedOpenFolders.map((folder) => new FileFolder(folder)), sortColumn: new FileSort(AnnotationName.FILE_SIZE, SortOrder.DESC), @@ -113,25 +105,8 @@ describe("FileExplorerURL", () => { ["3500000654", "ACTB-mEGFP", false], ["3500000654", "ACTB-mEGFP", true], ]; - const hierarchy = expectedAnnotationNames.map( - (annotationName) => - new Annotation({ - annotationName, - annotationDisplayName: "test-display-name", - description: "test-description", - type: "Date", - }) - ); - const annotations = hierarchy.concat([ - new Annotation({ - annotationName: "Cas9", - annotationDisplayName: "test-display-name", - description: "test-description", - type: "Date", - }), - ]); const components: FileExplorerURLComponents = { - hierarchy, + hierarchy: expectedAnnotationNames, filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)), openFolders: expectedOpenFolders.map((folder) => new FileFolder(folder)), sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC), @@ -144,7 +119,7 @@ describe("FileExplorerURL", () => { const encodedUrlWithWhitespace = " " + encodedUrl + " "; // Act - const result = FileExplorerURL.decode(encodedUrlWithWhitespace, annotations); + const result = FileExplorerURL.decode(encodedUrlWithWhitespace); // Assert expect(result).to.be.deep.equal(components); @@ -162,7 +137,7 @@ describe("FileExplorerURL", () => { const encodedUrl = FileExplorerURL.encode(components); // Act - const result = FileExplorerURL.decode(encodedUrl, []); + const result = FileExplorerURL.decode(encodedUrl); // Assert expect(result).to.be.deep.equal(components); @@ -180,126 +155,20 @@ describe("FileExplorerURL", () => { ); // Act / Assert - expect(() => FileExplorerURL.decode(encodedUrl, [])).to.throw(); - }); - - it("Throws error for urls with annotations outside of list of annotations", () => { - // Arrange - const components: FileExplorerURLComponents = { - hierarchy: [ - new Annotation({ - annotationName: "Cell Line", - annotationDisplayName: "Cell Line", - description: "Cell Line Annotation", - type: "Text", - }), - ], - filters: [], - openFolders: [], - }; - const annotations = [ - new Annotation({ - annotationName: "Cas9", - annotationDisplayName: "Cas9", - description: "Cas9 description", - type: "Text", - }), - ]; - const encodedUrl = FileExplorerURL.encode(components); - - // Act / Assert - expect(() => FileExplorerURL.decode(encodedUrl, annotations)).to.throw(); - }); - - it("Throws error for filters with annotations outside of list of annotations", () => { - // Arrange - const components: FileExplorerURLComponents = { - hierarchy: [], - filters: [new FileFilter("Cas9", "spCas9")], - openFolders: [], - }; - const annotations = [ - new Annotation({ - annotationName: "Cell Line", - annotationDisplayName: "Cell Line", - description: "Cell Line description", - type: "Text", - }), - ]; - const encodedUrl = FileExplorerURL.encode(components); - - // Act / Assert - expect(() => FileExplorerURL.decode(encodedUrl, annotations)).to.throw(); + expect(() => FileExplorerURL.decode(encodedUrl)).to.throw(); }); it("Throws error when folder depth is greater than hierarchy depth", () => { // Arrange const components: FileExplorerURLComponents = { - hierarchy: [ - new Annotation({ - annotationName: "Cell Line", - annotationDisplayName: "Cell Line", - description: "Cell Line Description", - type: "Text", - }), - ], + hierarchy: ["Cell Line"], filters: [], openFolders: [new FileFolder(["AICS-0"]), new FileFolder(["AICS-0", false])], }; - const annotations = [ - new Annotation({ - annotationName: "Cell Line", - annotationDisplayName: "Cell Line", - description: "Cell Line description", - type: "Text", - }), - ]; const encodedUrl = FileExplorerURL.encode(components); // Act / Assert - expect(() => FileExplorerURL.decode(encodedUrl, annotations)).to.throw(); - }); - - it("Throws error when sort column is not a file attribute", () => { - // Arrange - const expectedAnnotationNames = ["Plate Barcode", "Donor Plasmid", "Balls?"]; - const expectedFilters = [ - { name: "Cas9", value: "spCas9" }, - { name: "Donor Plasmid", value: "ACTB-mEGFP" }, - ]; - const expectedOpenFolders = [ - ["3500000654"], - ["3500000654", "ACTB-mEGFP"], - ["3500000654", "ACTB-mEGFP", false], - ["3500000654", "ACTB-mEGFP", true], - ]; - const hierarchy = expectedAnnotationNames.map( - (annotationName) => - new Annotation({ - annotationName, - annotationDisplayName: "test-display-name", - description: "test-description", - type: "Date", - }) - ); - const annotations = hierarchy.concat([ - new Annotation({ - annotationName: "Cas9", - annotationDisplayName: "test-display-name", - description: "test-description", - type: "Date", - }), - ]); - const components: FileExplorerURLComponents = { - hierarchy, - filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)), - openFolders: expectedOpenFolders.map((folder) => new FileFolder(folder)), - sortColumn: new FileSort(AnnotationName.KIND, SortOrder.DESC), - }; - const encodedUrl = FileExplorerURL.encode(components); - - // Act / Assert - expect(() => FileExplorerURL.decode(encodedUrl, annotations)).to.throw(); + expect(() => FileExplorerURL.decode(encodedUrl)).to.throw(); }); it("Throws error when sort order is not DESC or ASC", () => { @@ -315,25 +184,8 @@ describe("FileExplorerURL", () => { ["3500000654", "ACTB-mEGFP", false], ["3500000654", "ACTB-mEGFP", true], ]; - const hierarchy = expectedAnnotationNames.map( - (annotationName) => - new Annotation({ - annotationName, - annotationDisplayName: "test-display-name", - description: "test-description", - type: "Date", - }) - ); - const annotations = hierarchy.concat([ - new Annotation({ - annotationName: "Cas9", - annotationDisplayName: "test-display-name", - description: "test-description", - type: "Date", - }), - ]); const components: FileExplorerURLComponents = { - hierarchy, + hierarchy: expectedAnnotationNames, filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)), openFolders: expectedOpenFolders.map((folder) => new FileFolder(folder)), sortColumn: new FileSort(AnnotationName.FILE_PATH, "Garbage" as any), @@ -341,7 +193,7 @@ describe("FileExplorerURL", () => { const encodedUrl = FileExplorerURL.encode(components); // Act / Assert - expect(() => FileExplorerURL.decode(encodedUrl, annotations)).to.throw(); + expect(() => FileExplorerURL.decode(encodedUrl)).to.throw(); }); }); }); diff --git a/packages/core/entity/FileSelection/test/FileSelection.test.ts b/packages/core/entity/FileSelection/test/FileSelection.test.ts index 1817e1064..83581517e 100644 --- a/packages/core/entity/FileSelection/test/FileSelection.test.ts +++ b/packages/core/entity/FileSelection/test/FileSelection.test.ts @@ -9,6 +9,7 @@ import FileSelection, { FocusDirective } from ".."; import FileFilter from "../../FileFilter"; import { IndexError, ValueError } from "../../../errors"; import HttpFileService from "../../../services/FileService/HttpFileService"; +import FileDetail from "../../FileDetail"; describe("FileSelection", () => { describe("select", () => { @@ -345,7 +346,9 @@ describe("FileSelection", () => { queryResult.push(i); } // Due to overfetching the result set we desire is a subsection of query results - const expectedDetails = queryResult.slice(1, 31); + const expectedDetails = queryResult + .slice(1, 31) + .map((detail) => new FileDetail(detail as any)); const httpClient = createMockHttpClient({ when: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=${0}&limit=${31}`, respondWith: { diff --git a/packages/core/entity/FileSet/index.ts b/packages/core/entity/FileSet/index.ts index cb6cc3edd..cb99e38fd 100644 --- a/packages/core/entity/FileSet/index.ts +++ b/packages/core/entity/FileSet/index.ts @@ -47,7 +47,6 @@ export default class FileSet { constructor(opts: Partial = {}) { const { fileService, filters, maxCacheSize, sort } = defaults({}, opts, DEFAULT_OPTS); - // TODO: Can entity be cached...? this.cache = new LRUCache({ max: maxCacheSize }); this._filters = filters; this.sort = sort; diff --git a/packages/core/services/AnnotationService/HttpAnnotationService/test/HttpAnnotationService.test.ts b/packages/core/services/AnnotationService/HttpAnnotationService/test/HttpAnnotationService.test.ts index 337e2da36..5ac7d9111 100644 --- a/packages/core/services/AnnotationService/HttpAnnotationService/test/HttpAnnotationService.test.ts +++ b/packages/core/services/AnnotationService/HttpAnnotationService/test/HttpAnnotationService.test.ts @@ -2,6 +2,7 @@ import { createMockHttpClient } from "@aics/redux-utils"; import { expect } from "chai"; import { spy } from "sinon"; +import { TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../../../constants"; import Annotation from "../../../../entity/Annotation"; import { annotationsJson } from "../../../../entity/Annotation/mocks"; import FileFilter from "../../../../entity/FileFilter"; @@ -22,7 +23,9 @@ describe("HttpAnnotationService", () => { it("issues request for all available Annotations", async () => { const annotationService = new HttpAnnotationService({ baseUrl: "test", httpClient }); const annotations = await annotationService.fetchAnnotations(); - expect(annotations.length).to.equal(annotationsJson.length); + expect(annotations.length).to.equal( + annotationsJson.length + TOP_LEVEL_FILE_ANNOTATION_NAMES.length + ); expect(annotations[0]).to.be.instanceOf(Annotation); }); }); @@ -162,22 +165,25 @@ describe("HttpAnnotationService", () => { describe("fetchAvailableAnnotationsForHierarchy", () => { it("issues request for annotations that can be combined with current hierarchy", async () => { - const expectedValues = ["cell_dead", "date_created"]; + const annotationsFromServer = ["cell_dead", "date_created"]; const httpClient = createMockHttpClient({ when: `test/${HttpAnnotationService.BASE_ANNOTATION_URL}/hierarchy/available?hierarchy=cas9&hierarchy=cell_line`, respondWith: { data: { - data: expectedValues, + data: annotationsFromServer, }, }, }); + const hierarchy = ["cell_line", "cas9"]; + const expectedValues = [ + ...TOP_LEVEL_FILE_ANNOTATION_NAMES, + ...annotationsFromServer, + ...hierarchy, + ]; const annotationService = new HttpAnnotationService({ baseUrl: "test", httpClient }); - const values = await annotationService.fetchAvailableAnnotationsForHierarchy([ - "cell_line", - "cas9", - ]); - expect(values).to.equal(expectedValues); + const values = await annotationService.fetchAvailableAnnotationsForHierarchy(hierarchy); + expect(values.sort()).to.deep.equal(expectedValues.sort()); }); }); }); diff --git a/packages/core/services/FileService/DatabaseFileService/index.ts b/packages/core/services/FileService/DatabaseFileService/index.ts index 92200a6cf..57cb15376 100644 --- a/packages/core/services/FileService/DatabaseFileService/index.ts +++ b/packages/core/services/FileService/DatabaseFileService/index.ts @@ -1,4 +1,4 @@ -import { omit } from "lodash"; +import { isNil, omit } from "lodash"; import FileService, { GetFilesRequest, SelectionAggregationResult } from ".."; import DatabaseService from "../../DatabaseService"; @@ -32,10 +32,9 @@ export default class DatabaseFileService implements FileService { row["File Name"] || filePath.split("\\").pop()?.split("/").pop() || filePath; annotations.push({ name: "File Name", values: [fileName] }); annotations.push({ name: "File ID", values: [row["File ID"] || `${rowNumber}`] }); - if (row["File Size"] !== undefined && row["File Size"] !== null) { + if (!isNil(row["File Size"])) { annotations.push({ name: "File Size", values: [row["File Size"]] }); } - annotations.push({ name: "File Size", values: [row["File ID"] || `${rowNumber}`] }); if (row["Thumbnail"]) { annotations.push({ name: "Thumbnail", values: [row["Thumbnail"]] }); } @@ -78,7 +77,11 @@ export default class DatabaseFileService implements FileService { if (allFiles.length && allFiles[0].size === undefined) { return { count }; } - const size = allFiles.reduce((acc, file) => acc + (file.size || 0), 0); + // TODO: Should have file size return as number not a string + const size = allFiles.reduce( + (acc, file) => acc + parseInt((file.size as any) || "0", 10), + 0 + ); return { count, size }; } diff --git a/packages/core/services/FileService/DatabaseFileService/test/DatabaseFileService.test.ts b/packages/core/services/FileService/DatabaseFileService/test/DatabaseFileService.test.ts index cca191fb8..a6f401430 100644 --- a/packages/core/services/FileService/DatabaseFileService/test/DatabaseFileService.test.ts +++ b/packages/core/services/FileService/DatabaseFileService/test/DatabaseFileService.test.ts @@ -11,8 +11,9 @@ describe("DatabaseFileService", () => { const totalFileSize = 864452; const fileIds = ["abc123", "def456"]; const files = fileIds.map((file_id) => ({ - file_id, - file_size: `${totalFileSize / 2}`, + "File ID": file_id, + "File Size": `${totalFileSize / 2}`, + "File Path": "path/to/file", num_files: "6", })); @@ -34,10 +35,36 @@ describe("DatabaseFileService", () => { }); const data = response; expect(data.length).to.equal(2); - expect(data[0]).to.deep.equal({ - file_id: files[0].file_id, - file_size: totalFileSize / 2, + expect(data[0].details).to.deep.equal({ annotations: [ + { + name: "File Path", + values: ["path/to/file"], + }, + { + name: "File Name", + values: ["file"], + }, + { + name: "File ID", + values: ["abc123"], + }, + { + name: "File Size", + values: ["432226"], + }, + { + name: "File ID", + values: ["abc123"], + }, + { + name: "File Size", + values: ["432226"], + }, + { + name: "File Path", + values: ["path/to/file"], + }, { name: "num_files", values: ["6"], diff --git a/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts b/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts index 1c52b6889..a87d4d41f 100644 --- a/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts +++ b/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts @@ -9,9 +9,9 @@ import NumericRange from "../../../../entity/NumericRange"; describe("HttpFileService", () => { const baseUrl = "test"; const fileIds = ["abc123", "def456", "ghi789", "jkl012"]; - const files = fileIds.map((file_id) => { - file_id; - }); + const files = fileIds.map((file_id) => ({ + file_id, + })); describe("getFiles", () => { const httpClient = createMockHttpClient([ @@ -35,7 +35,7 @@ describe("HttpFileService", () => { }); const data = response; expect(data.length).to.equal(1); - expect(data[0]).to.equal(files[0]); + expect(data[0].id).to.equal(files[0]["file_id"]); }); }); diff --git a/packages/core/state/index.ts b/packages/core/state/index.ts index 87b80de46..9d335adda 100644 --- a/packages/core/state/index.ts +++ b/packages/core/state/index.ts @@ -76,9 +76,7 @@ export function createReduxStore(options: CreateStoreOptions = {}) { }, selection: { displayAnnotations, - selectedQuery: queries?.[0], - // TODO: Enable - queries: [], + queries, }, }); return configureStore({ diff --git a/packages/core/state/interaction/logics.ts b/packages/core/state/interaction/logics.ts index 4a5acd64a..5d13aa15c 100644 --- a/packages/core/state/interaction/logics.ts +++ b/packages/core/state/interaction/logics.ts @@ -229,7 +229,7 @@ const downloadFiles = createLogic({ filesToDownload = await fileSelection.fetchAllDetails(); } - const totalBytesToDownload = sumBy(filesToDownload, "file_size"); + const totalBytesToDownload = sumBy(filesToDownload, "size"); const totalBytesDisplay = numberFormatter.displayValue(totalBytesToDownload, "bytes"); await Promise.all( filesToDownload.map(async (file) => { @@ -457,7 +457,7 @@ const openWithLogic = createLogic({ filesToOpen = await fileSelection.fetchAllDetails(); } const filePaths = await Promise.all( - filesToOpen.map(async (file) => await executionEnvService.formatPathForHost(file.path)) + filesToOpen.map((file) => executionEnvService.formatPathForHost(file.path)) ); // Open the files in the specified executable @@ -522,10 +522,9 @@ const refresh = createLogic({ // Refresh list of annotations & which annotations are available const hierarchy = selection.selectors.getAnnotationHierarchy(getState()); - const annotationNamesInHierachy = hierarchy.map((a) => a.name); const [annotations, availableAnnotations] = await Promise.all([ annotationService.fetchAnnotations(), - annotationService.fetchAvailableAnnotationsForHierarchy(annotationNamesInHierachy), + annotationService.fetchAvailableAnnotationsForHierarchy(hierarchy), ]); dispatch(metadata.actions.receiveAnnotations(annotations)); dispatch(selection.actions.setAvailableAnnotations(availableAnnotations)); diff --git a/packages/core/state/interaction/test/logics.test.ts b/packages/core/state/interaction/test/logics.test.ts index 3e847c39c..a9f2b0027 100644 --- a/packages/core/state/interaction/test/logics.test.ts +++ b/packages/core/state/interaction/test/logics.test.ts @@ -52,6 +52,12 @@ describe("Interaction logics", () => { sortOrder: 0, }); + class FakeFileViewerService implements FileViewerService { + open() { + return Promise.resolve(); + } + } + describe("downloadManifest", () => { const sandbox = createSandbox(); @@ -909,6 +915,7 @@ describe("Interaction logics", () => { const fileKinds = ["PNG", "TIFF"]; for (let i = 0; i <= 100; i++) { files.push({ + file_path: `/allen/file_${i}.ext`, annotations: [ { name: AnnotationName.KIND, @@ -963,6 +970,7 @@ describe("Interaction logics", () => { interaction: { platformDependentServices: { executionEnvService: new UselessExecutionEnvService(), + fileViewerService: new FakeFileViewerService(), notificationService: new UselessNotificationService(), }, }, @@ -1078,6 +1086,7 @@ describe("Interaction logics", () => { platformDependentServices: { ...initialState.interaction.platformDependentServices, executionEnvService: new UselessExecutionEnvService(), + fileViewerService: new FakeFileViewerService(), notificationService: new UselessNotificationService(), }, }, @@ -1120,12 +1129,18 @@ describe("Interaction logics", () => { const csvKind = "CSV"; const csvFiles: Partial[] = []; for (let i = 0; i <= 50; i++) { - csvFiles.push({ annotations: [{ name: AnnotationName.KIND, values: [csvKind] }] }); + csvFiles.push({ + file_path: `/csv${i}.txt`, + annotations: [{ name: AnnotationName.KIND, values: [csvKind] }], + }); } const pngKind = "PNG"; const pngFiles: Partial[] = []; for (let i = 0; i <= 50; i++) { - pngFiles.push({ annotations: [{ name: AnnotationName.KIND, values: [pngKind] }] }); + pngFiles.push({ + file_path: `/png${i}.txt`, + annotations: [{ name: AnnotationName.KIND, values: [pngKind] }], + }); } const files = [...csvFiles, ...pngFiles]; const baseUrl = "test"; @@ -1146,6 +1161,12 @@ describe("Interaction logics", () => { sortOrder: 0, }); + class FakeExecutionEnvService extends ExecutionEnvServiceNoop { + public formatPathForHost(posixPath: string): Promise { + return Promise.resolve(posixPath); + } + } + it("attempts to open selected files with default apps", async () => { const app1 = { defaultFileKinds: [csvKind], @@ -1158,7 +1179,8 @@ describe("Interaction logics", () => { const state = mergeState(initialState, { interaction: { platformDependentServices: { - executionEnvService: new ExecutionEnvServiceNoop(), + executionEnvService: new FakeExecutionEnvService(), + fileViewerService: new FakeFileViewerService(), }, userSelectedApplications: [app1, app2], }, @@ -1181,7 +1203,6 @@ describe("Interaction logics", () => { type: OPEN_WITH, payload: { app: app1, - files: csvFiles, }, }) ).to.be.true; @@ -1199,7 +1220,8 @@ describe("Interaction logics", () => { const state = mergeState(initialState, { interaction: { platformDependentServices: { - executionEnvService: new ExecutionEnvServiceNoop(), + executionEnvService: new FakeExecutionEnvService(), + fileViewerService: new FakeFileViewerService(), }, }, selection: { diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts index 191885ce5..f4d71fda2 100644 --- a/packages/core/state/selection/actions.ts +++ b/packages/core/state/selection/actions.ts @@ -405,13 +405,13 @@ export function removeFromAnnotationHierarchy( export const SET_ANNOTATION_HIERARCHY = makeConstant(STATE_BRANCH_NAME, "set-annotation-hierarchy"); export interface SetAnnotationHierarchyAction { - payload: Annotation[]; + payload: string[]; type: string; } -export function setAnnotationHierarchy(annotations: Annotation[]): SetAnnotationHierarchyAction { +export function setAnnotationHierarchy(annotationNames: string[]): SetAnnotationHierarchyAction { return { - payload: annotations, + payload: annotationNames, type: SET_ANNOTATION_HIERARCHY, }; } diff --git a/packages/core/state/selection/logics.ts b/packages/core/state/selection/logics.ts index 07a75d0f1..d022d9db8 100644 --- a/packages/core/state/selection/logics.ts +++ b/packages/core/state/selection/logics.ts @@ -103,7 +103,7 @@ const modifyAnnotationHierarchy = createLogic({ async process(deps: ReduxLogicDeps, dispatch, done) { const { action, getState, ctx } = deps; const { payload: currentHierarchy } = action as SetAnnotationHierarchyAction; - const existingHierarchy = ctx.existingHierarchy; + const existingHierarchy = ctx.existingHierarchy as string[]; const originalPayload = ctx.originalAction.payload; const existingOpenFileFolders = selectionSelectors.getOpenFileFolders(getState()); @@ -111,9 +111,7 @@ const modifyAnnotationHierarchy = createLogic({ let openFileFolders: FileFolder[]; if (existingHierarchy.length > currentHierarchy.length) { // Determine which index the remove occurred - const indexOfRemoval = existingHierarchy.findIndex( - (a: Annotation) => a.name === originalPayload.id - ); + const indexOfRemoval = existingHierarchy.findIndex((a) => a === originalPayload.id); // Determine the new folders now that an annotation has been removed // removing any that can't be used anymore @@ -131,9 +129,7 @@ const modifyAnnotationHierarchy = createLogic({ const annotationIndexMap = currentHierarchy.reduce( (map, currentAnnotation, newIndex) => ({ ...map, - [newIndex]: existingHierarchy.findIndex( - (a: Annotation) => a.name === currentAnnotation.name - ), + [newIndex]: existingHierarchy.findIndex((a) => a === currentAnnotation), }), {} ); @@ -151,42 +147,34 @@ const modifyAnnotationHierarchy = createLogic({ done(); }, - transform(deps: ReduxLogicDeps, next, reject) { + transform(deps: ReduxLogicDeps, next) { const { action, getState, ctx } = deps; + const { + payload: { id: modifiedAnnotationName }, + } = action as ReorderAnnotationHierarchyAction | RemoveFromAnnotationHierarchyAction; - const allAnnotations = metadata.selectors.getAnnotations(getState()); const existingHierarchy = selectionSelectors.getAnnotationHierarchy(getState()); ctx.existingHierarchy = existingHierarchy; ctx.originalAction = action as | RemoveFromAnnotationHierarchyAction | ReorderAnnotationHierarchyAction; - const annotation = find( - allAnnotations, - (annotation) => annotation.name === action.payload.id - ); - - // Reject the action is the annotation modified is unknown to the state - if (annotation === undefined) { - reject && reject(action); // reject is for some reason typed in react-logic as optional - return; - } - let nextHierarchy: Annotation[]; - if (find(existingHierarchy, (a) => a.name === annotation.name)) { - const removed = existingHierarchy.filter((a) => a.name !== annotation.name); + let nextHierarchy: string[]; + if (find(existingHierarchy, (a) => a === modifiedAnnotationName)) { + const removed = existingHierarchy.filter((a) => a !== modifiedAnnotationName); // if moveTo is defined, change the order // otherwise, remove it from the hierarchy if (action.payload.moveTo !== undefined) { // change order - removed.splice(action.payload.moveTo, 0, annotation); + removed.splice(action.payload.moveTo, 0, modifiedAnnotationName); } nextHierarchy = removed; } else { // add to list nextHierarchy = Array.from(existingHierarchy); - nextHierarchy.splice(action.payload.moveTo, 0, annotation); + nextHierarchy.splice(action.payload.moveTo, 0, modifiedAnnotationName); } next(setAnnotationHierarchy(nextHierarchy)); @@ -197,7 +185,7 @@ const modifyAnnotationHierarchy = createLogic({ const setAvailableAnnotationsLogic = createLogic({ async process(deps: ReduxLogicDeps, dispatch, done) { const { action, httpClient, getState } = deps; - const annotationNamesInHierachy = action.payload.map((a: Annotation) => a.name); + const { payload: annotationHierarchy } = action as SetAnnotationHierarchyAction; const annotationService = interaction.selectors.getAnnotationService(getState()); const applicationVersion = interaction.selectors.getApplicationVersion(getState()); if (annotationService instanceof HttpAnnotationService) { @@ -211,7 +199,7 @@ const setAvailableAnnotationsLogic = createLogic({ dispatch( setAvailableAnnotations( await annotationService.fetchAvailableAnnotationsForHierarchy( - annotationNamesInHierachy + annotationHierarchy ) ) ); @@ -303,11 +291,9 @@ const toggleFileFolderCollapse = createLogic({ const decodeFileExplorerURLLogics = createLogic({ async process(deps: ReduxLogicDeps, dispatch, done) { const encodedURL = deps.action.payload; - const annotations = metadata.selectors.getAnnotations(deps.getState()); const collections = metadata.selectors.getCollections(deps.getState()); const { hierarchy, filters, openFolders, sortColumn, collection } = FileExplorerURL.decode( - encodedURL, - annotations + encodedURL ); let selectedCollection = collections.find( @@ -394,8 +380,7 @@ const selectNearbyFile = createLogic({ filters: sortedOpenFileListPaths[ fileListIndexAboveCurrentFileList ].fileFolder.map( - (filterValue, index) => - new FileFilter(hierarchy[index].displayName, filterValue) + (filterValue, index) => new FileFilter(hierarchy[index], filterValue) ), sort: sortColumn, }); @@ -432,8 +417,7 @@ const selectNearbyFile = createLogic({ filters: sortedOpenFileListPaths[ fileListIndexBelowCurrentFileList ].fileFolder.map( - (filterValue, index) => - new FileFilter(hierarchy[index].displayName, filterValue) + (filterValue, index) => new FileFilter(hierarchy[index], filterValue) ), sort: sortColumn, }); diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts index 1889ebf83..c570be5c1 100644 --- a/packages/core/state/selection/reducer.ts +++ b/packages/core/state/selection/reducer.ts @@ -36,7 +36,7 @@ import Tutorial from "../../entity/Tutorial"; import { Dataset } from "../../services/DatasetService"; export interface SelectionStateBranch { - annotationHierarchy: Annotation[]; + annotationHierarchy: string[]; availableAnnotationsForHierarchy: string[]; availableAnnotationsForHierarchyLoading: boolean; collection?: Dataset; diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts index a8672da67..38c44e98c 100644 --- a/packages/core/state/selection/selectors.ts +++ b/packages/core/state/selection/selectors.ts @@ -4,7 +4,7 @@ import { createSelector } from "reselect"; import { State } from "../"; import Annotation from "../../entity/Annotation"; import FileExplorerURL from "../../entity/FileExplorerURL"; -import FileFilter, { Filter } from "../../entity/FileFilter"; +import FileFilter from "../../entity/FileFilter"; import FileFolder from "../../entity/FileFolder"; import FileSort from "../../entity/FileSort"; import { Dataset } from "../../services/DatasetService"; @@ -36,7 +36,7 @@ export const getQueries = (state: State) => state.selection.queries; export const getEncodedFileExplorerUrl = createSelector( [getAnnotationHierarchy, getFileFilters, getOpenFileFolders, getSortColumn, getCollection], ( - hierarchy: Annotation[], + hierarchy: string[], filters: FileFilter[], openFolders: FileFolder[], sortColumn?: FileSort, @@ -56,15 +56,16 @@ export const getGroupedByFilterName = createSelector( [getFileFilters, getAnnotations], (globalFilters: FileFilter[], annotations: Annotation[]) => { const annotationNameToInstanceMap = keyBy(annotations, "name"); - const filters: Filter[] = map(globalFilters, (filter: FileFilter) => { + const filters = map(globalFilters, (filter: FileFilter) => { const annotation = annotationNameToInstanceMap[filter.name]; return { + displayName: annotation?.displayName, name: filter.name, value: filter.value, displayValue: annotation?.getDisplayValue(filter.value), }; }).filter((filter) => filter.displayValue !== undefined); - return groupBy(filters, (filter) => filter.name); + return groupBy(filters, (filter) => filter.displayName); } ); diff --git a/packages/core/state/selection/test/logics.test.ts b/packages/core/state/selection/test/logics.test.ts index 1b8952b6d..6e4236895 100644 --- a/packages/core/state/selection/test/logics.test.ts +++ b/packages/core/state/selection/test/logics.test.ts @@ -462,7 +462,7 @@ describe("Selection logics", () => { annotations: [...annotations], }, selection: { - annotationHierarchy: annotations.slice(0, 2), + annotationHierarchy: annotations.slice(0, 2).map((a) => a.name), openFileFolders: [], }, }; @@ -479,7 +479,7 @@ describe("Selection logics", () => { expect( actions.includes({ type: SET_ANNOTATION_HIERARCHY, - payload: [...annotations.slice(0, 2), annotations[2]], + payload: [...annotations.slice(0, 2), annotations[2]].map((a) => a.name), }) ).to.be.true; }); @@ -495,10 +495,10 @@ describe("Selection logics", () => { }, selection: { annotationHierarchy: [ - annotations[0], - annotations[1], - annotations[2], - annotations[3], + annotations[0].name, + annotations[1].name, + annotations[2].name, + annotations[3].name, ], openFileFolders: [], }, @@ -516,7 +516,12 @@ describe("Selection logics", () => { expect( actions.includes({ type: SET_ANNOTATION_HIERARCHY, - payload: [annotations[2], annotations[0], annotations[1], annotations[3]], + payload: [ + annotations[2].name, + annotations[0].name, + annotations[1].name, + annotations[3].name, + ], }) ).to.equal(true); }); @@ -526,15 +531,7 @@ describe("Selection logics", () => { // Create new Annotation entities rather than re-use existing // ones to test proper comparison using annotationName - const annotationHierarchy = annotations.slice(0, 4).map( - (a) => - new Annotation({ - annotationDisplayName: a.displayName, - annotationName: a.name, - description: a.description, - type: a.displayName, - }) - ); + const annotationHierarchy = annotations.slice(0, 4).map((a) => a.name); const state = { interaction: { fileExplorerServiceBaseUrl: "test", @@ -615,7 +612,7 @@ describe("Selection logics", () => { annotations: [...annotations], }, selection: { - annotationHierarchy: annotations.slice(0, 2), + annotationHierarchy: annotations.slice(0, 2).map((a) => a.name), openFileFolders: [ new FileFolder(["AICS-0"]), new FileFolder(["AICS-0", "false"]), @@ -650,7 +647,7 @@ describe("Selection logics", () => { annotations: [...annotations], }, selection: { - annotationHierarchy: annotations.slice(0, 3), + annotationHierarchy: annotations.slice(0, 3).map((a) => a.name), openFileFolders: [ new FileFolder(["AICS-0"]), new FileFolder(["AICS-0", "false"]), @@ -733,7 +730,7 @@ describe("Selection logics", () => { }); // Act - store.dispatch(setAnnotationHierarchy(annotations.slice(0, 2))); + store.dispatch(setAnnotationHierarchy(annotations.slice(0, 2).map((a) => a.name))); await logicMiddleware.whenComplete(); // Assert @@ -772,7 +769,7 @@ describe("Selection logics", () => { }); // Act - store.dispatch(setAnnotationHierarchy(annotations.slice(0, 2))); + store.dispatch(setAnnotationHierarchy(annotations.slice(0, 2).map((a) => a.name))); await logicMiddleware.whenComplete(); // Assert @@ -959,7 +956,7 @@ describe("Selection logics", () => { logics: selectionLogics, state, }); - const hierarchy = annotations.slice(0, 2); + const hierarchy = annotations.slice(0, 2).map((a) => a.name); const filters = [new FileFilter(annotations[3].name, "20x")]; const openFolders = [["a"], ["a", false]].map((folder) => new FileFolder(folder)); const sortColumn = new FileSort(AnnotationName.UPLOADED, SortOrder.DESC); diff --git a/packages/core/state/selection/test/reducer.test.ts b/packages/core/state/selection/test/reducer.test.ts index d8fb10c71..0ead3a24c 100644 --- a/packages/core/state/selection/test/reducer.test.ts +++ b/packages/core/state/selection/test/reducer.test.ts @@ -9,7 +9,7 @@ import FileSelection from "../../../entity/FileSelection"; import FileSet from "../../../entity/FileSet"; import NumericRange from "../../../entity/NumericRange"; import FileSort, { SortOrder } from "../../../entity/FileSort"; -import Annotation, { AnnotationName } from "../../../entity/Annotation"; +import { AnnotationName } from "../../../entity/Annotation"; import FileFolder from "../../../entity/FileFolder"; describe("Selection reducer", () => { @@ -51,14 +51,7 @@ describe("Selection reducer", () => { // Arrange const state = { ...selection.initialState, - annotationHierarchy: [ - new Annotation({ - annotationName: "Cell Line", - annotationDisplayName: "Cell Line", - description: "Line of cells", - type: "lookup", - }), - ], + annotationHierarchy: ["Cell Line"], fileSelection: new FileSelection().select({ fileSet: new FileSet(), index: 4, @@ -110,7 +103,7 @@ describe("Selection reducer", () => { ...initialState, selection: nextSelectionState, }) - ).to.deep.equal([TOP_LEVEL_FILE_ANNOTATIONS]); + ).to.deep.equal(TOP_LEVEL_FILE_ANNOTATIONS); }); }); diff --git a/packages/desktop/src/services/PersistentConfigServiceElectron.ts b/packages/desktop/src/services/PersistentConfigServiceElectron.ts index ee3ca6216..e6e399c65 100644 --- a/packages/desktop/src/services/PersistentConfigServiceElectron.ts +++ b/packages/desktop/src/services/PersistentConfigServiceElectron.ts @@ -46,6 +46,20 @@ const OPTIONS: Options> = { [PersistedConfigKeys.ImageJExecutable]: { type: "string", }, + [PersistedConfigKeys.Queries]: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string", + }, + url: { + type: "string", + }, + }, + }, + }, [PersistedConfigKeys.HasUsedApplicationBefore]: { type: "boolean", }, diff --git a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts index ff2d1667f..f0608c11a 100644 --- a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts +++ b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts @@ -45,7 +45,12 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { name: "ZEN", }, ]; - + const expectedQueries = [ + { + name: "foo", + url: "bar", + }, + ]; const expectedDisplayAnnotations = [ { annotationDisplayName: "Foo", @@ -59,6 +64,7 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { service.persist(PersistedConfigKeys.AllenMountPoint, expectedAllenMountPoint); service.persist(PersistedConfigKeys.CsvColumns, expectedCsvColumns); service.persist(PersistedConfigKeys.ImageJExecutable, expectedImageJExecutable); + service.persist(PersistedConfigKeys.Queries, expectedQueries); service.persist( PersistedConfigKeys.HasUsedApplicationBefore, expectedHasUsedApplicationBefore @@ -70,6 +76,7 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { [PersistedConfigKeys.AllenMountPoint]: expectedAllenMountPoint, [PersistedConfigKeys.CsvColumns]: expectedCsvColumns, [PersistedConfigKeys.ImageJExecutable]: expectedImageJExecutable, + [PersistedConfigKeys.Queries]: expectedQueries, [PersistedConfigKeys.HasUsedApplicationBefore]: expectedHasUsedApplicationBefore, [PersistedConfigKeys.UserSelectedApplications]: expectedUserSelectedApps, [PersistedConfigKeys.DisplayAnnotations]: expectedDisplayAnnotations, @@ -91,6 +98,7 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { [PersistedConfigKeys.AllenMountPoint]: "/some/path/to/allen", [PersistedConfigKeys.CsvColumns]: ["a", "b"], [PersistedConfigKeys.ImageJExecutable]: "/my/imagej", + [PersistedConfigKeys.Queries]: [], [PersistedConfigKeys.HasUsedApplicationBefore]: undefined, [PersistedConfigKeys.UserSelectedApplications]: [ {