diff --git a/packages/core/components/AnnotationFilterForm/index.tsx b/packages/core/components/AnnotationFilterForm/index.tsx index fddb21ec0..95a3fb097 100644 --- a/packages/core/components/AnnotationFilterForm/index.tsx +++ b/packages/core/components/AnnotationFilterForm/index.tsx @@ -1,10 +1,14 @@ +import { Spinner, SpinnerSize } from "@fluentui/react"; import { find, isNil } from "lodash"; import * as React from "react"; import { useDispatch, useSelector } from "react-redux"; import { AnnotationType } from "../../entity/AnnotationFormatter"; import FileFilter from "../../entity/FileFilter"; -import ListPicker, { ListItem } from "../../components/ListPicker"; +import ListPicker, { ListItem } from "../ListPicker"; +import NumberRangePicker from "../NumberRangePicker"; +import SearchBoxForm from "../SearchBoxForm"; +import DateRangePicker from "../DateRangePicker"; import { interaction, metadata, selection } from "../../state"; import useAnnotationValues from "./useAnnotationValues"; @@ -20,11 +24,11 @@ interface AnnotationFilterFormProps { */ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { const { annotationName } = props; - const dispatch = useDispatch(); - const annotations = useSelector(metadata.selectors.getAnnotations); - const fileFilters = useSelector(selection.selectors.getAnnotationFilters); + const annotations = useSelector(metadata.selectors.getSupportedAnnotations); + const fileFilters = 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( annotationName, annotationService @@ -35,6 +39,11 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { [annotations, annotationName] ); + const currentValues = React.useMemo( + () => find(fileFilters, (annotation) => annotation.name === annotationName), + [annotationName, fileFilters] + ); + const items = React.useMemo(() => { const appliedFilters = fileFilters .filter((filter) => filter.name === annotation?.name) @@ -89,22 +98,91 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { dispatch(selection.actions.addFileFilter(filters)); }; - // TODO, return different pickers based on annotation type - // e.g., a date picker, a range (numeric) picker, etc. - switch (annotation?.type) { - case AnnotationType.STRING: - // prettier-ignore - default: // FALL-THROUGH - return ( - - ); + function onSearch(filterValue: string) { + if (filterValue && filterValue.trim()) { + const fileFilter = new FileFilter(annotationName, filterValue); + if (currentValues) { + dispatch(selection.actions.removeFileFilter(currentValues)); + } + dispatch(selection.actions.addFileFilter(fileFilter)); + } + } + + function onReset() { + if (currentValues) { + dispatch(selection.actions.removeFileFilter(currentValues)); + } + } + + const listPicker = () => { + return ( + + ); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const customInput = () => { + switch (annotation?.type) { + case AnnotationType.DATE: + case AnnotationType.DATETIME: + return ( + + ); + case AnnotationType.NUMBER: + 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 ( + + ); } + return <> {customInput()} ; } diff --git a/packages/core/components/AnnotationList/index.tsx b/packages/core/components/AnnotationList/index.tsx index db0e15d3d..cfbda9615 100644 --- a/packages/core/components/AnnotationList/index.tsx +++ b/packages/core/components/AnnotationList/index.tsx @@ -45,7 +45,7 @@ const SEARCH_ICON_PATH_DATA = */ export default function AnnotationList(props: AnnotationListProps) { const dispatch = useDispatch(); - const filters = useSelector(selection.selectors.getAnnotationFilters); + const filters = useSelector(selection.selectors.getFileFilters); const annotationsLoading = useSelector( selection.selectors.getAvailableAnnotationsForHierarchyLoading ); @@ -94,7 +94,7 @@ export default function AnnotationList(props: AnnotationListProps) { return (

Available Annotations

-
Drag any annotation to the box above
+
Drag annotations to the box above
", () => { const allAnnotationDisplayNames = annotationsJson.map( (annotation) => annotation.annotationDisplayName ); - expect(queryNumberListItems()).to.equal(allAnnotationDisplayNames.length); + expect(queryNumberListItems()).to.equal( + allAnnotationDisplayNames.length + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length + ); allAnnotationDisplayNames.forEach((annotation) => { expect(getByText(annotation)).to.exist; }); @@ -187,8 +190,15 @@ describe("", () => { it("does not exist when no annotations are filtered", () => { // Arrange + const state = { + ...initialState, + selection: { + ...initialState.selection, + filters: [], + }, + }; const { store } = configureMockStore({ - state: mergeState(initialState, { + state: mergeState(state, { metadata: { annotations: annotationsJson.map( (annotation) => new Annotation(annotation) diff --git a/packages/core/components/AnnotationSidebar/AnnotationFilter.tsx b/packages/core/components/AnnotationSidebar/AnnotationFilter.tsx index 608cb48a6..ae5def6c9 100644 --- a/packages/core/components/AnnotationSidebar/AnnotationFilter.tsx +++ b/packages/core/components/AnnotationSidebar/AnnotationFilter.tsx @@ -24,8 +24,7 @@ const FILTERS_APPLIED_COLOR_INDICATOR = "#0b9aab"; // style guide torquise */ export default function AnnotationFilter(props: FilterProps) { const { annotationName, iconColor, styleOverrides } = props; - - const fileFilters = useSelector(selection.selectors.getAnnotationFilters); + const fileFilters = useSelector(selection.selectors.getFileFilters); const annotationIsFiltered = React.useMemo( () => fileFilters.some((filter) => filter.name === annotationName), @@ -38,7 +37,7 @@ export default function AnnotationFilter(props: FilterProps) { return ; }, directionalHint: DirectionalHint.rightTopEdge, - title: "Exclusively Include", + title: `Filter by ${annotationName}`, shouldFocusOnMount: true, items: [{ key: "placeholder" }], // necessary to have a non-empty items list to have `onRenderMenuList` called }; diff --git a/packages/core/components/AnnotationSidebar/selectors.ts b/packages/core/components/AnnotationSidebar/selectors.ts index 751b950f5..5b8142a0b 100644 --- a/packages/core/components/AnnotationSidebar/selectors.ts +++ b/packages/core/components/AnnotationSidebar/selectors.ts @@ -6,13 +6,14 @@ import { DnDItem } from "../../components/DnDList/DnDList"; import Annotation from "../../entity/Annotation"; import FileFilter from "../../entity/FileFilter"; import { metadata, selection } from "../../state"; +import { TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../constants"; export const getAnnotationListItems = createSelector( [ - metadata.selectors.getSortedAnnotations, + metadata.selectors.getSupportedAnnotations, selection.selectors.getAvailableAnnotationsForHierarchy, selection.selectors.getAnnotationHierarchy, - selection.selectors.getAnnotationFilters, + selection.selectors.getFileFilters, ], ( annotations: Annotation[], @@ -37,6 +38,8 @@ export const getAnnotationListItems = createSelector( filtered: filteredAnnotationNames.has(annotation.name), id: annotation.name, title: annotation.displayName, + type: annotation.type, + isFileProperty: TOP_LEVEL_FILE_ANNOTATION_NAMES.includes(annotation.name), })) // Sort the filtered annotations to the top .sort((a, b) => (a.filtered && !b.filtered ? -1 : 1)) diff --git a/packages/core/components/AnnotationSidebar/test/selectors.test.ts b/packages/core/components/AnnotationSidebar/test/selectors.test.ts index f9c83f703..cc8d69a57 100644 --- a/packages/core/components/AnnotationSidebar/test/selectors.test.ts +++ b/packages/core/components/AnnotationSidebar/test/selectors.test.ts @@ -7,6 +7,7 @@ import { annotationsJson } from "../../../entity/Annotation/mocks"; import * as annotationSelectors from "../selectors"; import { initialState } from "../../../state"; import FileFilter from "../../../entity/FileFilter"; +import { SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants"; describe(" selectors", () => { describe("getAnnotationListItems", () => { @@ -18,9 +19,12 @@ describe(" selectors", () => { }); const listItems = annotationSelectors.getAnnotationListItems(state); - expect(listItems.length).to.equal(annotationsJson.length); + expect(listItems.length).to.equal( + annotationsJson.length + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length + ); - const first = listItems[0]; // items are sorted according to Annotation::sort + // items are sorted according to Annotation::sort but file properties go first + const first = listItems[SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length]; expect(first).to.have.property("id"); expect(first).to.have.property("description", "AICS cell line"); expect(first).to.have.property("title", "Cell line"); @@ -31,17 +35,23 @@ describe(" selectors", () => { new FileFilter("Cell Line", "AICS-0"), new FileFilter("Date Created", "01/10/15"), ]; - const state = mergeState(initialState, { - metadata: { - annotations: map(annotationsJson, (annotation) => new Annotation(annotation)), - }, + const filteredState = { + ...initialState, selection: { + ...initialState.selection, filters, }, + }; + const state = mergeState(filteredState, { + metadata: { + annotations: map(annotationsJson, (annotation) => new Annotation(annotation)), + }, }); const listItems = annotationSelectors.getAnnotationListItems(state); - expect(listItems.length).to.equal(annotationsJson.length); + expect(listItems.length).to.equal( + annotationsJson.length + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length + ); listItems.forEach((item) => { const filtered = filters.findIndex((f) => f.name === item.id) !== -1; @@ -64,7 +74,9 @@ describe(" selectors", () => { }); const listItems = annotationSelectors.getAnnotationListItems(state); - expect(listItems.length).to.equal(annotationsJson.length); + expect(listItems.length).to.equal( + annotationsJson.length + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length + ); listItems.forEach((item) => { const disabled = !availableAnnotationsForHierarchySet.has(item.id); @@ -85,7 +97,9 @@ describe(" selectors", () => { }); const listItems = annotationSelectors.getAnnotationListItems(state); - expect(listItems.length).to.equal(annotationsJson.length); + expect(listItems.length).to.equal( + annotationsJson.length + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.length + ); listItems.forEach((item) => { expect(item).to.have.property("disabled", false); diff --git a/packages/core/components/DateRangePicker/DateRangePicker.module.css b/packages/core/components/DateRangePicker/DateRangePicker.module.css new file mode 100644 index 000000000..5462fa4fe --- /dev/null +++ b/packages/core/components/DateRangePicker/DateRangePicker.module.css @@ -0,0 +1,23 @@ +.filter-input, .date-range-box { + border: none; + flex: auto; +} + +.date-range-box { + display: flex; +} + +.date-range-box { + max-height: 32px; + overflow: hidden; +} + +.date-range-separator { + display: flex; + font-size: small; + padding: 0 2px; +} + +.date-range-separator i { + margin: auto; +} diff --git a/packages/core/components/DateRangePicker/index.tsx b/packages/core/components/DateRangePicker/index.tsx new file mode 100644 index 000000000..e47f01039 --- /dev/null +++ b/packages/core/components/DateRangePicker/index.tsx @@ -0,0 +1,95 @@ +import { DatePicker, Icon, IconButton } from "@fluentui/react"; +import * as React from "react"; +import FileFilter from "../../entity/FileFilter"; +import { DATE_ABSOLUTE_MIN, END_OF_TODAY } from "../../constants"; +import { extractDatesFromRangeOperatorFilterString } from "../../entity/AnnotationFormatter/date-time-formatter"; + +import styles from "./DateRangePicker.module.css"; + +interface DateRangePickerProps { + onSearch: (filterValue: string) => void; + onReset: () => void; + currentRange: FileFilter | undefined; +} + +// Color chosen from App.module.css +const PURPLE_ICON_STYLE = { icon: { color: "#827aa3" } }; + +// Because the datestring comes in as an ISO formatted date like 2021-01-02 +// creating a new date from that would result in a date displayed as the +// day before due to the UTC offset, to account for this we can add in the offset +// ahead of time. +export function extractDateFromDateString(dateString?: string): Date | undefined { + if (!dateString) { + return undefined; + } + const date = new Date(dateString); + date.setMinutes(date.getTimezoneOffset()); + return date; +} + +/** + * This component renders a simple form for selecting a minimum and maximum date range + */ +export default function DateRangePicker(props: DateRangePickerProps) { + const { onSearch, onReset, currentRange } = props; + + function onDateRangeSelection(startDate: Date | null, endDate: Date | null) { + // Derive previous startDate/endDate from current filter state, if possible + const { + startDate: oldStartDate, + endDate: oldEndDate, + } = extractDatesFromRangeOperatorFilterString(currentRange?.value); + if (oldEndDate) { + // The RANGE() filter uses an exclusive upper bound. + // However, we want to present dates in the UI as if the upper bound was inclusive. + // To handle that, we subtract a day from the upper bound used by the filter, then present the result + oldEndDate.setDate(oldEndDate.getDate() - 1); + } + const newStartDate = startDate || oldStartDate || DATE_ABSOLUTE_MIN; + const newEndDate = endDate || oldEndDate || END_OF_TODAY; + if (newStartDate && newEndDate) { + // Add 1 day to endDate to account for RANGE() filter upper bound exclusivity + const newEndDatePlusOne = new Date(newEndDate); + newEndDatePlusOne.setDate(newEndDatePlusOne.getDate() + 1); + onSearch(`RANGE(${newStartDate.toISOString()},${newEndDatePlusOne.toISOString()})`); + } + } + const { startDate, endDate } = extractDatesFromRangeOperatorFilterString(currentRange?.value); + if (endDate) { + // Subtract 1 day to endDate to account for RANGE() filter upper bound exclusivity + endDate.setDate(endDate.getDate() - 1); + } + return ( + + (v ? onDateRangeSelection(v, null) : onReset())} + styles={PURPLE_ICON_STYLE} + value={extractDateFromDateString(startDate?.toISOString())} + /> +
+ +
+ (v ? onDateRangeSelection(null, v) : onReset())} + styles={PURPLE_ICON_STYLE} + value={extractDateFromDateString(endDate?.toISOString())} + /> + +
+ ); +} diff --git a/packages/core/components/DateRangePicker/test/DateRangePicker.test.tsx b/packages/core/components/DateRangePicker/test/DateRangePicker.test.tsx new file mode 100644 index 000000000..67bf08d74 --- /dev/null +++ b/packages/core/components/DateRangePicker/test/DateRangePicker.test.tsx @@ -0,0 +1,69 @@ +import { render, fireEvent } from "@testing-library/react"; +import { expect } from "chai"; +import { noop } from "lodash"; +import * as React from "react"; +import sinon from "sinon"; + +import DateRangePicker from ".."; +import FileFilter from "../../../entity/FileFilter"; + +describe("", () => { + it("renders inputs for start and end dates with selectable date pickers", () => { + // Arrange + const onSearch = sinon.spy(); + const { getAllByLabelText, getAllByRole, getByLabelText, getByRole } = render( + + ); + + // Should render both input fields + expect(getAllByRole("combobox").length).to.equal(2); + expect(getAllByLabelText(/start/).length).to.equal(1); + expect(getAllByLabelText(/end/).length).to.equal(1); + + // Select a start date + expect(onSearch.called).to.equal(false); + fireEvent.click(getByLabelText(/start/)); + fireEvent.click(getByRole("button", { name: /^18,\s/ })); + expect(onSearch.called).to.equal(true); + + // Reset spy to isolate assertions) + onSearch.resetHistory(); + + // Select an end date + expect(onSearch.called).to.equal(false); + fireEvent.click(getByLabelText(/end/)); + fireEvent.click(getByRole("button", { name: /^20,\s/ })); + expect(onSearch.called).to.equal(true); + }); + + it("initializes to values passed through props if provided", () => { + // Arrange + const currentRange = new FileFilter( + "date", + `RANGE(2024-02-21T00:00:00.000Z,2024-03-21T00:00:00.000Z)` + ); + const { getByText } = render( + + ); + + expect(getByText("Wed Feb 21 2024")).to.exist; + // We currently subtract a day to account for exclusive upper date range + expect(getByText("Wed Mar 20 2024")).to.exist; + }); + + it("renders a 'Clear' button if given a callback", () => { + // Arrange + const onSearch = noop; + const onReset = sinon.spy(); + + // Act / Assert + const { getByTitle } = render( + + ); + + // Hit reset + expect(onReset.called).to.equal(false); + fireEvent.click(getByTitle("Clear")); + expect(onReset.called).to.equal(true); + }); +}); diff --git a/packages/core/components/DirectoryTree/index.tsx b/packages/core/components/DirectoryTree/index.tsx index 504e04de5..707eaee94 100644 --- a/packages/core/components/DirectoryTree/index.tsx +++ b/packages/core/components/DirectoryTree/index.tsx @@ -9,7 +9,6 @@ import FileSet from "../../entity/FileSet"; import Tutorial from "../../entity/Tutorial"; import RootLoadingIndicator from "./RootLoadingIndicator"; import useDirectoryHierarchy from "./useDirectoryHierarchy"; -import FileMetadataSearchBar from "../FileMetadataSearchBar"; import EmptyFileListMessage from "../EmptyFileListMessage"; import styles from "./DirectoryTree.module.css"; @@ -78,7 +77,6 @@ export default function DirectoryTree(props: FileListProps) {
-
    {items.reduce((accum, item, index) => { - const disabled = item.disabled; + const isDragDisabled = item.disabled || item?.isFileProperty; return [ ...accum, ...(dividers && dividers[index] ? [dividers[index]] : []), @@ -65,7 +67,7 @@ export default function DnDList(props: DnDListProps) { key={item.id} draggableId={JSON.stringify({ sourceId: id, itemId: item.id })} index={index} - isDragDisabled={disabled} + isDragDisabled={isDragDisabled} > {(draggableProps, draggableState) => ( <> diff --git a/packages/core/components/EmptyFileListMessage/FilterList.tsx b/packages/core/components/EmptyFileListMessage/FilterList.tsx index 34535f1a6..748f897b3 100644 --- a/packages/core/components/EmptyFileListMessage/FilterList.tsx +++ b/packages/core/components/EmptyFileListMessage/FilterList.tsx @@ -37,7 +37,10 @@ export default function FilterList(props: Props) { } }, [textRef, filters]); - const operator = filters.length > 1 ? "for values of" : "equal to"; + const firstFilterValue = filters[0].value.toString(); + let operator = "equal to"; + if (filters.length > 1) operator = "for values of"; + else if (firstFilterValue.includes("RANGE")) operator = "between"; const valueDisplay = map(filters, (filter) => filter.displayValue).join(", "); const display = ` ${operator} ${valueDisplay}`; diff --git a/packages/core/components/FileList/FileListColumnPicker.tsx b/packages/core/components/FileList/FileListColumnPicker.tsx index 6d2494cab..9a4e7a0d9 100644 --- a/packages/core/components/FileList/FileListColumnPicker.tsx +++ b/packages/core/components/FileList/FileListColumnPicker.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { useSelector, useDispatch } from "react-redux"; -import ListPicker, { ListItem } from "../../components/ListPicker"; +import ListPicker, { ListItem } from "../ListPicker"; import { metadata, selection } from "../../state"; /** diff --git a/packages/core/components/FileMetadataSearchBar/FileMetadataSearchBar.module.css b/packages/core/components/FileMetadataSearchBar/FileMetadataSearchBar.module.css deleted file mode 100644 index 275d5713c..000000000 --- a/packages/core/components/FileMetadataSearchBar/FileMetadataSearchBar.module.css +++ /dev/null @@ -1,35 +0,0 @@ -.search-bar-container { - border: solid black 1px; - border-radius: 3px; - display: flex; - margin-bottom: 5px; -} - -.filter-input, .search-box-selector, .date-range-box { - border: none; - flex: auto; -} - -.search-box-selector, .date-range-box { - display: flex; -} - -.file-attribute-selector { - width: 100%; - max-width: 185px; -} - -.date-range-box { - max-height: 32px; - overflow: hidden; -} - -.date-range-separator { - display: flex; - font-size: small; - padding: 0 2px; -} - -.date-range-separator i { - margin: auto; -} diff --git a/packages/core/components/FileMetadataSearchBar/index.tsx b/packages/core/components/FileMetadataSearchBar/index.tsx deleted file mode 100644 index 70a398cf4..000000000 --- a/packages/core/components/FileMetadataSearchBar/index.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { - DatePicker, - Dropdown, - Icon, - IconButton, - IDropdownOption, - SearchBox, -} from "@fluentui/react"; -import * as React from "react"; -import { useDispatch, useSelector } from "react-redux"; - -import { AnnotationName, TOP_LEVEL_FILE_ANNOTATIONS } from "../../constants"; -import { AnnotationType } from "../../entity/AnnotationFormatter"; -import FileFilter from "../../entity/FileFilter"; -import Tutorial from "../../entity/Tutorial"; -import { selection } from "../../state"; -import { getFileAttributeFilter } from "../../state/selection/selectors"; - -import styles from "./FileMetadataSearchBar.module.css"; - -const FILE_ATTRIBUTE_OPTIONS = TOP_LEVEL_FILE_ANNOTATIONS.filter( - (a) => a.name !== AnnotationName.FILE_SIZE -).map((a) => ({ - key: a.name, - text: a.displayName, -})); -const FILE_NAME_OPTION = FILE_ATTRIBUTE_OPTIONS.find( - (o) => o.key === AnnotationName.FILE_NAME -) as IDropdownOption; -// Color chosen from App.module.css -const PURPLE_ICON_STYLE = { icon: { color: "#827aa3" } }; - -// Because the datestring comes in as an ISO formatted date like 2021-01-02 -// creating a new date from that would result in a date displayed as the -// day before due to the UTC offset, to account for this we can add in the offset -// ahead of time. -export function extractDateFromDateString(dateString?: string): Date | undefined { - if (!dateString) { - return undefined; - } - const date = new Date(dateString); - date.setMinutes(date.getTimezoneOffset()); - return date; -} - -function extractDatesFromRangeOperatorFilterString( - filterString: string -): { startDate: Date; endDate: Date } | null { - // Regex with capture groups for identifying ISO datestrings in the RANGE() filter operator - // e.g. RANGE(2022-01-01T00:00:00.000Z,2022-01-31T00:00:00.000Z) - // Captures "2022-01-01T00:00:00.000Z" and "2022-01-31T00:00:00.000Z" - const RANGE_OPERATOR_REGEX = /RANGE\(([\d\-:TZ.]+),([\d\-:TZ.]+)\)/g; - const exec = RANGE_OPERATOR_REGEX.exec(filterString); - if (exec && exec.length === 3) { - // Length of 3 because we use two capture groups - const startDate = new Date(exec[1]); - // The RANGE() filter uses an exclusive upper bound. - // However, we want to present dates in the UI as if the upper bound was inclusive. - // To handle that, we'll subtract a day from the upper bound used by the filter, then present the result - const endDate = new Date(exec[2]); - endDate.setDate(endDate.getDate() - 1); - - return { startDate, endDate }; - } - return null; -} - -/** - * This component renders a dynamic search bar for querying file records by - * basic file attributes that are otherwise not queryable through usual means like - * in the for example. - */ -export default function FileMetadataSearchBar() { - const dispatch = useDispatch(); - const fileAttributeFilter = useSelector(getFileAttributeFilter); - const [lastSelectedAttribute, setLastSelectedAttribute] = React.useState( - FILE_NAME_OPTION - ); - const selectedAttribute = - FILE_ATTRIBUTE_OPTIONS.find((a) => a.key === fileAttributeFilter?.name) || - lastSelectedAttribute; - - function onResetSearch() { - if (fileAttributeFilter) { - dispatch(selection.actions.removeFileFilter(fileAttributeFilter)); - } - } - - function onSearch(filterValue: string) { - if (filterValue && filterValue.trim()) { - const fileFilter = new FileFilter(selectedAttribute.key as string, filterValue); - if (fileAttributeFilter) { - dispatch(selection.actions.removeFileFilter(fileAttributeFilter)); - } - dispatch(selection.actions.addFileFilter(fileFilter)); - } - } - - function onDateRangeSelection(startDate: Date | null, endDate: Date | null) { - // Derive previous startDate/endDate from current filter state, if possible - let oldStartDate; - let oldEndDate; - const splitFileAttributeFilter = extractDatesFromRangeOperatorFilterString( - fileAttributeFilter?.value - ); - if (splitFileAttributeFilter !== null) { - oldStartDate = splitFileAttributeFilter.startDate; - oldEndDate = splitFileAttributeFilter.endDate; - } - - const newStartDate = startDate || oldStartDate || endDate; - const newEndDate = endDate || oldEndDate || startDate; - if (newStartDate && newEndDate) { - // Add 1 day to endDate to account for RANGE() filter upper bound exclusivity - const newEndDatePlusOne = new Date(newEndDate); - newEndDatePlusOne.setDate(newEndDatePlusOne.getDate() + 1); - onSearch(`RANGE(${newStartDate.toISOString()},${newEndDatePlusOne.toISOString()})`); - } - } - - function onAttributeSelection(_: React.FormEvent, option?: IDropdownOption) { - onResetSearch(); - if (option) { - setLastSelectedAttribute(option); - } - } - - let searchBox: React.ReactNode; - const annotation = TOP_LEVEL_FILE_ANNOTATIONS.find((a) => a.name === selectedAttribute.key); - if (annotation?.type === AnnotationType.DATETIME) { - let startDate; - let endDate; - const splitDates = extractDatesFromRangeOperatorFilterString(fileAttributeFilter?.value); - if (splitDates !== null) { - startDate = splitDates.startDate; - endDate = splitDates.endDate; - } - - searchBox = ( - - (v ? onDateRangeSelection(v, null) : onResetSearch())} - styles={PURPLE_ICON_STYLE} - value={extractDateFromDateString(startDate?.toISOString())} - /> -
    - -
    - (v ? onDateRangeSelection(null, v) : onResetSearch())} - styles={PURPLE_ICON_STYLE} - value={extractDateFromDateString(endDate?.toISOString())} - /> - -
    - ); - } else { - searchBox = ( - - ); - } - - return ( -
    - - {searchBox} -
    - ); -} diff --git a/packages/core/components/FileMetadataSearchBar/test/FileMetadataSearchBar.test.tsx b/packages/core/components/FileMetadataSearchBar/test/FileMetadataSearchBar.test.tsx deleted file mode 100644 index 86438ae00..000000000 --- a/packages/core/components/FileMetadataSearchBar/test/FileMetadataSearchBar.test.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { configureMockStore } from "@aics/redux-utils"; -import { fireEvent, render } from "@testing-library/react"; -import { expect } from "chai"; -import * as React from "react"; -import { Provider } from "react-redux"; - -import FileMetadataSearchBar, { extractDateFromDateString } from "../"; -import FileFilter from "../../../entity/FileFilter"; -import { AnnotationName, TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants"; -import { initialState, reducer, reduxLogics, selection } from "../../../state"; - -describe("", () => { - const ENTER_KEY = { keyCode: 13 }; - - it("submits default file attribute when input is typed and submitted with no filters applied", async () => { - // Arrange - const state = { - ...initialState, - selection: { - filters: [], - }, - }; - const { actions, logicMiddleware, store } = configureMockStore({ state }); - const { getByRole } = render( - - - - ); - const searchQuery = "21304104.czi"; - - // Act - fireEvent.change(getByRole("searchbox"), { target: { value: searchQuery } }); - fireEvent.keyDown(getByRole("searchbox"), ENTER_KEY); - await logicMiddleware.whenComplete(); - - // Assert - expect( - actions.includesMatch( - selection.actions.addFileFilter( - new FileFilter(AnnotationName.FILE_NAME, searchQuery) - ) - ) - ).to.be.true; - }); - - it("renders with past year date filter by default", async () => { - const { store } = configureMockStore({ state: initialState }); - const dateUpper = new Date(); - const dateLower = new Date(); - const upperYear = dateUpper.getFullYear(); - dateLower.setFullYear(upperYear - 1); - const upperDateString = dateUpper.toDateString(); - const lowerDateString = dateLower.toDateString(); - const { getByText } = render( - - - - ); - const uploadedDisplayName = - TOP_LEVEL_FILE_ANNOTATIONS.find((a) => a.name === AnnotationName.UPLOADED) - ?.displayName || ""; - expect(getByText(uploadedDisplayName)).to.not.be.empty; - expect(getByText(upperDateString)).to.not.be.empty; - expect(getByText(lowerDateString)).to.not.be.empty; - }); - - it("submits newly chosen file attribute when input is typed and submitted", async () => { - // Arrange - const { actions, logicMiddleware, store } = configureMockStore({ - state: initialState, - reducer, - logics: reduxLogics, - }); - const { getByRole, getByText } = render( - - - - ); - const searchQuery = "21304404.czi"; - - // Act - fireEvent.click(getByText("Uploaded")); - fireEvent.click(getByText("File ID")); - fireEvent.change(getByRole("searchbox"), { target: { value: searchQuery } }); - fireEvent.keyDown(getByRole("searchbox"), ENTER_KEY); - await logicMiddleware.whenComplete(); - - // Assert - expect( - actions.includesMatch( - selection.actions.setFileFilters([ - new FileFilter(AnnotationName.FILE_ID, searchQuery), - ]) - ) - ).to.be.true; - }); - - it("loads text search bar with filter from URL", () => { - // Arrange - const thumbnailDisplayName = - TOP_LEVEL_FILE_ANNOTATIONS.find((a) => a.name === AnnotationName.THUMBNAIL_PATH) - ?.displayName || ""; - const filter = new FileFilter(AnnotationName.THUMBNAIL_PATH, "/my/thumbnail.png"); - const state = { - ...initialState, - selection: { - filters: [filter], - }, - }; - const { store } = configureMockStore({ state }); - const { getByText, getByDisplayValue } = render( - - - - ); - - // Assert - expect(getByText(thumbnailDisplayName)).to.not.be.empty; - expect(getByDisplayValue(filter.value)).to.not.be.empty; - }); - - describe("load date search bar with filter from URL", () => { - [ - ["2022-01-01", "2022-02-01", "Sat Jan 01 2022", "Mon Jan 31 2022"], - ["2022-01-01", "2022-01-31", "Sat Jan 01 2022", "Sun Jan 30 2022"], - ["2022-01-01", "2022-02-02", "Sat Jan 01 2022", "Tue Feb 01 2022"], - ["2022-01-31", "2022-01-02", "Mon Jan 31 2022", "Sat Jan 01 2022"], - ].forEach(([dateLowerBound, dateUpperBound, expectedDate1, expectedDate2]) => { - it(`loads correct dates for filter "RANGE(${dateLowerBound},{${dateUpperBound})"`, () => { - // Arrange - const uploadedDisplayName = - TOP_LEVEL_FILE_ANNOTATIONS.find((a) => a.name === AnnotationName.UPLOADED) - ?.displayName || ""; - const dateLowerAsDate = new Date(dateLowerBound); - const dateUpperAsDate = new Date(dateUpperBound); - const dateLowerISO = dateLowerAsDate.toISOString(); - const dateUpperISO = dateUpperAsDate.toISOString(); - const filter = new FileFilter( - AnnotationName.UPLOADED, - `RANGE(${dateLowerISO},${dateUpperISO})` - ); - const state = { - ...initialState, - selection: { - filters: [filter], - }, - }; - const { store } = configureMockStore({ state }); - const { getByText } = render( - - - - ); - - // Assert - expect(getByText(uploadedDisplayName)).to.not.be.empty; - expect(getByText(expectedDate1)).to.not.be.empty; - expect(getByText(expectedDate2)).to.not.be.empty; - }); - }); - }); - - it("creates RANGE() file filter of RANGE(day,day+1) when only start date is selected", async () => { - // Arrange - const { actions, logicMiddleware, store } = configureMockStore({ - state: initialState, - reducer, - logics: reduxLogics, - }); - const { getByText, getAllByRole, getByRole } = render( - - - - ); - const startDate = new Date(); - const day = 15; // Arbitrary day of the month that won't show up twice in the Calendar popup - startDate.setDate(day); - startDate.setHours(0); - startDate.setMinutes(0); - startDate.setSeconds(0); - startDate.setMilliseconds(0); - const endDate = new Date(startDate); - endDate.setDate(endDate.getDate() + 1); - const expectedRange = `RANGE(${startDate.toISOString()},${endDate.toISOString()})`; - - // Act - const dropdownComponent = getAllByRole("combobox").at(0) as HTMLElement; - fireEvent.click(dropdownComponent); - fireEvent.click(getByRole("option", { name: "Uploaded" })); - fireEvent.click(getByText("Start of date range")); - fireEvent.click(getByText(day)); - await logicMiddleware.whenComplete(); - - // Assert - expect( - actions.includesMatch( - selection.actions.setFileFilters([ - new FileFilter(AnnotationName.UPLOADED, expectedRange), - ]) - ) - ).to.be.true; - }); - - describe("extractDateFromDateString", () => { - [ - ["2021-02-01", 1612137600000], - ["3030-03-04", 33455721600000], - ["2018-3-4", 1520150400000], - ].forEach(([dateString, expectedInMs]) => { - it(`returns expected point in time as date instance for ${dateString}`, () => { - // Arrange - const expected = new Date(expectedInMs); - expected.setMinutes(expected.getTimezoneOffset()); - - // Act - const actual = extractDateFromDateString(dateString as string); - - // Assert - expect(actual).to.not.be.undefined; - expect(actual?.getFullYear()).to.equal(expected.getFullYear()); - expect(actual?.getMonth()).to.equal(expected.getMonth()); - expect(actual?.getDate()).to.equal(expected.getDate()); - }); - }); - - it("returns undefined when given falsy input", () => { - // Act - const actual = extractDateFromDateString(undefined); - - // Assert - expect(actual).to.be.undefined; - }); - }); -}); diff --git a/packages/core/components/FilterDisplayBar/FilterMedallion.tsx b/packages/core/components/FilterDisplayBar/FilterMedallion.tsx index 26b2d80f4..8c73022b6 100644 --- a/packages/core/components/FilterDisplayBar/FilterMedallion.tsx +++ b/packages/core/components/FilterDisplayBar/FilterMedallion.tsx @@ -63,7 +63,10 @@ export default function FilterMedallion(props: Props) { } }, [textRef, filters]); - const operator = filters.length > 1 ? "ONE OF" : "EQUALS"; + const firstFilterValue = filters[0].value.toString(); + let operator = "EQUALS"; + if (filters.length > 1) operator = "ONE OF"; + else if (firstFilterValue.includes("RANGE")) operator = "BETWEEN"; const valueDisplay = map(filters, (filter) => filter.displayValue).join(", "); const display = `${name} ${operator} ${valueDisplay}`; diff --git a/packages/core/components/FilterDisplayBar/index.tsx b/packages/core/components/FilterDisplayBar/index.tsx index bafad15c0..d8c16a578 100644 --- a/packages/core/components/FilterDisplayBar/index.tsx +++ b/packages/core/components/FilterDisplayBar/index.tsx @@ -21,7 +21,7 @@ interface Props { export default function FilterDisplayBar(props: Props) { const { className, classNameHidden } = props; - const globalFilters = useSelector(selection.selectors.getAnnotationFilters); + const globalFilters = useSelector(selection.selectors.getFileFilters); const groupedByFilterName = useSelector(selection.selectors.getGroupedByFilterName); return ( diff --git a/packages/core/components/FilterDisplayBar/test/FilterDisplayBar.test.tsx b/packages/core/components/FilterDisplayBar/test/FilterDisplayBar.test.tsx index 38bac5241..f6f2f1045 100644 --- a/packages/core/components/FilterDisplayBar/test/FilterDisplayBar.test.tsx +++ b/packages/core/components/FilterDisplayBar/test/FilterDisplayBar.test.tsx @@ -135,7 +135,11 @@ describe("", () => { expect(await findByText(/^Cell Line/)).to.exist; // now you don't - const clearButton = await findByRole("button", { exact: false, name: "Clear" }); + const clearButton = await findByRole("button", { + exact: false, + name: "Clear", + description: /Cell Line/, + }); fireEvent.click(clearButton); expect(() => getByText(/^Cell Line/)).to.throw(); }); diff --git a/packages/core/components/Modal/AnnotationSelector/index.tsx b/packages/core/components/Modal/AnnotationSelector/index.tsx index 4efd70b90..e658b6d34 100644 --- a/packages/core/components/Modal/AnnotationSelector/index.tsx +++ b/packages/core/components/Modal/AnnotationSelector/index.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { useSelector } from "react-redux"; import Annotation from "../../../entity/Annotation"; -import ListPicker, { ListItem } from "../../../components/ListPicker"; +import ListPicker, { ListItem } from "../../ListPicker"; import { metadata } from "../../../state"; import styles from "./AnnotationSelector.module.css"; diff --git a/packages/core/components/NumberRangePicker/NumberRangePicker.module.css b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css new file mode 100644 index 000000000..18e386ed4 --- /dev/null +++ b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css @@ -0,0 +1,65 @@ +.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; +} + +.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; +} + +.action-button { + color: steelblue; + cursor: pointer; + display: flex; + margin: 0.5rem auto 0; +} + +.action-button.disabled { + color: grey; + cursor: not-allowed; +} + +.input-field { + float: left; + margin-left: 10px; +} + +label,input{ + display: block; +} + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; /* <-- Apparently some margin are still there even though it's hidden */ +} \ No newline at end of file diff --git a/packages/core/components/NumberRangePicker/index.tsx b/packages/core/components/NumberRangePicker/index.tsx new file mode 100644 index 000000000..35327d788 --- /dev/null +++ b/packages/core/components/NumberRangePicker/index.tsx @@ -0,0 +1,170 @@ +import { ActionButton, 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"; + +import styles from "./NumberRangePicker.module.css"; + +export interface ListItem { + displayValue: AnnotationValue; + value: AnnotationValue; +} + +interface NumberRangePickerProps { + disabled?: boolean; + errorMessage?: string; + items: ListItem[]; + loading?: boolean; + onSearch: (filterValue: string) => void; + onReset: () => void; + currentRange: FileFilter | undefined; + units?: string; +} + +/** + * A NumberRangePicker is a simple form that renders input fields for the minimum and maximum values + * desired by the user and buttons to submit and reset the range. It also displays the + * overall min and max for that annotation, if available, and allows a user to select that full range. + * + * It is best suited for selecting items that are numbers. + */ +export default function NumberRangePicker(props: NumberRangePickerProps) { + const { errorMessage, items, loading, onSearch, onReset, currentRange, units } = props; + + const overallMin = React.useMemo(() => { + return items[0]?.displayValue.toString() ?? ""; + }, [items]); + const overallMax = React.useMemo(() => { + return items.at(-1)?.displayValue.toString() ?? ""; + }, [items]); + + const [searchMinValue, setSearchMinValue] = React.useState( + extractValuesFromRangeOperatorFilterString(currentRange?.value).minValue ?? overallMin + ); + const [searchMaxValue, setSearchMaxValue] = React.useState( + extractValuesFromRangeOperatorFilterString(currentRange?.value).maxValue ?? overallMax + ); + + function onResetSearch() { + onReset(); + setSearchMinValue(""); + 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, + maxValue: oldMaxValue, + } = extractValuesFromRangeOperatorFilterString(currentRange?.value); + const newMinValue = searchMinValue || oldMinValue || overallMin; + const newMaxValue = searchMaxValue || oldMaxValue || (Number(overallMax) + 1).toString(); // Ensure that actual max is not excluded + if (newMinValue && newMaxValue) { + onSearch(`RANGE(${newMinValue},${newMaxValue})`); + } + }; + + const onMinChange = (event?: React.ChangeEvent) => { + if (event) { + setSearchMinValue(event.target.value); + } + }; + const onMaxChange = (event?: React.ChangeEvent) => { + if (event) { + setSearchMaxValue(event.target.value); + } + }; + + if (errorMessage) { + return
    Whoops! Encountered an error: {errorMessage}
    ; + } + + if (loading) { + return ( +
    + +
    + ); + } + + return ( +
    +
    +
    + + + {units} +
    +
    + + + {units} +
    +
    + + Submit range + + {onSelectFullRange && ( + + Select Full Range + + )} + + Reset + +
    +
    +
    +
    + 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 new file mode 100644 index 000000000..e0957318a --- /dev/null +++ b/packages/core/components/NumberRangePicker/test/NumberRangePicker.test.tsx @@ -0,0 +1,156 @@ +import { render, fireEvent, screen } from "@testing-library/react"; +import { expect } from "chai"; +import { noop } from "lodash"; +import * as React from "react"; +import sinon from "sinon"; + +import NumberRangePicker, { ListItem } from ".."; +import FileFilter from "../../../entity/FileFilter"; + +describe("", () => { + it("renders input fields for min and max values initialized to overall min/max", () => { + // Arrange + const items: ListItem[] = ["0", "20"].map((val) => ({ + displayValue: val, + value: val, + })); + + // Act / Assert + const { getAllByTitle } = render( + + ); + + // Should render both input fields + expect(getAllByTitle(/^Min/).length).to.equal(1); + expect(getAllByTitle(/^Max/).length).to.equal(1); + + // Should initialize to min and max item provided, respectively + expect(screen.getByTitle(/^Min/).value).to.equal("0"); + expect(screen.getByTitle(/^Max/).value).to.equal("20"); + }); + + it("initializes to values passed through props if provided", () => { + // Arrange + const currentRange = new FileFilter("foo", "RANGE(0, 12.34)"); + const items: ListItem[] = ["-20", "20"].map((val) => ({ + displayValue: val, + value: val, + })); + + render( + + ); + + // Should initialize to min and max item provided, respectively + expect(screen.getByTitle(/^Min/).value).to.equal("0"); + expect(screen.getByTitle(/^Max/).value).to.equal("12.34"); + }); + + it("renders a 'Reset' button if given a callback", () => { + // Arrange + const onSearch = noop; + const onReset = sinon.spy(); + const items: ListItem[] = ["0", "20"].map((val) => ({ + displayValue: val, + value: val, + })); + + // Act / Assert + const { getByText } = render( + + ); + + // Sanity check + expect(screen.getByTitle(/^Min/).value).to.equal("0"); + expect(screen.getByTitle(/^Max/).value).to.equal("20"); + + // Hit reset + expect(onReset.called).to.equal(false); + fireEvent.click(getByText("Reset")); + expect(onReset.called).to.equal(true); + + // Should clear min and max values + expect(screen.getByTitle(/^Min/).value).to.equal(""); + 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) => ({ + displayValue: val, + value: val, + })); + const { getByText } = render( + + ); + + // Act / Assert + expect( + getByText( + `Full range available: ${items[0].displayValue}, ${ + items[items.length - 1].displayValue + }` + ) + ).to.exist; + }); +}); diff --git a/packages/core/components/SearchBoxForm/SearchBoxForm.module.css b/packages/core/components/SearchBoxForm/SearchBoxForm.module.css new file mode 100644 index 000000000..a8b60ea04 --- /dev/null +++ b/packages/core/components/SearchBoxForm/SearchBoxForm.module.css @@ -0,0 +1,21 @@ +.search-box-input { + border: none; + flex: auto; +} + +.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); +} diff --git a/packages/core/components/SearchBoxForm/index.tsx b/packages/core/components/SearchBoxForm/index.tsx new file mode 100644 index 000000000..6a83c93c3 --- /dev/null +++ b/packages/core/components/SearchBoxForm/index.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import { SearchBox } from "@fluentui/react"; +import FileFilter from "../../entity/FileFilter"; +import styles from "./SearchBoxForm.module.css"; + +interface SearchBoxFormProps { + onSearch: (filterValue: string) => void; + onReset: () => void; + fieldName: string; + currentValue: FileFilter | undefined; +} + +const SEARCH_BOX_STYLE_OVERRIDES = { + icon: { + color: "steelblue", + }, +}; + +/** + * This component renders a simple form for searching on text values + */ +export default function SearchBoxForm(props: SearchBoxFormProps) { + const { onSearch, onReset, fieldName, currentValue } = props; + + const [searchValue, setSearchValue] = React.useState(currentValue?.value ?? ""); + + const onSearchBoxChange = (event?: React.ChangeEvent) => { + if (event) { + setSearchValue(event.target.value); + } + }; + + function onClear() { + onReset(); + setSearchValue(""); + } + + return ( +
    +
    + +
    +
    + ); +} diff --git a/packages/core/components/SearchBoxForm/test/SearchBoxForm.test.tsx b/packages/core/components/SearchBoxForm/test/SearchBoxForm.test.tsx new file mode 100644 index 000000000..570664015 --- /dev/null +++ b/packages/core/components/SearchBoxForm/test/SearchBoxForm.test.tsx @@ -0,0 +1,74 @@ +import { render, fireEvent, screen } from "@testing-library/react"; +import { expect } from "chai"; +import { noop } from "lodash"; +import * as React from "react"; +import sinon from "sinon"; + +import SearchBoxForm from ".."; +import FileFilter from "../../../entity/FileFilter"; + +describe("", () => { + it("renders an input field for the search term", () => { + // Arrange + const { getAllByRole } = render( + + ); + // Assert + expect(getAllByRole("searchbox").length).to.equal(1); + }); + + it("initializes to values passed through props if provided", () => { + // Arrange + const currentValue = new FileFilter("foo", "bar"); + + render( + + ); + + // Should initialize to value provided, respectively + expect(screen.getByRole("searchbox").value).to.equal("bar"); + }); + + it("renders a 'Reset' button if given a callback", () => { + // Arrange + const onSearch = noop; + const onReset = sinon.spy(); + + // Act / Assert + const { getByRole, getByLabelText } = render( + + ); + // Enter values + fireEvent.change(getByRole("searchbox"), { + target: { + value: "bar", + }, + }); + + // Sanity check + expect(screen.getByRole("searchbox").value).to.equal("bar"); + + // Hit reset + expect(onReset.called).to.equal(false); + fireEvent.click(getByLabelText("Clear text")); + expect(onReset.called).to.equal(true); + + // Should clear min and max values + expect(screen.getByRole("searchbox").value).to.equal(""); + }); +}); diff --git a/packages/core/constants/index.ts b/packages/core/constants/index.ts index e875bd763..40fb3bdaf 100644 --- a/packages/core/constants/index.ts +++ b/packages/core/constants/index.ts @@ -28,7 +28,7 @@ export const AnnotationName = { // Date range options where the date range is a computed value based on the relative date to today const BEGINNING_OF_TODAY = new Date(); BEGINNING_OF_TODAY.setHours(0, 0, 0, 0); -const END_OF_TODAY = new Date(); +export const END_OF_TODAY = new Date(); END_OF_TODAY.setHours(23, 59, 59); const DATE_LAST_YEAR = new Date(BEGINNING_OF_TODAY); DATE_LAST_YEAR.setMonth(BEGINNING_OF_TODAY.getMonth() - 12); @@ -38,6 +38,8 @@ const DATE_LAST_MONTH = new Date(BEGINNING_OF_TODAY); DATE_LAST_MONTH.setMonth(BEGINNING_OF_TODAY.getMonth() - 1); const DATE_LAST_WEEK = new Date(BEGINNING_OF_TODAY); DATE_LAST_WEEK.setDate(BEGINNING_OF_TODAY.getDate() - 7); +export const DATE_ABSOLUTE_MIN = new Date(BEGINNING_OF_TODAY); +DATE_ABSOLUTE_MIN.setFullYear(2000); export const PAST_YEAR_FILTER = new FileFilter( AnnotationName.UPLOADED, `RANGE(${DATE_LAST_YEAR.toISOString()},${END_OF_TODAY.toISOString()})` @@ -146,4 +148,8 @@ export const TOP_LEVEL_FILE_ANNOTATIONS = [ }), ]; +export const SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS = TOP_LEVEL_FILE_ANNOTATIONS.filter( + (a) => a.name !== AnnotationName.FILE_SIZE +); + export const TOP_LEVEL_FILE_ANNOTATION_NAMES = TOP_LEVEL_FILE_ANNOTATIONS.map((a) => a.name); diff --git a/packages/core/entity/Annotation/index.ts b/packages/core/entity/Annotation/index.ts index b225f8ca0..ee5665291 100644 --- a/packages/core/entity/Annotation/index.ts +++ b/packages/core/entity/Annotation/index.ts @@ -55,6 +55,10 @@ export default class Annotation { return this.annotation.type; } + public get units(): string | undefined { + return this.annotation.units; + } + /** * Get the annotation this instance represents from a given FmsFile. An annotation on an FmsFile * can either be at the "top-level" of the document or it can be within it's "annotations" list. diff --git a/packages/core/entity/AnnotationFormatter/date-formatter.ts b/packages/core/entity/AnnotationFormatter/date-formatter.ts index b3012a45c..21f271a30 100644 --- a/packages/core/entity/AnnotationFormatter/date-formatter.ts +++ b/packages/core/entity/AnnotationFormatter/date-formatter.ts @@ -1,3 +1,4 @@ +import { extractDatesFromRangeOperatorFilterString } from "./date-time-formatter"; /** * Accepts date/time string (UTC offset must be specified), outputs stringified, formatted version of just the date. * Should be replaced by a proper date parsing and formatting library like moment as soon as it matters. @@ -6,19 +7,28 @@ */ export default { displayValue(value: string): string { - const date = new Date(value); - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat // for options const options = { month: "2-digit", day: "2-digit", year: "numeric", + // TODO: Having date-time in local time and date in UTC creates UI inconsistency problems timeZone: "UTC", } as Intl.DateTimeFormatOptions; - const formatted = new Intl.DateTimeFormat("en-US", options).format(date); - const [month, day, year] = formatted.split("/"); - return `${year}-${month}-${day}`; + + const { startDate, endDate } = extractDatesFromRangeOperatorFilterString(value); + const formatDate = (date: Date) => { + const formatted = new Intl.DateTimeFormat("en-US", options).format(date); + const [month, day, year] = formatted.split("/"); + return `${year}-${month}-${day}`; + }; + if (startDate && endDate) { + return `${formatDate(startDate)}, ${formatDate(endDate)}`; + } else { + const date = new Date(value); + return formatDate(date); + } }, valueOf(value: any) { diff --git a/packages/core/entity/AnnotationFormatter/date-time-formatter.ts b/packages/core/entity/AnnotationFormatter/date-time-formatter.ts index 5f53568d8..4d3c6c597 100644 --- a/packages/core/entity/AnnotationFormatter/date-time-formatter.ts +++ b/packages/core/entity/AnnotationFormatter/date-time-formatter.ts @@ -4,12 +4,37 @@ */ export default { displayValue(value: string): string { - const date = new Date(value); + const { startDate, endDate } = extractDatesFromRangeOperatorFilterString(value); const options = { timeZone: "America/Los_Angeles" }; - return date.toLocaleString(undefined, options); + if (startDate && endDate) { + return `${startDate.toLocaleString(undefined, options)}; ${endDate.toLocaleString( + undefined, + options + )}`; + } else { + const date = new Date(value); + return date.toLocaleString(undefined, options); + } }, valueOf(value: any) { return value; }, }; + +export function extractDatesFromRangeOperatorFilterString( + filterString: string +): { startDate: Date | undefined; endDate: Date | undefined } { + // Regex with capture groups for identifying ISO datestrings in the RANGE() filter operator + // e.g. RANGE(2022-01-01T00:00:00.000Z,2022-01-31T00:00:00.000Z) + // Captures "2022-01-01T00:00:00.000Z" and "2022-01-31T00:00:00.000Z" + const RANGE_OPERATOR_REGEX = /RANGE\(([\d\-\+:TZ.]+),([\d\-\+:TZ.]+)\)/g; + const exec = RANGE_OPERATOR_REGEX.exec(filterString); + let startDate, endDate; + if (exec && exec.length === 3) { + // Length of 3 because we use two capture groups + startDate = new Date(exec[1]); + endDate = new Date(exec[2]); + } + return { startDate, endDate }; +} diff --git a/packages/core/entity/AnnotationFormatter/number-formatter.ts b/packages/core/entity/AnnotationFormatter/number-formatter.ts index a87632c0c..48a38c900 100644 --- a/packages/core/entity/AnnotationFormatter/number-formatter.ts +++ b/packages/core/entity/AnnotationFormatter/number-formatter.ts @@ -2,14 +2,36 @@ import filesize from "filesize"; export default { displayValue(value: string | number, units?: string): string { - if (units === "bytes") { - return filesize(Number(value)); + const { minValue, maxValue } = extractValuesFromRangeOperatorFilterString(value.toString()); + const formatNumber = (val: string | number) => { + if (units === "bytes") { + return filesize(Number(val)); + } + return `${val}${units ? " " + units : ""}`; + }; + if (minValue && maxValue) { + return `[${formatNumber(minValue)},${formatNumber(maxValue)})`; } - - return `${value}${units ? " " + units : ""}`; + return formatNumber(value); }, valueOf(value: any) { return Number(value); }, }; + +export function extractValuesFromRangeOperatorFilterString( + filterString: string +): { minValue: string | undefined; maxValue: string | undefined } { + // Regex with capture groups for identifying values in the RANGE() filter operator + // e.g. RANGE(-.1, 10) captures "-.1" and "10" + // Does not check for valid values, just captures existing floats + const RANGE_OPERATOR_REGEX = /RANGE\(([\d\-.]+),\s?([\d\-.]+)\)/g; + const exec = RANGE_OPERATOR_REGEX.exec(filterString); + let minValue, maxValue; + if (exec && exec.length === 3) { + minValue = exec[1]; + maxValue = exec[2]; + } + return { minValue, maxValue }; +} diff --git a/packages/core/entity/AnnotationFormatter/test/formatters.test.ts b/packages/core/entity/AnnotationFormatter/test/formatters.test.ts index e60d5c083..3753df368 100644 --- a/packages/core/entity/AnnotationFormatter/test/formatters.test.ts +++ b/packages/core/entity/AnnotationFormatter/test/formatters.test.ts @@ -55,6 +55,12 @@ describe("Annotation formatters", () => { // behind UTC { input: "2018-05-24T00:00:00-08:00", expected: "5/24/2018, 1:00:00 AM" }, + + // range format + { + input: "RANGE(2018-05-24T00:00:00-08:00,2019-05-26T00:00:00-08:00)", + expected: "5/24/2018, 1:00:00 AM; 5/26/2019, 1:00:00 AM", + }, ]; spec.forEach((testCase) => @@ -79,8 +85,14 @@ describe("Annotation formatters", () => { // no colon in UTC offset { input: "2018-05-24T00:00:00+0000", expected: "2018-05-24" }, - ]; + // range format + { + input: "RANGE(2018-05-24T00:00:00+0000,2019-05-26T00:00:00+0000)", + expected: "2018-05-24, 2019-05-26", + }, + ]; + 6; spec.forEach((testCase) => { it(`formats ${testCase.input} as a date (expected: ${testCase.expected})`, () => { expect(dateFormatter.displayValue(testCase.input)).to.equal(testCase.expected); @@ -107,6 +119,16 @@ describe("Annotation formatters", () => { it("formats a value according to its type", () => { expect(numberFormatter.valueOf("3")).to.equal(3); }); + + it("formats a range without units", () => { + expect(numberFormatter.displayValue("RANGE(3,4)")).to.equal("[3,4)"); + }); + + it("formats a range with units", () => { + expect(numberFormatter.displayValue("RANGE(3,4)", "moles")).to.equal( + "[3 moles,4 moles)" + ); + }); }); describe("Duration annotation formatter", () => { diff --git a/packages/core/state/metadata/selectors.ts b/packages/core/state/metadata/selectors.ts index 2da165b8e..4dadccec5 100644 --- a/packages/core/state/metadata/selectors.ts +++ b/packages/core/state/metadata/selectors.ts @@ -3,7 +3,7 @@ import { createSelector } from "reselect"; import { State } from "../"; import Annotation from "../../entity/Annotation"; -import { TOP_LEVEL_FILE_ANNOTATIONS } from "../../constants"; +import { SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS, TOP_LEVEL_FILE_ANNOTATIONS } from "../../constants"; import { Dataset } from "../../services/DatasetService"; // BASIC SELECTORS @@ -15,6 +15,10 @@ export const getSortedAnnotations = createSelector(getAnnotations, (annotations: Annotation.sort(annotations) ); +export const getSupportedAnnotations = createSelector(getSortedAnnotations, (annotations) => + Annotation.sort([...SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS, ...annotations]) +); + export const getCustomAnnotationsCombinedWithFileAttributes = createSelector( getSortedAnnotations, (annotations) => Annotation.sort([...TOP_LEVEL_FILE_ANNOTATIONS, ...annotations]) diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts index a4dca291e..0cc560a85 100644 --- a/packages/core/state/selection/selectors.ts +++ b/packages/core/state/selection/selectors.ts @@ -9,7 +9,7 @@ import FileFolder from "../../entity/FileFolder"; import FileSort from "../../entity/FileSort"; import { Dataset } from "../../services/DatasetService"; import { groupBy, keyBy, map } from "lodash"; -import { getAnnotations } from "../metadata/selectors"; +import { getSupportedAnnotations } from "../metadata/selectors"; // BASIC SELECTORS export const getAnnotationHierarchy = (state: State) => state.selection.annotationHierarchy; @@ -63,7 +63,7 @@ export const getFileAttributeFilter = createSelector([getFileFilters], (fileFilt | undefined => fileFilters.find((f) => TOP_LEVEL_FILE_ANNOTATION_NAMES.includes(f.name))); export const getGroupedByFilterName = createSelector( - [getAnnotationFilters, getAnnotations], + [getFileFilters, getSupportedAnnotations], (globalFilters: FileFilter[], annotations: Annotation[]) => { const annotationNameToInstanceMap = keyBy(annotations, "name"); const filters: Filter[] = map(globalFilters, (filter: FileFilter) => {