From 4bdd6ec4a48ed41a14ce3d30dc7b69b4bb6fa4a1 Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Wed, 20 Mar 2024 15:18:36 -0700 Subject: [PATCH 01/10] create new form components for different data types --- .../DateRangePicker.module.css | 23 +++ .../core/components/DateRangePicker/index.tsx | 103 +++++++++++ .../core/components/DirectoryTree/index.tsx | 2 - .../RangePicker/RangePicker.module.css | 56 ++++++ .../core/components/RangePicker/index.tsx | 171 ++++++++++++++++++ .../SearchBoxForm/SearchBoxForm.module.css | 21 +++ .../core/components/SearchBoxForm/index.tsx | 53 ++++++ packages/core/constants/index.ts | 8 +- packages/core/entity/Annotation/index.ts | 4 + 9 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 packages/core/components/DateRangePicker/DateRangePicker.module.css create mode 100644 packages/core/components/DateRangePicker/index.tsx create mode 100644 packages/core/components/RangePicker/RangePicker.module.css create mode 100644 packages/core/components/RangePicker/index.tsx create mode 100644 packages/core/components/SearchBoxForm/SearchBoxForm.module.css create mode 100644 packages/core/components/SearchBoxForm/index.tsx 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..8057e3535 --- /dev/null +++ b/packages/core/components/DateRangePicker/index.tsx @@ -0,0 +1,103 @@ +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 + let oldStartDate; + let oldEndDate; + const splitFileAttributeFilter = extractDatesFromRangeOperatorFilterString( + currentRange?.value + ); + if (splitFileAttributeFilter !== null) { + oldStartDate = splitFileAttributeFilter.startDate; + oldEndDate = splitFileAttributeFilter.endDate; + // 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()})`); + } + } + + let startDate; + let endDate; + const splitDates = extractDatesFromRangeOperatorFilterString(currentRange?.value); + if (splitDates !== null) { + startDate = splitDates.startDate; + endDate = splitDates.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/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) {
-
    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::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + /* display: none; <- Crashes Chrome on hover */ + -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/RangePicker/index.tsx b/packages/core/components/RangePicker/index.tsx new file mode 100644 index 000000000..a04b43886 --- /dev/null +++ b/packages/core/components/RangePicker/index.tsx @@ -0,0 +1,171 @@ +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 "./RangePicker.module.css"; + +export interface ListItem { + displayValue: AnnotationValue; + value: AnnotationValue; +} + +interface RangePickerProps { + disabled?: boolean; + errorMessage?: string; + items: ListItem[]; + loading?: boolean; + onSearch: (filterValue: string) => void; + onReset: () => void; + currentRange: FileFilter | undefined; + units?: string; +} + +/** + * A RangePicker 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 RangePicker(props: RangePickerProps) { + 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); + onSubmitRange(); + } + + const onSubmitRange = () => { + let oldMinValue; + let oldMaxValue; + const splitFileAttributeFilter = extractValuesFromRangeOperatorFilterString( + currentRange?.value + ); + if (splitFileAttributeFilter !== null) { + oldMinValue = splitFileAttributeFilter.minValue; + oldMaxValue = splitFileAttributeFilter.maxValue; + } + const newMinValue = searchMinValue || oldMinValue || overallMin; + const newMaxValue = searchMaxValue || oldMaxValue || overallMax + 1; + if (newMinValue && newMaxValue) { + // // Increment by small amount to max to account for RANGE() filter upper bound exclusivity + // const incrementSize = calculateIncrementSize(newMaxValue) + // const newMaxValuePlusFloat = (Number(newMaxValue) + Number(incrementSize)).toString() + 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/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/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. From 1a65d4627a4230a0ecbd76f317e3995fecd811ec Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Wed, 20 Mar 2024 15:19:31 -0700 Subject: [PATCH 02/10] update formatters to accept ranges --- .../AnnotationFormatter/date-formatter.ts | 22 ++++++++++--- .../date-time-formatter.ts | 29 +++++++++++++++-- .../AnnotationFormatter/number-formatter.ts | 32 ++++++++++++++++--- .../test/formatters.test.ts | 24 +++++++++++++- 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/packages/core/entity/AnnotationFormatter/date-formatter.ts b/packages/core/entity/AnnotationFormatter/date-formatter.ts index b3012a45c..e7e2d34e8 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,30 @@ */ 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 splitDates = 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 (splitDates) { + const startDate = formatDate(splitDates?.startDate); + const endDate = formatDate(splitDates?.endDate); + return `${startDate}, ${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..545e7c476 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 splitDates = extractDatesFromRangeOperatorFilterString(value); const options = { timeZone: "America/Los_Angeles" }; - return date.toLocaleString(undefined, options); + if (splitDates) { + const startDate = splitDates?.startDate.toLocaleString(undefined, options); + const endDate = splitDates?.endDate.toLocaleString(undefined, options); + return `${startDate}; ${endDate}`; + } else { + const date = new Date(value); + return date.toLocaleString(undefined, options); + } }, valueOf(value: any) { return value; }, }; + +export 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]); + const endDate = new Date(exec[2]); + + return { startDate, endDate }; + } + return null; +} diff --git a/packages/core/entity/AnnotationFormatter/number-formatter.ts b/packages/core/entity/AnnotationFormatter/number-formatter.ts index a87632c0c..5feba39e9 100644 --- a/packages/core/entity/AnnotationFormatter/number-formatter.ts +++ b/packages/core/entity/AnnotationFormatter/number-formatter.ts @@ -2,14 +2,38 @@ import filesize from "filesize"; export default { displayValue(value: string | number, units?: string): string { - if (units === "bytes") { - return filesize(Number(value)); + const splitValues = extractValuesFromRangeOperatorFilterString(value.toString()); + const formatNumber = (val: string | number) => { + if (units === "bytes") { + return filesize(Number(val)); + } + return `${val}${units ? " " + units : ""}`; + }; + if (splitValues) { + const minValue = formatNumber(splitValues?.minValue); + const maxValue = formatNumber(splitValues?.maxValue); + return `[${minValue},${maxValue})`; } - - return `${value}${units ? " " + units : ""}`; + return formatNumber(value); }, valueOf(value: any) { return Number(value); }, }; + +export function extractValuesFromRangeOperatorFilterString( + filterString: string +): { minValue: string; maxValue: string } | null { + // 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\-.]+),([\d\-.]+)\)/g; + const exec = RANGE_OPERATOR_REGEX.exec(filterString); + if (exec && exec.length === 3) { + const minValue = exec[1]; + const maxValue = exec[2]; // TODO: Revisit inclusive vs exclusive upper bound + return { minValue, maxValue }; + } + return null; +} 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", () => { From b0ab0a1080a78e8b312973195ba4afde75d87eac Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Wed, 20 Mar 2024 15:25:46 -0700 Subject: [PATCH 03/10] modify annotation filter form behavior for different data types --- .../components/AnnotationFilterForm/index.tsx | 120 +++++++++++++++--- .../core/components/AnnotationList/index.tsx | 4 +- .../AnnotationSidebar/AnnotationFilter.tsx | 5 +- .../components/AnnotationSidebar/selectors.ts | 7 +- packages/core/components/DnDList/DnDList.tsx | 6 +- packages/core/state/metadata/selectors.ts | 8 +- packages/core/state/selection/selectors.ts | 4 +- 7 files changed, 121 insertions(+), 33 deletions(-) diff --git a/packages/core/components/AnnotationFilterForm/index.tsx b/packages/core/components/AnnotationFilterForm/index.tsx index fddb21ec0..e56148591 100644 --- a/packages/core/components/AnnotationFilterForm/index.tsx +++ b/packages/core/components/AnnotationFilterForm/index.tsx @@ -5,8 +5,12 @@ import { useDispatch, useSelector } from "react-redux"; import { AnnotationType } from "../../entity/AnnotationFormatter"; import FileFilter from "../../entity/FileFilter"; import ListPicker, { ListItem } from "../../components/ListPicker"; +import RangePicker from "../../components/RangePicker"; +import SearchBoxForm from "../SearchBoxForm"; +import DateRangePicker from "../DateRangePicker"; import { interaction, metadata, selection } from "../../state"; import useAnnotationValues from "./useAnnotationValues"; +import { Spinner, SpinnerSize } from "@fluentui/react"; interface AnnotationFilterFormProps { annotationName: string; @@ -18,13 +22,13 @@ interface AnnotationFilterFormProps { * of annotation: if the annotation is of type string, it will render a list for the user to choose * amongst its items; if the annotation is of type date, it will render a date input; etc. */ -export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { +export default function AnnotationFilterFormWrapper(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
    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/DnDList/DnDList.tsx b/packages/core/components/DnDList/DnDList.tsx index aabea0288..0f7ed2cdb 100644 --- a/packages/core/components/DnDList/DnDList.tsx +++ b/packages/core/components/DnDList/DnDList.tsx @@ -13,6 +13,8 @@ export interface DnDItem { disabled?: boolean; id: string; // a unique identifier for the annotation, e.g., annotation.name title: string; // the value to display, e.g., annotation.displayName + type?: string; + isFileProperty?: boolean; } export interface DnDListDividers { @@ -57,7 +59,7 @@ export default function DnDList(props: DnDListProps) { data-testid={DND_LIST_CONTAINER_ID} > {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/state/metadata/selectors.ts b/packages/core/state/metadata/selectors.ts index 2da165b8e..667599c93 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,12 @@ export const getSortedAnnotations = createSelector(getAnnotations, (annotations: Annotation.sort(annotations) ); +export const getSupportedAnnotations = createSelector( + getSortedAnnotations, + // TODO: Modify selector to be less case by case + (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) => { From 0a7f82d8e6007ce5ba68334915ff7e7cda618563 Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Wed, 20 Mar 2024 15:27:12 -0700 Subject: [PATCH 04/10] update affected tests --- .../test/AnnotationList.test.tsx | 14 ++++++-- .../AnnotationSidebar/test/selectors.test.ts | 32 +++++++++++++------ .../EmptyFileListMessage/FilterList.tsx | 8 ++++- .../FileMetadataSearchBar/index.tsx | 10 +++--- .../FilterDisplayBar/FilterMedallion.tsx | 4 ++- .../components/FilterDisplayBar/index.tsx | 2 +- .../test/FilterDisplayBar.test.tsx | 6 +++- 7 files changed, 57 insertions(+), 19 deletions(-) diff --git a/packages/core/components/AnnotationList/test/AnnotationList.test.tsx b/packages/core/components/AnnotationList/test/AnnotationList.test.tsx index 2047171d3..1c30b883b 100644 --- a/packages/core/components/AnnotationList/test/AnnotationList.test.tsx +++ b/packages/core/components/AnnotationList/test/AnnotationList.test.tsx @@ -11,6 +11,7 @@ import { annotationsJson } from "../../../entity/Annotation/mocks"; import FileFilter from "../../../entity/FileFilter"; import { initialState, reducer, reduxLogics, selection } from "../../../state"; import { DND_LIST_CONTAINER_ID } from "../../DnDList/DnDList"; +import { SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants"; import styles from "../AnnotationList.module.css"; @@ -55,7 +56,9 @@ describe("", () => { 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/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/EmptyFileListMessage/FilterList.tsx b/packages/core/components/EmptyFileListMessage/FilterList.tsx index 34535f1a6..a5e3f1d9f 100644 --- a/packages/core/components/EmptyFileListMessage/FilterList.tsx +++ b/packages/core/components/EmptyFileListMessage/FilterList.tsx @@ -37,7 +37,13 @@ export default function FilterList(props: Props) { } }, [textRef, filters]); - const operator = filters.length > 1 ? "for values of" : "equal to"; + const firstFilterValue = filters[0].value.toString(); + const operator = + filters.length > 1 + ? "for values of" + : firstFilterValue.includes("RANGE") + ? "between" + : "equal to"; const valueDisplay = map(filters, (filter) => filter.displayValue).join(", "); const display = ` ${operator} ${valueDisplay}`; diff --git a/packages/core/components/FileMetadataSearchBar/index.tsx b/packages/core/components/FileMetadataSearchBar/index.tsx index 70a398cf4..3713ca39a 100644 --- a/packages/core/components/FileMetadataSearchBar/index.tsx +++ b/packages/core/components/FileMetadataSearchBar/index.tsx @@ -9,7 +9,11 @@ import { import * as React from "react"; import { useDispatch, useSelector } from "react-redux"; -import { AnnotationName, TOP_LEVEL_FILE_ANNOTATIONS } from "../../constants"; +import { + AnnotationName, + TOP_LEVEL_FILE_ANNOTATIONS, + SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS, +} from "../../constants"; import { AnnotationType } from "../../entity/AnnotationFormatter"; import FileFilter from "../../entity/FileFilter"; import Tutorial from "../../entity/Tutorial"; @@ -18,9 +22,7 @@ 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) => ({ +const FILE_ATTRIBUTE_OPTIONS = SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.map((a) => ({ key: a.name, text: a.displayName, })); diff --git a/packages/core/components/FilterDisplayBar/FilterMedallion.tsx b/packages/core/components/FilterDisplayBar/FilterMedallion.tsx index 26b2d80f4..f7039f841 100644 --- a/packages/core/components/FilterDisplayBar/FilterMedallion.tsx +++ b/packages/core/components/FilterDisplayBar/FilterMedallion.tsx @@ -63,7 +63,9 @@ export default function FilterMedallion(props: Props) { } }, [textRef, filters]); - const operator = filters.length > 1 ? "ONE OF" : "EQUALS"; + const firstFilterValue = filters[0].value.toString(); + const operator = + filters.length > 1 ? "ONE OF" : firstFilterValue.includes("RANGE") ? "BETWEEN" : "EQUALS"; 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(); }); From 4150affadd7210cc83a6b4800dedec999e78d0d6 Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Wed, 20 Mar 2024 20:07:42 -0700 Subject: [PATCH 05/10] provide unit tests for new form components --- .../components/AnnotationFilterForm/index.tsx | 2 +- .../test/DateRangePicker.test.tsx | 69 +++++++++ .../RangePicker/test/RangePicker.test.tsx | 135 ++++++++++++++++++ .../AnnotationFormatter/number-formatter.ts | 2 +- 4 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 packages/core/components/DateRangePicker/test/DateRangePicker.test.tsx create mode 100644 packages/core/components/RangePicker/test/RangePicker.test.tsx diff --git a/packages/core/components/AnnotationFilterForm/index.tsx b/packages/core/components/AnnotationFilterForm/index.tsx index e56148591..d21ca3042 100644 --- a/packages/core/components/AnnotationFilterForm/index.tsx +++ b/packages/core/components/AnnotationFilterForm/index.tsx @@ -22,7 +22,7 @@ interface AnnotationFilterFormProps { * of annotation: if the annotation is of type string, it will render a list for the user to choose * amongst its items; if the annotation is of type date, it will render a date input; etc. */ -export default function AnnotationFilterFormWrapper(props: AnnotationFilterFormProps) { +export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { const { annotationName } = props; const dispatch = useDispatch(); const annotations = useSelector(metadata.selectors.getSupportedAnnotations); 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..9b2e3b20c --- /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/RangePicker/test/RangePicker.test.tsx b/packages/core/components/RangePicker/test/RangePicker.test.tsx new file mode 100644 index 000000000..8d393701c --- /dev/null +++ b/packages/core/components/RangePicker/test/RangePicker.test.tsx @@ -0,0 +1,135 @@ +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 RangePicker, { 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, + })); + + 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/entity/AnnotationFormatter/number-formatter.ts b/packages/core/entity/AnnotationFormatter/number-formatter.ts index 5feba39e9..6d3fa8a96 100644 --- a/packages/core/entity/AnnotationFormatter/number-formatter.ts +++ b/packages/core/entity/AnnotationFormatter/number-formatter.ts @@ -28,7 +28,7 @@ export function extractValuesFromRangeOperatorFilterString( // 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\-.]+),([\d\-.]+)\)/g; + const RANGE_OPERATOR_REGEX = /RANGE\(([\d\-.]+),\s?([\d\-.]+)\)/g; const exec = RANGE_OPERATOR_REGEX.exec(filterString); if (exec && exec.length === 3) { const minValue = exec[1]; From c4346d4410fed376c1cc80cc4bbf050365496ebd Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Fri, 22 Mar 2024 15:50:35 -0700 Subject: [PATCH 06/10] rename number range and update imports --- .../components/AnnotationFilterForm/index.tsx | 11 ++- .../test/DateRangePicker.test.tsx | 2 +- .../FileList/FileListColumnPicker.tsx | 2 +- .../Modal/AnnotationSelector/index.tsx | 2 +- .../NumberRangePicker.module.css} | 10 +++ .../index.tsx | 83 +++++++++---------- .../test/NumberRangePicker.test.tsx} | 0 7 files changed, 61 insertions(+), 49 deletions(-) rename packages/core/components/{RangePicker/RangePicker.module.css => NumberRangePicker/NumberRangePicker.module.css} (90%) rename packages/core/components/{RangePicker => NumberRangePicker}/index.tsx (69%) rename packages/core/components/{RangePicker/test/RangePicker.test.tsx => NumberRangePicker/test/NumberRangePicker.test.tsx} (100%) diff --git a/packages/core/components/AnnotationFilterForm/index.tsx b/packages/core/components/AnnotationFilterForm/index.tsx index d21ca3042..729d8e27a 100644 --- a/packages/core/components/AnnotationFilterForm/index.tsx +++ b/packages/core/components/AnnotationFilterForm/index.tsx @@ -1,16 +1,16 @@ +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 RangePicker from "../../components/RangePicker"; +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"; -import { Spinner, SpinnerSize } from "@fluentui/react"; interface AnnotationFilterFormProps { annotationName: string; @@ -102,6 +102,9 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { if (filterValue && filterValue.trim()) { const fileFilter = new FileFilter(annotationName, filterValue); if (currentValues) { + console.info("currentValues", currentValues); + console.info("fileFilter", fileFilter); + dispatch(selection.actions.removeFileFilter(currentValues)); } dispatch(selection.actions.addFileFilter(fileFilter)); @@ -149,7 +152,7 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { ); case AnnotationType.NUMBER: return ( - ", () => { +describe("", () => { it("renders inputs for start and end dates with selectable date pickers", () => { // Arrange const onSearch = sinon.spy(); 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/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/RangePicker/RangePicker.module.css b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css similarity index 90% rename from packages/core/components/RangePicker/RangePicker.module.css rename to packages/core/components/NumberRangePicker/NumberRangePicker.module.css index f2fc31de2..fc9f90dc4 100644 --- a/packages/core/components/RangePicker/RangePicker.module.css +++ b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css @@ -16,6 +16,7 @@ } .buttons { + clear: both; display: flex; } @@ -48,6 +49,15 @@ cursor: not-allowed; } +.input-field { + float: left; + margin-left: 10px; +} + +label,input{ + display: block; +} + input::-webkit-outer-spin-button, input::-webkit-inner-spin-button { /* display: none; <- Crashes Chrome on hover */ diff --git a/packages/core/components/RangePicker/index.tsx b/packages/core/components/NumberRangePicker/index.tsx similarity index 69% rename from packages/core/components/RangePicker/index.tsx rename to packages/core/components/NumberRangePicker/index.tsx index a04b43886..35327d788 100644 --- a/packages/core/components/RangePicker/index.tsx +++ b/packages/core/components/NumberRangePicker/index.tsx @@ -5,14 +5,14 @@ import FileFilter from "../../entity/FileFilter"; import { extractValuesFromRangeOperatorFilterString } from "../../entity/AnnotationFormatter/number-formatter"; import { AnnotationValue } from "../../services/AnnotationService"; -import styles from "./RangePicker.module.css"; +import styles from "./NumberRangePicker.module.css"; export interface ListItem { displayValue: AnnotationValue; value: AnnotationValue; } -interface RangePickerProps { +interface NumberRangePickerProps { disabled?: boolean; errorMessage?: string; items: ListItem[]; @@ -24,13 +24,13 @@ interface RangePickerProps { } /** - * A RangePicker is a simple form that renders input fields for the minimum and maximum values + * 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 RangePicker(props: RangePickerProps) { +export default function NumberRangePicker(props: NumberRangePickerProps) { const { errorMessage, items, loading, onSearch, onReset, currentRange, units } = props; const overallMin = React.useMemo(() => { @@ -41,10 +41,10 @@ export default function RangePicker(props: RangePickerProps) { }, [items]); const [searchMinValue, setSearchMinValue] = React.useState( - extractValuesFromRangeOperatorFilterString(currentRange?.value)?.minValue ?? overallMin + extractValuesFromRangeOperatorFilterString(currentRange?.value).minValue ?? overallMin ); const [searchMaxValue, setSearchMaxValue] = React.useState( - extractValuesFromRangeOperatorFilterString(currentRange?.value)?.maxValue ?? overallMax + extractValuesFromRangeOperatorFilterString(currentRange?.value).maxValue ?? overallMax ); function onResetSearch() { @@ -56,25 +56,18 @@ export default function RangePicker(props: RangePickerProps) { function onSelectFullRange() { setSearchMinValue(overallMin); setSearchMaxValue(overallMax); - onSubmitRange(); + // Plus 1 here to ensure that actual max is not excluded for full range + onSearch(`RANGE(${overallMin},${(Number(overallMax) + 1).toString()})`); } const onSubmitRange = () => { - let oldMinValue; - let oldMaxValue; - const splitFileAttributeFilter = extractValuesFromRangeOperatorFilterString( - currentRange?.value - ); - if (splitFileAttributeFilter !== null) { - oldMinValue = splitFileAttributeFilter.minValue; - oldMaxValue = splitFileAttributeFilter.maxValue; - } + const { + minValue: oldMinValue, + maxValue: oldMaxValue, + } = extractValuesFromRangeOperatorFilterString(currentRange?.value); const newMinValue = searchMinValue || oldMinValue || overallMin; - const newMaxValue = searchMaxValue || oldMaxValue || overallMax + 1; + const newMaxValue = searchMaxValue || oldMaxValue || (Number(overallMax) + 1).toString(); // Ensure that actual max is not excluded if (newMinValue && newMaxValue) { - // // Increment by small amount to max to account for RANGE() filter upper bound exclusivity - // const incrementSize = calculateIncrementSize(newMaxValue) - // const newMaxValuePlusFloat = (Number(newMaxValue) + Number(incrementSize)).toString() onSearch(`RANGE(${newMinValue},${newMaxValue})`); } }; @@ -105,28 +98,34 @@ export default function RangePicker(props: RangePickerProps) { return (
    - - {units} - - {units} +
    + + + {units} +
    +
    + + + {units} +
    Date: Fri, 22 Mar 2024 16:42:39 -0700 Subject: [PATCH 07/10] refactor range extract function --- .../core/components/DateRangePicker/index.tsx | 22 ++++++------------- .../AnnotationFormatter/date-formatter.ts | 8 +++---- .../date-time-formatter.ts | 22 +++++++++---------- .../AnnotationFormatter/number-formatter.ts | 18 +++++++-------- 4 files changed, 29 insertions(+), 41 deletions(-) diff --git a/packages/core/components/DateRangePicker/index.tsx b/packages/core/components/DateRangePicker/index.tsx index 8057e3535..e47f01039 100644 --- a/packages/core/components/DateRangePicker/index.tsx +++ b/packages/core/components/DateRangePicker/index.tsx @@ -36,14 +36,11 @@ export default function DateRangePicker(props: DateRangePickerProps) { 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( - currentRange?.value - ); - if (splitFileAttributeFilter !== null) { - oldStartDate = splitFileAttributeFilter.startDate; - oldEndDate = splitFileAttributeFilter.endDate; + 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 @@ -58,13 +55,8 @@ export default function DateRangePicker(props: DateRangePickerProps) { onSearch(`RANGE(${newStartDate.toISOString()},${newEndDatePlusOne.toISOString()})`); } } - - let startDate; - let endDate; - const splitDates = extractDatesFromRangeOperatorFilterString(currentRange?.value); - if (splitDates !== null) { - startDate = splitDates.startDate; - endDate = splitDates.endDate; + 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); } diff --git a/packages/core/entity/AnnotationFormatter/date-formatter.ts b/packages/core/entity/AnnotationFormatter/date-formatter.ts index e7e2d34e8..21f271a30 100644 --- a/packages/core/entity/AnnotationFormatter/date-formatter.ts +++ b/packages/core/entity/AnnotationFormatter/date-formatter.ts @@ -17,16 +17,14 @@ export default { timeZone: "UTC", } as Intl.DateTimeFormatOptions; - const splitDates = extractDatesFromRangeOperatorFilterString(value); + 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 (splitDates) { - const startDate = formatDate(splitDates?.startDate); - const endDate = formatDate(splitDates?.endDate); - return `${startDate}, ${endDate}`; + if (startDate && endDate) { + return `${formatDate(startDate)}, ${formatDate(endDate)}`; } else { const date = new Date(value); return formatDate(date); diff --git a/packages/core/entity/AnnotationFormatter/date-time-formatter.ts b/packages/core/entity/AnnotationFormatter/date-time-formatter.ts index 545e7c476..4d3c6c597 100644 --- a/packages/core/entity/AnnotationFormatter/date-time-formatter.ts +++ b/packages/core/entity/AnnotationFormatter/date-time-formatter.ts @@ -4,12 +4,13 @@ */ export default { displayValue(value: string): string { - const splitDates = extractDatesFromRangeOperatorFilterString(value); + const { startDate, endDate } = extractDatesFromRangeOperatorFilterString(value); const options = { timeZone: "America/Los_Angeles" }; - if (splitDates) { - const startDate = splitDates?.startDate.toLocaleString(undefined, options); - const endDate = splitDates?.endDate.toLocaleString(undefined, options); - return `${startDate}; ${endDate}`; + if (startDate && endDate) { + return `${startDate.toLocaleString(undefined, options)}; ${endDate.toLocaleString( + undefined, + options + )}`; } else { const date = new Date(value); return date.toLocaleString(undefined, options); @@ -23,18 +24,17 @@ export default { export function extractDatesFromRangeOperatorFilterString( filterString: string -): { startDate: Date; endDate: Date } | null { +): { 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 - const startDate = new Date(exec[1]); - const endDate = new Date(exec[2]); - - return { startDate, endDate }; + startDate = new Date(exec[1]); + endDate = new Date(exec[2]); } - return null; + return { startDate, endDate }; } diff --git a/packages/core/entity/AnnotationFormatter/number-formatter.ts b/packages/core/entity/AnnotationFormatter/number-formatter.ts index 6d3fa8a96..48a38c900 100644 --- a/packages/core/entity/AnnotationFormatter/number-formatter.ts +++ b/packages/core/entity/AnnotationFormatter/number-formatter.ts @@ -2,17 +2,15 @@ import filesize from "filesize"; export default { displayValue(value: string | number, units?: string): string { - const splitValues = extractValuesFromRangeOperatorFilterString(value.toString()); + const { minValue, maxValue } = extractValuesFromRangeOperatorFilterString(value.toString()); const formatNumber = (val: string | number) => { if (units === "bytes") { return filesize(Number(val)); } return `${val}${units ? " " + units : ""}`; }; - if (splitValues) { - const minValue = formatNumber(splitValues?.minValue); - const maxValue = formatNumber(splitValues?.maxValue); - return `[${minValue},${maxValue})`; + if (minValue && maxValue) { + return `[${formatNumber(minValue)},${formatNumber(maxValue)})`; } return formatNumber(value); }, @@ -24,16 +22,16 @@ export default { export function extractValuesFromRangeOperatorFilterString( filterString: string -): { minValue: string; maxValue: string } | null { +): { 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) { - const minValue = exec[1]; - const maxValue = exec[2]; // TODO: Revisit inclusive vs exclusive upper bound - return { minValue, maxValue }; + minValue = exec[1]; + maxValue = exec[2]; } - return null; + return { minValue, maxValue }; } From fa23f0f6c1956f2e54ee7eacb7e2bdc59fee28be Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Fri, 22 Mar 2024 16:43:20 -0700 Subject: [PATCH 08/10] remove unused FileMetadataSearchBar component --- .../FileMetadataSearchBar.module.css | 35 --- .../FileMetadataSearchBar/index.tsx | 198 --------------- .../test/FileMetadataSearchBar.test.tsx | 234 ------------------ 3 files changed, 467 deletions(-) delete mode 100644 packages/core/components/FileMetadataSearchBar/FileMetadataSearchBar.module.css delete mode 100644 packages/core/components/FileMetadataSearchBar/index.tsx delete mode 100644 packages/core/components/FileMetadataSearchBar/test/FileMetadataSearchBar.test.tsx 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 3713ca39a..000000000 --- a/packages/core/components/FileMetadataSearchBar/index.tsx +++ /dev/null @@ -1,198 +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, - SEARCHABLE_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 = SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS.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; - }); - }); -}); From 2d7c92f6bddf5f1aa2520aa6b3ffdda4ae015352 Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Mon, 25 Mar 2024 10:55:34 -0700 Subject: [PATCH 09/10] add unit test for search box form --- .../test/NumberRangePicker.test.tsx | 35 +++++++-- .../SearchBoxForm/test/SearchBoxForm.test.tsx | 74 +++++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 packages/core/components/SearchBoxForm/test/SearchBoxForm.test.tsx diff --git a/packages/core/components/NumberRangePicker/test/NumberRangePicker.test.tsx b/packages/core/components/NumberRangePicker/test/NumberRangePicker.test.tsx index 8d393701c..e0957318a 100644 --- a/packages/core/components/NumberRangePicker/test/NumberRangePicker.test.tsx +++ b/packages/core/components/NumberRangePicker/test/NumberRangePicker.test.tsx @@ -4,10 +4,10 @@ import { noop } from "lodash"; import * as React from "react"; import sinon from "sinon"; -import RangePicker, { ListItem } from ".."; +import NumberRangePicker, { ListItem } from ".."; import FileFilter from "../../../entity/FileFilter"; -describe("", () => { +describe("", () => { it("renders input fields for min and max values initialized to overall min/max", () => { // Arrange const items: ListItem[] = ["0", "20"].map((val) => ({ @@ -15,8 +15,14 @@ describe("", () => { value: val, })); + // Act / Assert const { getAllByTitle } = render( - + ); // Should render both input fields @@ -37,7 +43,12 @@ describe("", () => { })); render( - + ); // Should initialize to min and max item provided, respectively @@ -56,7 +67,7 @@ describe("", () => { // Act / Assert const { getByText } = render( - ", () => { value: val, })); const { getByTitle, getByText } = render( - + ); // Enter values @@ -120,7 +136,12 @@ describe("", () => { value: val, })); const { getByText } = render( - + ); // Act / Assert 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(""); + }); +}); From b1588ac4f6da7596cf56eca1df685e09772b58fc Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Tue, 26 Mar 2024 11:02:53 -0700 Subject: [PATCH 10/10] clean up and improve readability --- packages/core/components/AnnotationFilterForm/index.tsx | 3 --- .../core/components/EmptyFileListMessage/FilterList.tsx | 9 +++------ .../core/components/FilterDisplayBar/FilterMedallion.tsx | 5 +++-- .../NumberRangePicker/NumberRangePicker.module.css | 1 - packages/core/state/metadata/selectors.ts | 6 ++---- 5 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/core/components/AnnotationFilterForm/index.tsx b/packages/core/components/AnnotationFilterForm/index.tsx index 729d8e27a..95a3fb097 100644 --- a/packages/core/components/AnnotationFilterForm/index.tsx +++ b/packages/core/components/AnnotationFilterForm/index.tsx @@ -102,9 +102,6 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { if (filterValue && filterValue.trim()) { const fileFilter = new FileFilter(annotationName, filterValue); if (currentValues) { - console.info("currentValues", currentValues); - console.info("fileFilter", fileFilter); - dispatch(selection.actions.removeFileFilter(currentValues)); } dispatch(selection.actions.addFileFilter(fileFilter)); diff --git a/packages/core/components/EmptyFileListMessage/FilterList.tsx b/packages/core/components/EmptyFileListMessage/FilterList.tsx index a5e3f1d9f..748f897b3 100644 --- a/packages/core/components/EmptyFileListMessage/FilterList.tsx +++ b/packages/core/components/EmptyFileListMessage/FilterList.tsx @@ -38,12 +38,9 @@ export default function FilterList(props: Props) { }, [textRef, filters]); const firstFilterValue = filters[0].value.toString(); - const operator = - filters.length > 1 - ? "for values of" - : firstFilterValue.includes("RANGE") - ? "between" - : "equal to"; + 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/FilterDisplayBar/FilterMedallion.tsx b/packages/core/components/FilterDisplayBar/FilterMedallion.tsx index f7039f841..8c73022b6 100644 --- a/packages/core/components/FilterDisplayBar/FilterMedallion.tsx +++ b/packages/core/components/FilterDisplayBar/FilterMedallion.tsx @@ -64,8 +64,9 @@ export default function FilterMedallion(props: Props) { }, [textRef, filters]); const firstFilterValue = filters[0].value.toString(); - const operator = - filters.length > 1 ? "ONE OF" : firstFilterValue.includes("RANGE") ? "BETWEEN" : "EQUALS"; + 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/NumberRangePicker/NumberRangePicker.module.css b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css index fc9f90dc4..18e386ed4 100644 --- a/packages/core/components/NumberRangePicker/NumberRangePicker.module.css +++ b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css @@ -60,7 +60,6 @@ label,input{ input::-webkit-outer-spin-button, input::-webkit-inner-spin-button { - /* display: none; <- Crashes Chrome on hover */ -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/state/metadata/selectors.ts b/packages/core/state/metadata/selectors.ts index 667599c93..4dadccec5 100644 --- a/packages/core/state/metadata/selectors.ts +++ b/packages/core/state/metadata/selectors.ts @@ -15,10 +15,8 @@ export const getSortedAnnotations = createSelector(getAnnotations, (annotations: Annotation.sort(annotations) ); -export const getSupportedAnnotations = createSelector( - getSortedAnnotations, - // TODO: Modify selector to be less case by case - (annotations) => Annotation.sort([...SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS, ...annotations]) +export const getSupportedAnnotations = createSelector(getSortedAnnotations, (annotations) => + Annotation.sort([...SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS, ...annotations]) ); export const getCustomAnnotationsCombinedWithFileAttributes = createSelector(