From 7742b04f208555f973756c46c48cf87df3b1272d Mon Sep 17 00:00:00 2001 From: SeanLeRoy Date: Fri, 15 Mar 2024 15:14:05 -0700 Subject: [PATCH 01/33] Check for data source before prompting --- .../Modal/DataSourcePrompt/index.tsx | 141 ++++++++++-------- 1 file changed, 76 insertions(+), 65 deletions(-) diff --git a/packages/core/components/Modal/DataSourcePrompt/index.tsx b/packages/core/components/Modal/DataSourcePrompt/index.tsx index 6cd4d8746..b366bac9b 100644 --- a/packages/core/components/Modal/DataSourcePrompt/index.tsx +++ b/packages/core/components/Modal/DataSourcePrompt/index.tsx @@ -42,6 +42,7 @@ export default function DataSourcePrompt({ onDismiss }: Props) { const annotations = useSelector(metadata.selectors.getAnnotations); const [isCheckingForDataSource, setIsCheckingForDataSource] = React.useState(true); + const [isLoading, setIsLoading] = React.useState(true); const [dataSourceURL, setDataSourceURL] = React.useState(""); const [isAICSEmployee, setIsAICSEmployee] = React.useState(false); const [isDataSourceDetailExpanded, setIsDataSourceDetailExpanded] = React.useState(false); @@ -63,6 +64,7 @@ export default function DataSourcePrompt({ onDismiss }: Props) { createdBy: "Unknown", }) ); + setIsLoading(false); onDismiss(); }, [databaseService, dispatch, onDismiss] @@ -72,9 +74,12 @@ export default function DataSourcePrompt({ onDismiss }: Props) { const matchingCollection = collections.find((c) => c.id === lastUsedCollection.id); if (matchingCollection) { dispatch(selection.actions.changeCollection(matchingCollection)); + setIsLoading(false); onDismiss(); } else if (lastUsedCollection.uri) { loadDataFromURI(lastUsedCollection.uri); + } else { + setIsLoading(false); } } @@ -126,79 +131,85 @@ export default function DataSourcePrompt({ onDismiss }: Props) { ); } - const body = ( - <> -

- Please provide a ".csv", ".parquet", or ".json" file - containing metadata about some files. See more details for information about what a - data source file should look like... -

- {isDataSourceDetailExpanded ? ( -
- {DATA_SOURCE_DETAILS.map((text) => ( -

- {text} -

- ))} + let body; + let footer = null; + if (isLoading) { + body = ; + } else { + body = ( + <> +

+ Please provide a ".csv", ".parquet", or ".json" + file containing metadata about some files. See more details for information + about what a data source file should look like... +

+ {isDataSourceDetailExpanded ? ( +
+ {DATA_SOURCE_DETAILS.map((text) => ( +

+ {text} +

+ ))} +
+ setIsDataSourceDetailExpanded(false)} + > + LESS  + + +
+
+ ) : (
setIsDataSourceDetailExpanded(false)} + onClick={() => setIsDataSourceDetailExpanded(true)} > - LESS  - + MORE  +
-
- ) : ( -
+ )} +
setIsDataSourceDetailExpanded(true)} - > - MORE  - - -
- )} -
- -
-
- or -
-
-
- setDataSourceURL(newValue || "")} - placeholder="Paste URL (ex. S3, Azure)" - value={dataSourceURL} + className={styles.browseButton} + ariaLabel="Browse for a data source file on your machine" + iconProps={{ iconName: "DocumentSearch" }} + onClick={onChooseFile} + text="Choose File" + title="Browse for a data source file on your machine" /> - -
- - ); - const footer = isAICSEmployee ? ( -

- Unable to connect to necessary server in the Allen Institute network. Check WiFi or VPN - connection. -

- ) : ( - - Allen Institute employee? - - ); +
+
+ or +
+
+
+ setDataSourceURL(newValue || "")} + placeholder="Paste URL (ex. S3, Azure)" + value={dataSourceURL} + /> + +
+ + ); + footer = isAICSEmployee ? ( +

+ Unable to connect to necessary server in the Allen Institute network. Check WiFi or + VPN connection. +

+ ) : ( + + Allen Institute employee? + + ); + } return ( Date: Fri, 15 Mar 2024 15:26:20 -0700 Subject: [PATCH 02/33] Adjust missing description spacing --- .../AnnotationListItem.module.css | 4 +++ .../AnnotationList/AnnotationListItem.tsx | 1 + .../FileService/DatabaseFileService/index.ts | 28 +++++++++++-------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/core/components/AnnotationList/AnnotationListItem.module.css b/packages/core/components/AnnotationList/AnnotationListItem.module.css index 58a9fe7c2..aacbb7794 100644 --- a/packages/core/components/AnnotationList/AnnotationListItem.module.css +++ b/packages/core/components/AnnotationList/AnnotationListItem.module.css @@ -13,6 +13,10 @@ fill: var(--blue); /* defined in App.module.css */ } +.missing-description { + margin-left: 12px; +} + .title { flex: 1 1 auto; text-overflow: ellipsis; diff --git a/packages/core/components/AnnotationList/AnnotationListItem.tsx b/packages/core/components/AnnotationList/AnnotationListItem.tsx index e743fcf88..f17613ab0 100644 --- a/packages/core/components/AnnotationList/AnnotationListItem.tsx +++ b/packages/core/components/AnnotationList/AnnotationListItem.tsx @@ -52,6 +52,7 @@ export default React.memo(function AnnotationListItem(props: AnnotationListItemP className={classNames( { [styles.disabled]: disabled, + [styles.missingDescription]: !description, }, styles.title )} diff --git a/packages/core/services/FileService/DatabaseFileService/index.ts b/packages/core/services/FileService/DatabaseFileService/index.ts index e76b4146e..4252773c0 100644 --- a/packages/core/services/FileService/DatabaseFileService/index.ts +++ b/packages/core/services/FileService/DatabaseFileService/index.ts @@ -5,7 +5,7 @@ import DatabaseService from "../../DatabaseService"; import DatabaseServiceNoop from "../../DatabaseService/DatabaseServiceNoop"; import FileSelection from "../../../entity/FileSelection"; import FileSet from "../../../entity/FileSet"; -import { TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../../constants"; +import { TOP_LEVEL_FILE_ANNOTATION_NAMES_WITH_MULTIPLE_CASINGS } from "../../constants"; interface Config { databaseService: DatabaseService; @@ -21,20 +21,24 @@ export default class DatabaseFileService implements FileService { row: { [key: string]: string }, rowNumber: number ): FmsFile { - if (!("file_path" in row)) { - throw new Error('"file_path" is a required column for data sources'); + const filePath = row["File Path"] || row["file_path"]; + if (!filePath) { + throw new Error('"File Path" (or "file_path") is a required column for data sources'); } + + const fileSize = row["File Size"] || row["file_size"]; return { - file_id: row["file_id"] || `${rowNumber}`, - file_name: + file_id: row["File ID"] || row["File Id"] || row["file_id"] || `${rowNumber}`, + file_name: + row["File Name"] || row["file_name"] || - row["file_path"].split("\\").pop()?.split("/").pop() || - row["file_path"], - file_path: row["file_path"], - file_size: "file_size" in row ? parseInt(row["file_size"], 10) : undefined, - uploaded: row["uploaded"], - thumbnail: row["thumbnail"], - annotations: Object.entries(omit(row, TOP_LEVEL_FILE_ANNOTATION_NAMES)).map( + filePath.split("\\").pop()?.split("/").pop() || + filePath, + file_path: filePath, + file_size: (fileSize !== undefined && fileSize !== null) ? parseInt(fileSize, 10) : undefined, + uploaded: row["Uploaded"] || row["uploaded"], + thumbnail: row["Thumbnail"] || row["thumbnail"], + annotations: Object.entries(omit(row, ...TOP_LEVEL_FILE_ANNOTATION_NAMES_WITH_MULTIPLE_CASINGS)).map( ([name, values]: any) => ({ name, values: `${values}`.split(",").map((value: string) => value.trim()), From 4bdd6ec4a48ed41a14ce3d30dc7b69b4bb6fa4a1 Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Wed, 20 Mar 2024 15:18:36 -0700 Subject: [PATCH 03/33] 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 04/33] 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 05/33] 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 06/33] 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 07/33] 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 08/33] 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 09/33] 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 10/33] 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 11/33] 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 12/33] 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( From b755bce8f08f8d56d3ce5ef594cc03735c7a7b51 Mon Sep 17 00:00:00 2001 From: BrianWhitneyAI Date: Tue, 2 Apr 2024 15:17:14 -0700 Subject: [PATCH 13/33] feature/persistant-columns --- .../services/PersistentConfigService/index.ts | 4 +++ packages/core/state/index.ts | 8 +++++ packages/core/state/metadata/logics.ts | 32 +++++++++++-------- packages/desktop/src/renderer/index.tsx | 12 +++++-- .../PersistentConfigServiceElectron.ts | 20 ++++++++++++ 5 files changed, 60 insertions(+), 16 deletions(-) diff --git a/packages/core/services/PersistentConfigService/index.ts b/packages/core/services/PersistentConfigService/index.ts index fac029820..541b320fd 100644 --- a/packages/core/services/PersistentConfigService/index.ts +++ b/packages/core/services/PersistentConfigService/index.ts @@ -1,9 +1,12 @@ +import { AnnotationResponse } from "../AnnotationService"; + /** * Keys for the data saved by this service */ export enum PersistedConfigKeys { AllenMountPoint = "ALLEN_MOUNT_POINT", CsvColumns = "CSV_COLUMNS", + DisplayAnnotations = "DISPLAY_ANNOTATIONS", ImageJExecutable = "IMAGE_J_EXECUTABLE", // Deprecated HasUsedApplicationBefore = "HAS_USED_APPLICATION_BEFORE", UserSelectedApplications = "USER_SELECTED_APPLICATIONS", @@ -16,6 +19,7 @@ export interface UserSelectedApplication { export interface PersistedConfig { [PersistedConfigKeys.CsvColumns]?: string[]; + [PersistedConfigKeys.DisplayAnnotations]?: AnnotationResponse[]; [PersistedConfigKeys.ImageJExecutable]?: string; // Deprecated [PersistedConfigKeys.HasUsedApplicationBefore]?: boolean; [PersistedConfigKeys.UserSelectedApplications]?: UserSelectedApplication[]; diff --git a/packages/core/state/index.ts b/packages/core/state/index.ts index f12904f05..cb4e02e15 100644 --- a/packages/core/state/index.ts +++ b/packages/core/state/index.ts @@ -8,6 +8,7 @@ import { PersistedConfig, PersistedConfigKeys } from "../services/PersistentConf import interaction, { InteractionStateBranch } from "./interaction"; import metadata, { MetadataStateBranch } from "./metadata"; import selection, { SelectionStateBranch } from "./selection"; +import Annotation from "../entity/Annotation"; export { interaction, metadata, selection }; @@ -63,6 +64,13 @@ export function createReduxStore(options: CreateStoreOptions = {}) { userSelectedApplications: persistedConfig && persistedConfig[PersistedConfigKeys.UserSelectedApplications], }, + selection: { + displayAnnotations: + persistedConfig && + persistedConfig[PersistedConfigKeys.DisplayAnnotations]?.map( + (annotation) => new Annotation(annotation) + ), + }, }); return configureStore({ middleware: [...(options.middleware || []), ...(middleware || [])], diff --git a/packages/core/state/metadata/logics.ts b/packages/core/state/metadata/logics.ts index 2cb3707df..81b774ba8 100644 --- a/packages/core/state/metadata/logics.ts +++ b/packages/core/state/metadata/logics.ts @@ -20,6 +20,7 @@ const requestAnnotations = createLogic({ const { getState, httpClient } = deps; const applicationVersion = interaction.selectors.getApplicationVersion(getState()); const baseUrl = interaction.selectors.getFileExplorerServiceBaseUrl(getState()); + const displayAnnotations = selection.selectors.getAnnotationsToDisplay(getState()); const annotationService = new AnnotationService({ applicationVersion, baseUrl, @@ -28,21 +29,26 @@ const requestAnnotations = createLogic({ try { const annotations = await annotationService.fetchAnnotations(); - const defaultDisplayAnnotations = compact([ - find( - TOP_LEVEL_FILE_ANNOTATIONS, - (annotation) => annotation.name === AnnotationName.FILE_NAME - ), - find(annotations, (annotation) => annotation.name === AnnotationName.KIND), - find(annotations, (annotation) => annotation.name === AnnotationName.TYPE), - find( - TOP_LEVEL_FILE_ANNOTATIONS, - (annotation) => annotation.name === AnnotationName.FILE_SIZE - ), - ]); + + if (!displayAnnotations.length) { + const defaultDisplayAnnotations = compact([ + find( + TOP_LEVEL_FILE_ANNOTATIONS, + (annotation) => annotation.name === AnnotationName.FILE_NAME + ), + find(annotations, (annotation) => annotation.name === AnnotationName.KIND), + find(annotations, (annotation) => annotation.name === AnnotationName.TYPE), + find( + TOP_LEVEL_FILE_ANNOTATIONS, + (annotation) => annotation.name === AnnotationName.FILE_SIZE + ), + ]); + dispatch( + selection.actions.selectDisplayAnnotation(defaultDisplayAnnotations, true) + ); + } dispatch(receiveAnnotations(annotations)); - dispatch(selection.actions.selectDisplayAnnotation(defaultDisplayAnnotations, true)); } catch (err) { console.error("Failed to fetch annotations", err); } finally { diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index adae2098f..e70324378 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -10,7 +10,7 @@ import { Provider } from "react-redux"; import FmsFileExplorer from "../../../core/App"; import { PersistedConfigKeys } from "../../../core/services"; -import { createReduxStore, interaction } from "../../../core/state"; +import { createReduxStore, interaction, selection } from "../../../core/state"; import ApplicationInfoServiceElectron from "../services/ApplicationInfoServiceElectron"; import ExecutionEnvServiceElectron from "../services/ExecutionEnvServiceElectron"; @@ -73,16 +73,22 @@ const store = createReduxStore({ store.subscribe(() => { const state = store.getState(); const csvColumns = interaction.selectors.getCsvColumns(state); + const displayAnnotations = selection.selectors.getAnnotationsToDisplay(state); const userSelectedApplications = interaction.selectors.getUserSelectedApplications(state); const hasUsedApplicationBefore = interaction.selectors.hasUsedApplicationBefore(state); const appState = { [PersistedConfigKeys.CsvColumns]: csvColumns, + [PersistedConfigKeys.DisplayAnnotations]: displayAnnotations.map((annotation) => ({ + annotationDisplayName: annotation.displayName, + annotationName: annotation.name, + description: annotation.description, + type: annotation.type, + })), [PersistedConfigKeys.UserSelectedApplications]: userSelectedApplications, }; if (JSON.stringify(appState) !== JSON.stringify(persistentConfigService.getAll())) { persistentConfigService.persist({ - [PersistedConfigKeys.CsvColumns]: csvColumns, - [PersistedConfigKeys.UserSelectedApplications]: userSelectedApplications, + ...appState, [PersistedConfigKeys.HasUsedApplicationBefore]: hasUsedApplicationBefore, }); } diff --git a/packages/desktop/src/services/PersistentConfigServiceElectron.ts b/packages/desktop/src/services/PersistentConfigServiceElectron.ts index 17815a6cd..ee3ca6216 100644 --- a/packages/desktop/src/services/PersistentConfigServiceElectron.ts +++ b/packages/desktop/src/services/PersistentConfigServiceElectron.ts @@ -22,6 +22,26 @@ const OPTIONS: Options> = { type: "string", }, }, + [PersistedConfigKeys.DisplayAnnotations]: { + type: "array", + items: { + type: "object", + properties: { + annotationDisplayName: { + type: "string", + }, + annotationName: { + type: "string", + }, + description: { + type: "string", + }, + type: { + type: "string", + }, + }, + }, + }, // ImageJExecutable is Deprecated [PersistedConfigKeys.ImageJExecutable]: { type: "string", From 13a90d06761f5664cbbdf385ad7ea1988bb70070 Mon Sep 17 00:00:00 2001 From: BrianWhitneyAI Date: Wed, 3 Apr 2024 15:05:07 -0700 Subject: [PATCH 14/33] bugfix/deselect-not-persisting --- packages/core/state/selection/actions.ts | 8 ++++++-- packages/core/state/selection/reducer.ts | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts index d30ff0a62..24dbdef3c 100644 --- a/packages/core/state/selection/actions.ts +++ b/packages/core/state/selection/actions.ts @@ -157,7 +157,9 @@ export const DESELECT_DISPLAY_ANNOTATION = makeConstant( ); export interface DeselectDisplayAnnotationAction { - payload: Annotation | Annotation[]; + payload: { + annotation: Annotation | Annotation[]; + }; type: string; } @@ -165,7 +167,9 @@ export function deselectDisplayAnnotation( annotation: Annotation | Annotation[] ): DeselectDisplayAnnotationAction { return { - payload: annotation, + payload: { + annotation, + }, type: DESELECT_DISPLAY_ANNOTATION, }; } diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts index 0148d01a2..7ff01b00e 100644 --- a/packages/core/state/selection/reducer.ts +++ b/packages/core/state/selection/reducer.ts @@ -1,5 +1,5 @@ import { makeReducer } from "@aics/redux-utils"; -import { castArray, difference, omit, without } from "lodash"; +import { castArray, difference, omit } from "lodash"; import interaction from "../interaction"; import { AnnotationName, PAST_YEAR_FILTER } from "../../constants"; @@ -123,9 +123,9 @@ export default makeReducer( sortColumn: action.payload, }), [DESELECT_DISPLAY_ANNOTATION]: (state, action) => { - const displayAnnotations = without( - state.displayAnnotations, - ...castArray(action.payload) + // remove deselected annotation from state.displayAnnotations + const displayAnnotations = state.displayAnnotations.filter( + (annotation) => annotation.name !== action.payload.annotation.name ); const columnWidthsToPrune = difference( From 21ee35bc53ef0cb252e1995aa28bcf8a216028f1 Mon Sep 17 00:00:00 2001 From: BrianWhitneyAI Date: Thu, 4 Apr 2024 15:15:18 -0700 Subject: [PATCH 15/33] add persistant display annotations test --- .../PersistentConfigServiceElectron.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts index 5d05ef1b0..ff2d1667f 100644 --- a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts +++ b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts @@ -45,6 +45,17 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { name: "ZEN", }, ]; + + const expectedDisplayAnnotations = [ + { + annotationDisplayName: "Foo", + annotationName: "foo", + description: "foo-long", + type: "string", + units: "string", + }, + ]; + service.persist(PersistedConfigKeys.AllenMountPoint, expectedAllenMountPoint); service.persist(PersistedConfigKeys.CsvColumns, expectedCsvColumns); service.persist(PersistedConfigKeys.ImageJExecutable, expectedImageJExecutable); @@ -53,12 +64,15 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { expectedHasUsedApplicationBefore ); service.persist(PersistedConfigKeys.UserSelectedApplications, expectedUserSelectedApps); + service.persist(PersistedConfigKeys.DisplayAnnotations, expectedDisplayAnnotations); + const expectedConfig = { [PersistedConfigKeys.AllenMountPoint]: expectedAllenMountPoint, [PersistedConfigKeys.CsvColumns]: expectedCsvColumns, [PersistedConfigKeys.ImageJExecutable]: expectedImageJExecutable, [PersistedConfigKeys.HasUsedApplicationBefore]: expectedHasUsedApplicationBefore, [PersistedConfigKeys.UserSelectedApplications]: expectedUserSelectedApps, + [PersistedConfigKeys.DisplayAnnotations]: expectedDisplayAnnotations, }; // Act @@ -85,6 +99,15 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { name: "ImageJ/Fiji", }, ], + [PersistedConfigKeys.DisplayAnnotations]: [ + { + annotationDisplayName: "Foo", + annotationName: "foo", + description: "foo-long", + type: "string", + units: "string", + }, + ], }; // Act From 61f7cbfb5dbd0e83ea6f8959e80caaaaefab87a7 Mon Sep 17 00:00:00 2001 From: BrianWhitneyAI Date: Fri, 5 Apr 2024 11:27:38 -0700 Subject: [PATCH 16/33] adjust dispatch ordering --- packages/core/state/metadata/logics.ts | 46 +++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/core/state/metadata/logics.ts b/packages/core/state/metadata/logics.ts index 81b774ba8..1c93a299b 100644 --- a/packages/core/state/metadata/logics.ts +++ b/packages/core/state/metadata/logics.ts @@ -10,6 +10,7 @@ import { } from "./actions"; import { AnnotationName, TOP_LEVEL_FILE_ANNOTATIONS } from "../../constants"; import AnnotationService from "../../services/AnnotationService"; +import Annotation from "../../entity/Annotation"; /** * Interceptor responsible for turning REQUEST_ANNOTATIONS action into a network call for available annotations. Outputs @@ -27,33 +28,32 @@ const requestAnnotations = createLogic({ httpClient, }); - try { - const annotations = await annotationService.fetchAnnotations(); - - if (!displayAnnotations.length) { - const defaultDisplayAnnotations = compact([ - find( - TOP_LEVEL_FILE_ANNOTATIONS, - (annotation) => annotation.name === AnnotationName.FILE_NAME - ), - find(annotations, (annotation) => annotation.name === AnnotationName.KIND), - find(annotations, (annotation) => annotation.name === AnnotationName.TYPE), - find( - TOP_LEVEL_FILE_ANNOTATIONS, - (annotation) => annotation.name === AnnotationName.FILE_SIZE - ), - ]); - dispatch( - selection.actions.selectDisplayAnnotation(defaultDisplayAnnotations, true) - ); - } + let annotations: Annotation[] = []; - dispatch(receiveAnnotations(annotations)); + try { + annotations = await annotationService.fetchAnnotations(); } catch (err) { console.error("Failed to fetch annotations", err); - } finally { - done(); } + + dispatch(receiveAnnotations(annotations)); + + if (!displayAnnotations.length) { + const defaultDisplayAnnotations = compact([ + find( + TOP_LEVEL_FILE_ANNOTATIONS, + (annotation) => annotation.name === AnnotationName.FILE_NAME + ), + find(annotations, (annotation) => annotation.name === AnnotationName.KIND), + find(annotations, (annotation) => annotation.name === AnnotationName.TYPE), + find( + TOP_LEVEL_FILE_ANNOTATIONS, + (annotation) => annotation.name === AnnotationName.FILE_SIZE + ), + ]); + dispatch(selection.actions.selectDisplayAnnotation(defaultDisplayAnnotations, true)); + } + done(); }, type: REQUEST_ANNOTATIONS, }); From f3b21abbf790b9287163be67ba25e771407927e0 Mon Sep 17 00:00:00 2001 From: BrianWhitneyAI Date: Fri, 5 Apr 2024 15:06:19 -0700 Subject: [PATCH 17/33] comment resolution --- packages/core/state/metadata/logics.ts | 5 +++-- packages/desktop/src/renderer/index.tsx | 9 ++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/core/state/metadata/logics.ts b/packages/core/state/metadata/logics.ts index 1c93a299b..f12ef8f4e 100644 --- a/packages/core/state/metadata/logics.ts +++ b/packages/core/state/metadata/logics.ts @@ -32,12 +32,13 @@ const requestAnnotations = createLogic({ try { annotations = await annotationService.fetchAnnotations(); + dispatch(receiveAnnotations(annotations)); } catch (err) { console.error("Failed to fetch annotations", err); + done(); + return; } - dispatch(receiveAnnotations(annotations)); - if (!displayAnnotations.length) { const defaultDisplayAnnotations = compact([ find( diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index e70324378..b8c63908e 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -75,7 +75,6 @@ store.subscribe(() => { const csvColumns = interaction.selectors.getCsvColumns(state); const displayAnnotations = selection.selectors.getAnnotationsToDisplay(state); const userSelectedApplications = interaction.selectors.getUserSelectedApplications(state); - const hasUsedApplicationBefore = interaction.selectors.hasUsedApplicationBefore(state); const appState = { [PersistedConfigKeys.CsvColumns]: csvColumns, [PersistedConfigKeys.DisplayAnnotations]: displayAnnotations.map((annotation) => ({ @@ -85,13 +84,9 @@ store.subscribe(() => { type: annotation.type, })), [PersistedConfigKeys.UserSelectedApplications]: userSelectedApplications, + [PersistedConfigKeys.HasUsedApplicationBefore]: true, }; - if (JSON.stringify(appState) !== JSON.stringify(persistentConfigService.getAll())) { - persistentConfigService.persist({ - ...appState, - [PersistedConfigKeys.HasUsedApplicationBefore]: hasUsedApplicationBefore, - }); - } + persistentConfigService.persist(appState); }); function renderFmsFileExplorer() { From 00ee7c7664b1d2d38a3b63eec2dbdc41fc7bb0cb Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Mon, 15 Apr 2024 11:30:20 -0700 Subject: [PATCH 18/33] update state and constants to support thumbnail view --- packages/core/constants/index.ts | 7 ++++ packages/core/state/selection/actions.ts | 39 ++++++++++++++++++++++ packages/core/state/selection/reducer.ts | 16 ++++++++- packages/core/state/selection/selectors.ts | 3 ++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/core/constants/index.ts b/packages/core/constants/index.ts index 40fb3bdaf..49929ea83 100644 --- a/packages/core/constants/index.ts +++ b/packages/core/constants/index.ts @@ -153,3 +153,10 @@ export const SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS = TOP_LEVEL_FILE_ANNOTATIONS. ); export const TOP_LEVEL_FILE_ANNOTATION_NAMES = TOP_LEVEL_FILE_ANNOTATIONS.map((a) => a.name); + +export const THUMBNAIL_SIZE_TO_NUM_COLUMNS = { + LARGE: 5, + SMALL: 10, +}; + +export const RENDERABLE_IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".gif"]; diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts index d30ff0a62..1cd640a21 100644 --- a/packages/core/state/selection/actions.ts +++ b/packages/core/state/selection/actions.ts @@ -542,3 +542,42 @@ export function adjustGlobalFontSize(shouldDisplaySmallFont: boolean): AdjustGlo type: ADJUST_GLOBAL_FONT_SIZE, }; } + +/** + * SET_FILE_VIEW_TYPE + * + * Intention to set the file view type to thumbnail or list + */ +export const SET_FILE_THUMBNAIL_VIEW = makeConstant(STATE_BRANCH_NAME, "set-file-thumbnail-view"); + +export interface SetFileThumbnailView { + payload: boolean; + type: string; +} + +export function setFileThumbnailView(shouldDisplayThumbnailView: boolean): SetFileThumbnailView { + return { + payload: shouldDisplayThumbnailView, + type: SET_FILE_THUMBNAIL_VIEW, + }; +} + +/** + * SET_FILE_GRID_COLUMN_COUNT + */ +export const SET_FILE_GRID_COLUMN_COUNT = makeConstant( + STATE_BRANCH_NAME, + "set-file-grid-column-count" +); + +export interface SetFileGridColumnCount { + payload: number; + type: string; +} + +export function setFileGridColumnCount(fileGridColumnCount: number): SetFileGridColumnCount { + return { + payload: fileGridColumnCount, + type: SET_FILE_GRID_COLUMN_COUNT, + }; +} diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts index 0148d01a2..b82f9b1dc 100644 --- a/packages/core/state/selection/reducer.ts +++ b/packages/core/state/selection/reducer.ts @@ -2,7 +2,7 @@ import { makeReducer } from "@aics/redux-utils"; import { castArray, difference, omit, without } from "lodash"; import interaction from "../interaction"; -import { AnnotationName, PAST_YEAR_FILTER } from "../../constants"; +import { AnnotationName, PAST_YEAR_FILTER, THUMBNAIL_SIZE_TO_NUM_COLUMNS } from "../../constants"; import Annotation from "../../entity/Annotation"; import FileFilter from "../../entity/FileFilter"; import FileFolder from "../../entity/FileFolder"; @@ -24,6 +24,8 @@ import { CHANGE_VIEW, SELECT_TUTORIAL, ADJUST_GLOBAL_FONT_SIZE, + SET_FILE_THUMBNAIL_VIEW, + SET_FILE_GRID_COLUMN_COUNT, } from "./actions"; import FileSort, { SortOrder } from "../../entity/FileSort"; import Tutorial from "../../entity/Tutorial"; @@ -38,10 +40,12 @@ export interface SelectionStateBranch { [index: string]: number; // columnName to widthPercent mapping }; displayAnnotations: Annotation[]; + fileGridColumnCount: number; fileSelection: FileSelection; filters: FileFilter[]; openFileFolders: FileFolder[]; shouldDisplaySmallFont: boolean; + shouldDisplayThumbnailView: boolean; sortColumn?: FileSort; tutorial?: Tutorial; } @@ -57,10 +61,12 @@ export const initialState = { [AnnotationName.FILE_SIZE]: 0.15, }, displayAnnotations: [], + fileGridColumnCount: THUMBNAIL_SIZE_TO_NUM_COLUMNS.LARGE, fileSelection: new FileSelection(), filters: [PAST_YEAR_FILTER], openFileFolders: [], shouldDisplaySmallFont: false, + shouldDisplayThumbnailView: false, sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC), }; @@ -74,6 +80,14 @@ export default makeReducer( ...state, shouldDisplaySmallFont: action.payload, }), + [SET_FILE_THUMBNAIL_VIEW]: (state, action) => ({ + ...state, + shouldDisplayThumbnailView: action.payload, + }), + [SET_FILE_GRID_COLUMN_COUNT]: (state, action) => ({ + ...state, + fileGridColumnCount: action.payload, + }), [SET_FILE_FILTERS]: (state, action) => ({ ...state, filters: action.payload, diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts index 0cc560a85..e6b4ec88a 100644 --- a/packages/core/state/selection/selectors.ts +++ b/packages/core/state/selection/selectors.ts @@ -19,11 +19,14 @@ export const getAvailableAnnotationsForHierarchy = (state: State) => export const getAvailableAnnotationsForHierarchyLoading = (state: State) => state.selection.availableAnnotationsForHierarchyLoading; export const getColumnWidths = (state: State) => state.selection.columnWidths; +export const getFileGridColumnCount = (state: State) => state.selection.fileGridColumnCount; export const getFileFilters = (state: State) => state.selection.filters; export const getCollection = (state: State) => state.selection.collection; export const getFileSelection = (state: State) => state.selection.fileSelection; export const getOpenFileFolders = (state: State) => state.selection.openFileFolders; export const getShouldDisplaySmallFont = (state: State) => state.selection.shouldDisplaySmallFont; +export const getShouldDisplayThumbnailView = (state: State) => + state.selection.shouldDisplayThumbnailView; export const getSortColumn = (state: State) => state.selection.sortColumn; export const getTutorial = (state: State) => state.selection.tutorial; From 63ea29a3a8dabd6eba303e82373959dcc672ecc2 Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Mon, 15 Apr 2024 11:54:14 -0700 Subject: [PATCH 19/33] create thumbnail view for file list --- .../FileDetails/FileDetails.module.css | 2 +- .../core/components/FileDetails/index.tsx | 69 ++++++- .../LazilyRenderedThumbnail.module.css | 25 +++ .../FileList/LazilyRenderedThumbnail.tsx | 173 ++++++++++++++++++ packages/core/components/FileList/index.tsx | 110 +++++++++-- 5 files changed, 363 insertions(+), 16 deletions(-) create mode 100644 packages/core/components/FileList/LazilyRenderedThumbnail.module.css create mode 100644 packages/core/components/FileList/LazilyRenderedThumbnail.tsx diff --git a/packages/core/components/FileDetails/FileDetails.module.css b/packages/core/components/FileDetails/FileDetails.module.css index 85c245e8c..f891a330e 100644 --- a/packages/core/components/FileDetails/FileDetails.module.css +++ b/packages/core/components/FileDetails/FileDetails.module.css @@ -114,7 +114,7 @@ } .font-size-button { - background-color: darkgrey; + background-color: lightgrey; border-radius: 0; font-size: 10px; font-weight: normal; diff --git a/packages/core/components/FileDetails/index.tsx b/packages/core/components/FileDetails/index.tsx index e4d0ac0bb..278fcca90 100644 --- a/packages/core/components/FileDetails/index.tsx +++ b/packages/core/components/FileDetails/index.tsx @@ -1,4 +1,4 @@ -import { ActionButton, IButtonStyles } from "@fluentui/react"; +import { ActionButton, IButtonStyles, Icon } from "@fluentui/react"; import classNames from "classnames"; import * as React from "react"; import { useDispatch, useSelector } from "react-redux"; @@ -15,6 +15,7 @@ import { ROOT_ELEMENT_ID } from "../../App"; import { selection } from "../../state"; import SvgIcon from "../../components/SvgIcon"; import { NO_IMAGE_ICON_PATH_DATA } from "../../icons"; +import { RENDERABLE_IMAGE_FORMATS, THUMBNAIL_SIZE_TO_NUM_COLUMNS } from "../../constants"; import styles from "./FileDetails.module.css"; @@ -126,7 +127,11 @@ export default function FileDetails(props: FileDetails) { const globalDispatch = useDispatch(); const [windowState, windowDispatch] = React.useReducer(windowStateReducer, INITIAL_STATE); const [fileDetails, isLoading] = useFileDetails(); + const fileGridColumnCount = useSelector(selection.selectors.getFileGridColumnCount); const shouldDisplaySmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont); + const shouldDisplayThumbnailView = useSelector( + selection.selectors.getShouldDisplayThumbnailView + ); // If FileDetails pane is minimized, set its width to the width of the WindowActionButtons. Else, let it be // defined by whatever the CSS determines (setting an inline style to undefined will prompt ReactDOM to not apply @@ -164,8 +169,7 @@ export default function FileDetails(props: FileDetails) {
    ); } else if (fileDetails) { - const renderableImageFormats = [".jpg", ".jpeg", ".png", ".gif"]; - const isFileRenderableImage = renderableImageFormats.some((format) => + const isFileRenderableImage = RENDERABLE_IMAGE_FORMATS.some((format) => fileDetails?.name.toLowerCase().endsWith(format) ); if (isFileRenderableImage) { @@ -226,6 +230,65 @@ export default function FileDetails(props: FileDetails) { [styles.hidden]: windowState.state === WindowState.MINIMIZED, })} /> + { + globalDispatch(selection.actions.setFileThumbnailView(true)); + globalDispatch( + selection.actions.setFileGridColumnCount( + THUMBNAIL_SIZE_TO_NUM_COLUMNS.LARGE + ) + ); + }} + title="Large thumbnail view" + > + + + + { + globalDispatch(selection.actions.setFileThumbnailView(true)); + globalDispatch( + selection.actions.setFileGridColumnCount( + THUMBNAIL_SIZE_TO_NUM_COLUMNS.SMALL + ) + ); + }} + title="Small thumbnail view" + > + + + + globalDispatch( + selection.actions.setFileThumbnailView(!shouldDisplayThumbnailView) + ) + } + title="List view" + > + +
    void; + onSelect: OnSelect; +} + +interface LazilyRenderedThumbnailProps { + columnIndex: number; // injected by react-window + data: LazilyRenderedThumbnailContext; // injected by react-window + rowIndex: number; // injected by react-window + style: React.CSSProperties; // injected by react-window +} + +const MARGIN = 20; // px; + +/** + * A single file in the listing of available files FMS. + * Follows the pattern set by LazilyRenderedRow + */ +export default function LazilyRenderedThumbnail(props: LazilyRenderedThumbnailProps) { + const { + data: { fileSet, itemCount, measuredWidth, onContextMenu, onSelect }, + columnIndex, + rowIndex, + style, + } = props; + + const shouldDisplaySmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont); + const fileSelection = useSelector(selection.selectors.getFileSelection); + const fileGridColCount = useSelector(selection.selectors.getFileGridColumnCount); + const overallIndex = fileGridColCount * rowIndex + columnIndex; + const file = fileSet.getFileByIndex(overallIndex); + const thumbnailSize = measuredWidth / fileGridColCount - 2 * MARGIN; + + const isSelected = React.useMemo(() => { + return fileSelection.isSelected(fileSet, overallIndex); + }, [fileSelection, fileSet, overallIndex]); + + const isFocused = React.useMemo(() => { + return fileSelection.isFocused(fileSet, overallIndex); + }, [fileSelection, fileSet, overallIndex]); + + const onClick = (evt: React.MouseEvent) => { + evt.preventDefault(); + evt.stopPropagation(); + + if (onSelect && file !== undefined) { + onSelect( + { index: overallIndex, id: file.file_id }, + { + // Details on different OS keybindings + // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent#Properties + ctrlKeyIsPressed: evt.ctrlKey || evt.metaKey, + shiftKeyIsPressed: evt.shiftKey, + } + ); + } + }; + + // Display the start of the file name and at least part of the file type + const clipFileName = (filename: string) => { + if (fileGridColCount === THUMBNAIL_SIZE_TO_NUM_COLUMNS.SMALL && filename.length > 15) { + return filename.slice(0, 6) + "..." + filename.slice(-4); + } else if (filename.length > 20) { + return filename.slice(0, 9) + "..." + filename.slice(-8); + } + return filename; + }; + + // If the file has a thumbnail image specified, we want to display the specified thumbnail. + // Otherwise, we want to display the file itself as the thumbnail if possible. + // If there is no thumbnail and the file cannot be displayed as the thumbnail, show a no image icon + // TODO: Add custom icons per file type + let thumbnail = ( + + ); + if (file?.thumbnail) { + // thumbnail exists + thumbnail = ( +
    + +
    + ); + } else if (file) { + const isFileRenderableImage = RENDERABLE_IMAGE_FORMATS.some((format) => + file?.file_name.toLowerCase().endsWith(format) + ); + if (isFileRenderableImage) { + // render the image as the thumbnail + thumbnail = ( +
    + +
    + ); + } + } + + let content; + if (file) { + const filenameForRender = clipFileName(file?.file_name); + content = ( +
    + {thumbnail} +
    + {filenameForRender} +
    +
    + ); + } else if (overallIndex < itemCount) { + // Grid will attempt to render a cell even if we're past the total index + content = "Loading..."; + } // No `else` since if past total index we stil want empty content to fill up the outer grid + + return ( +
    + {content} +
    + ); +} diff --git a/packages/core/components/FileList/index.tsx b/packages/core/components/FileList/index.tsx index 6bb07d8b5..b82b31cc4 100644 --- a/packages/core/components/FileList/index.tsx +++ b/packages/core/components/FileList/index.tsx @@ -3,12 +3,13 @@ import debouncePromise from "debounce-promise"; import { defaults, isFunction } from "lodash"; import * as React from "react"; import { useSelector } from "react-redux"; -import { FixedSizeList } from "react-window"; +import { FixedSizeGrid, FixedSizeList } from "react-window"; import InfiniteLoader from "react-window-infinite-loader"; import FileSet from "../../entity/FileSet"; import Header from "./Header"; import LazilyRenderedRow from "./LazilyRenderedRow"; +import LazilyRenderedThumbnail from "./LazilyRenderedThumbnail"; import { selection } from "../../state"; import useLayoutMeasurements from "../../hooks/useLayoutMeasurements"; import useFileSelector from "./useFileSelector"; @@ -36,6 +37,8 @@ const DEFAULTS = { }; const MAX_NON_ROOT_HEIGHT = 300; +const SMALL_ROW_HEIGHT = 18; +const TALL_ROW_HEIGHT = 22; /** * Wrapper for react-window-infinite-loader and react-window that knows how to lazily fetch its own data. It will lay @@ -45,8 +48,17 @@ export default function FileList(props: FileListProps) { const [totalCount, setTotalCount] = React.useState(null); const fileSelection = useSelector(selection.selectors.getFileSelection); const isDisplayingSmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont); + const shouldDisplayThumbnailView = useSelector( + selection.selectors.getShouldDisplayThumbnailView + ); + const fileGridColumnCount = useSelector(selection.selectors.getFileGridColumnCount); + const [measuredNodeRef, measuredHeight, measuredWidth] = useLayoutMeasurements< + HTMLDivElement + >(); + let defaultRowHeight = isDisplayingSmallFont ? SMALL_ROW_HEIGHT : TALL_ROW_HEIGHT; + if (shouldDisplayThumbnailView) defaultRowHeight = measuredWidth / fileGridColumnCount; const { className, fileSet, isRoot, rowHeight, sortOrder } = defaults({}, props, DEFAULTS, { - rowHeight: isDisplayingSmallFont ? 18 : 22, + rowHeight: defaultRowHeight, }); const onSelect = useFileSelector(fileSet, sortOrder); @@ -58,24 +70,29 @@ export default function FileList(props: FileListProps) { // 100% of the height of its container. // Otherwise, the height of the list should reflect the number of items it has to render, up to // a certain maximum. - const [measuredNodeRef, measuredHeight, measuredWidth] = useLayoutMeasurements< - HTMLDivElement - >(); const dataDrivenHeight = rowHeight * (totalCount || DEFAULT_TOTAL_COUNT) + 3 * rowHeight; // adding three additional rowHeights leaves room for the header + horz. scroll bar const calculatedHeight = Math.min(MAX_NON_ROOT_HEIGHT, dataDrivenHeight); const height = isRoot ? measuredHeight : calculatedHeight; const listRef = React.useRef(null); + const gridRef = React.useRef(null); const outerRef = React.useRef(null); // This hook is responsible for ensuring that if the details pane is currently showing a file row // within this FileList the file row shown in the details pane is scrolled into view. React.useEffect(() => { - if (listRef.current && outerRef.current && fileSelection.isFocused(fileSet)) { + if ( + (listRef.current || gridRef.current) && + outerRef.current && + fileSelection.isFocused(fileSet) + ) { const { indexWithinFileSet } = fileSelection.getFocusedItemIndices(); if (indexWithinFileSet !== undefined) { const listScrollTop = outerRef.current.scrollTop; - const focusedItemTop = indexWithinFileSet * rowHeight; + let focusedItemTop = indexWithinFileSet * rowHeight; + if (gridRef.current) { + focusedItemTop = (indexWithinFileSet / fileGridColumnCount) * rowHeight; + } const focusedItemBottom = focusedItemTop + rowHeight; const headerHeight = 40; // px; defined in Header.module.css; stickily sits on top of the list const visibleArea = height - headerHeight; @@ -88,11 +105,17 @@ export default function FileList(props: FileListProps) { const centeredWithinVisibleArea = Math.floor( centerOfFocusedItem - visibleArea / 2 ); - listRef.current.scrollTo(Math.max(0, centeredWithinVisibleArea)); + if (listRef.current) { + listRef.current.scrollTo(Math.max(0, centeredWithinVisibleArea)); + } else if (gridRef.current) { + gridRef.current.scrollTo({ + scrollTop: Math.max(0, centeredWithinVisibleArea), + }); + } } } } - }, [fileSelection, fileSet, height, rowHeight]); + }, [fileSelection, fileSet, height, fileGridColumnCount, rowHeight]); // Get a count of all files in the FileList, but don't wait on it React.useEffect(() => { @@ -126,7 +149,7 @@ export default function FileList(props: FileListProps) { itemCount={totalCount || DEFAULT_TOTAL_COUNT} > {({ onItemsRendered, ref: innerRef }) => { - const callbackRef = (instance: FixedSizeList | null) => { + const callbackRefList = (instance: FixedSizeList | null) => { listRef.current = instance; // react-window-infinite-loader takes a reference to the List component instance: @@ -135,8 +158,69 @@ export default function FileList(props: FileListProps) { innerRef(instance); } }; + const callbackRefGrid = (instance: FixedSizeGrid | null) => { + gridRef.current = instance; + if (isFunction(innerRef)) { + innerRef(instance); + } + }; + // Custom onItemsRendered for grids + // The built-in onItemsRendered from InfiniteLoader only supports lists + const onGridItemsRendered = (gridData: any) => { + const { + visibleRowStartIndex, + visibleRowStopIndex, + visibleColumnStopIndex, + overscanRowStartIndex, + overscanRowStopIndex, + overscanColumnStopIndex, + } = gridData; + + // Convert injected grid props to InfiniteLoader list props + const visibleStartIndex = + visibleRowStartIndex * (visibleColumnStopIndex + 1); + const visibleStopIndex = + visibleRowStopIndex * (visibleColumnStopIndex + 1); + const overscanStartIndex = + overscanRowStartIndex * (overscanColumnStopIndex + 1); + const overscanStopIndex = + overscanRowStopIndex * (overscanColumnStopIndex + 1); - return ( + onItemsRendered({ + // call onItemsRendered from InfiniteLoader + visibleStartIndex, + visibleStopIndex, + overscanStartIndex, + overscanStopIndex, + }); + }; + + const fixedSizeGrid = ( + + {LazilyRenderedThumbnail} + + ); + + const fixedSizeList = ( {LazilyRenderedRow} ); + + return shouldDisplayThumbnailView ? fixedSizeGrid : fixedSizeList; }} ); From 3cff413a3413af5e31e335ee15d082374faff0ed Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Mon, 15 Apr 2024 11:54:41 -0700 Subject: [PATCH 20/33] add unit tests for thumbnail component --- .../test/LazilyRenderedThumbnail.test.tsx | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx diff --git a/packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx b/packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx new file mode 100644 index 000000000..e65917d40 --- /dev/null +++ b/packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx @@ -0,0 +1,202 @@ +import { configureMockStore, mergeState } from "@aics/redux-utils"; +import { render } from "@testing-library/react"; +import { expect } from "chai"; +import * as React from "react"; +import { Provider } from "react-redux"; +import * as sinon from "sinon"; + +import LazilyRenderedThumbnail from "../LazilyRenderedThumbnail"; +import { initialState } from "../../../state"; +import FileSet from "../../../entity/FileSet"; + +describe("", () => { + function makeItemData() { + const fileSet = new FileSet(); + sinon.stub(fileSet, "getFileByIndex").callsFake((index) => { + if (index === 0) { + return { + annotations: [], + file_id: "abc1230", + file_name: "my_image0.czi", + file_path: "some/path/to/my_image0.czi", + file_size: 1, + thumbnail: "some/path/to/my_image0.jpg", + uploaded: new Date().toISOString(), + }; + } + if (index === 9) { + return { + annotations: [], + file_id: "abc1239", + file_name: "my_image9.jpg", + file_path: "some/path/to/my_image9.jpg", + file_size: 1, + thumbnail: "", + uploaded: new Date().toISOString(), + }; + } + if (index === 25) { + return { + annotations: [], + file_id: "abc12325", + file_name: "my_image25.czi", + file_path: "some/path/to/my_image25.czi", + file_size: 1, + thumbnail: "", + uploaded: new Date().toISOString(), + }; + } + }); + + return { + fileSet, + measuredWidth: 600, + itemCount: 100, + onContextMenu: sinon.spy(), + onSelect: sinon.spy(), + }; + } + + it("renders thumbnail when file has one specified", () => { + // Arrange + const state = mergeState(initialState, {}); + const { store } = configureMockStore({ state }); + + // Act + const { getByText, getByRole } = render( + + + + ); + + // Assert + // Also checking for proper row/col indexing + const thumbnail = getByRole("img"); + expect(thumbnail.getAttribute("src")).to.include("some/path/to/my_image0.jpg"); + expect(getByText("my_image0.czi")).to.not.equal(null); + }); + + it("renders file as thumbnail if file is renderable type", () => { + // Arrange + const { store } = configureMockStore({ state: initialState }); + + // Act + const { getByText, getByRole } = render( + + + + ); + + // Assert + // Also confirms proper row/col indexing + const thumbnail = getByRole("img"); + expect(thumbnail.getAttribute("src")).to.include("some/path/to/my_image9.jpg"); + expect(getByText("my_image9.jpg")).to.not.equal(null); + }); + + it("renders svg as thumbnail if file has no renderable thumbnail", () => { + // Arrange + const { store } = configureMockStore({ state: initialState }); + + // Act + const { getByText, queryByRole } = render( + + + + ); + + // Assert + // Also confirms proper row/col indexing + expect(".no-thumbnail").to.exist; + expect(".svg").to.exist; + expect(queryByRole("img")).not.to.exist; + expect(getByText("my_image25.czi")).to.not.equal(null); + }); + + it("renders a loading indicator when data is not available", () => { + // Arrange + const { store } = configureMockStore({ state: initialState }); + + // Act + const { queryByText } = render( + + + + ); + + // Assert + expect(queryByText("my_image")).to.equal(null); + expect(queryByText("Loading...")).to.not.equal(null); + }); + + // We want to be able to render empty cells past the total item count in order to fill the grid + it("renders an empty cell if the index is past the total item count", () => { + // Arrange + const { store } = configureMockStore({ state: initialState }); + + // Act + const { queryByText } = render( + + + + ); + + // Assert + expect(queryByText("my_image")).to.equal(null); + expect(queryByText("Loading...")).to.equal(null); + }); + + it("renders and indexes correctly with different number of columns", () => { + // Arrange + const state = { + ...initialState, + selection: { + ...initialState.selection, + fileGridColumnCount: 10, + }, + }; + const { store } = configureMockStore({ state }); + + // Act + const { getByText } = render( + + + + ); + + // Assert + expect(".no-thumbnail").to.exist; + expect(".svg").to.exist; + expect(getByText("my_image25.czi")).to.not.equal(null); + }); +}); From ec4660524ba6b1acd8b9303b78780e911e2ff219 Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Thu, 18 Apr 2024 10:39:20 -0700 Subject: [PATCH 21/33] remove empty line --- packages/core/components/FileDetails/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/components/FileDetails/index.tsx b/packages/core/components/FileDetails/index.tsx index 278fcca90..aa355c09c 100644 --- a/packages/core/components/FileDetails/index.tsx +++ b/packages/core/components/FileDetails/index.tsx @@ -252,7 +252,6 @@ export default function FileDetails(props: FileDetails) { >
    - Date: Fri, 19 Apr 2024 14:35:37 -0700 Subject: [PATCH 22/33] Squash: generalize ui for various data sources + add theme --- packages/core/App.module.css | 71 ++-- packages/core/App.tsx | 31 +- .../AggregateInfoBox.module.css | 3 +- .../components/AnnotationFilterForm/index.tsx | 26 +- .../test/AnnotationFilterForm.test.tsx | 14 +- .../AnnotationHierarchy.module.css | 34 -- .../HierarchyListItem.module.css | 16 - .../AnnotationHierarchy/HierarchyListItem.tsx | 41 --- .../components/AnnotationHierarchy/index.tsx | 46 --- .../test/AnnotationHierarchy.test.tsx | 58 ---- .../AnnotationList/AnnotationList.module.css | 106 ------ .../AnnotationListItem.module.css | 29 -- .../AnnotationList/AnnotationListItem.tsx | 67 ---- .../core/components/AnnotationList/index.tsx | 131 -------- .../test/AnnotationList.test.tsx | 250 -------------- .../components/AnnotationPicker/index.tsx | 66 ++++ .../AnnotationSidebar/AnnotationFilter.tsx | 79 ----- .../AnnotationSidebar.module.css | 16 - .../components/AnnotationSidebar/index.tsx | 66 ---- .../components/AnnotationSidebar/selectors.ts | 56 ---- .../AnnotationSidebar/test/selectors.test.ts | 95 ------ .../ContextMenu/ContextMenu.module.css | 13 + .../core/components/ContextMenu/index.tsx | 3 + packages/core/components/ContextMenu/items.ts | 116 +++---- .../DirectoryTree/DirectoryTree.module.css | 2 +- .../DirectoryTreeNode.module.css | 10 +- .../DirectoryTree/DirectoryTreeNodeHeader.tsx | 2 +- .../RootLoadingIndicator.module.css | 2 +- .../core/components/DirectoryTree/index.tsx | 4 - .../DirectoryTree/test/DirectoryTree.test.tsx | 4 +- .../components/DnDList/DnDList.module.css | 7 +- packages/core/components/DnDList/DnDList.tsx | 1 - .../components/DnDList/DnDListItem.module.css | 8 +- .../components/EmptyFileListMessage/index.tsx | 27 +- .../core/components/FileDetails/Download.tsx | 10 +- .../FileDetails/FileAnnotationList.tsx | 14 +- .../FileDetails/FileAnnotationRow.module.css | 16 +- .../FileDetails/FileAnnotationRow.tsx | 1 - .../FileDetails/FileDetails.module.css | 90 ++--- .../FileDetails/OpenFileButton.module.css | 18 + .../components/FileDetails/OpenFileButton.tsx | 111 +++---- .../FileDetails/Pagination.module.css | 15 + .../components/FileDetails/Pagination.tsx | 28 +- .../core/components/FileDetails/index.tsx | 55 +--- .../components/FileDetails/useFileDetails.ts | 6 +- .../FileExplorerURLBar.module.css | 31 -- .../components/FileExplorerURLBar/index.tsx | 162 --------- .../test/FileExplorerURLBar.test.tsx | 60 ---- .../core/components/FileList/ColumnPicker.tsx | 27 ++ .../components/FileList/FileList.module.css | 6 +- .../FileList/FileListColumnPicker.tsx | 42 --- .../components/FileList/Header.module.css | 9 +- packages/core/components/FileList/Header.tsx | 4 +- .../FileList/LazilyRenderedRow.module.css | 9 +- .../components/FileList/LazilyRenderedRow.tsx | 11 +- .../components/FileList/test/Header.test.tsx | 3 +- .../FileList/test/LazilyRenderedRow.test.tsx | 13 +- .../FileList/useFileAccessContextMenu.ts | 123 ++++--- .../FileMetadataSearchBar.module.css | 35 -- .../FileMetadataSearchBar/index.tsx | 196 ----------- .../test/FileMetadataSearchBar.test.tsx | 234 ------------- .../core/components/FileRow/Cell.module.css | 2 +- .../components/FileRow/FileRow.module.css | 6 +- packages/core/components/FileRow/index.tsx | 7 +- .../FilterDisplayBar.module.css | 30 -- .../FilterMedallion.module.css | 51 --- .../FilterDisplayBar/FilterMedallion.tsx | 102 ------ .../components/FilterDisplayBar/index.tsx | 41 --- .../test/FilterDisplayBar.test.tsx | 174 ---------- .../HeaderRibbon/CollectionControl.tsx | 309 ----------------- .../HeaderRibbon/HeaderRibbon.module.css | 171 ---------- .../components/HeaderRibbon/HelpControl.tsx | 248 -------------- .../components/HeaderRibbon/ViewControl.tsx | 81 ----- .../core/components/HeaderRibbon/index.tsx | 120 ------- .../test/CollectionControl.test.tsx | 147 --------- .../HeaderRibbon/test/HeaderRibbon.test.tsx | 37 --- .../HeaderRibbon/test/ViewControl.test.tsx | 46 --- .../tutorials/CreateCollection.tsx | 33 -- .../HeaderRibbon/tutorials/OrganizeFiles.ts | 18 - .../HeaderRibbon/tutorials/ShareView.ts | 12 - .../HeaderRibbon/tutorials/SortFiles.ts | 7 - .../HeaderRibbon/tutorials/index.ts | 19 -- .../ListPicker/ListPicker.module.css | 126 +++++-- packages/core/components/ListPicker/index.tsx | 152 +++++---- .../ListPicker/test/ListPicker.test.tsx | 26 +- .../AnnotationSelector.module.css | 32 -- .../Modal/AnnotationSelector/index.tsx | 77 ----- .../Modal/BaseModal/BaseModal.module.css | 18 +- .../core/components/Modal/BaseModal/index.tsx | 29 +- .../CollectionForm/CollectionForm.module.css | 70 ---- .../components/Modal/CollectionForm/index.tsx | 280 ---------------- .../test/CollectionForm.test.tsx | 290 ---------------- .../Modal/CsvManifest/CsvManifest.module.css | 17 + .../components/Modal/CsvManifest/index.tsx | 10 +- .../CsvManifest/test/CsvManifest.test.tsx | 4 + .../DataSourcePrompt.module.css | 35 +- .../Modal/DataSourcePrompt/index.tsx | 264 +++++---------- .../PythonSnippet/PythonSnippet.module.css | 10 +- .../PythonSnippet/test/PythonSnippet.test.tsx | 10 +- .../components/Modal/TipsAndTricks/index.tsx | 39 --- .../TipsAndTricks/test/TipsAndTricks.test.tsx | 29 -- packages/core/components/Modal/index.tsx | 17 +- packages/core/components/Modal/selectors.ts | 25 +- .../components/Modal/test/selectors.test.ts | 66 ---- .../components/QuerySidebar/Query.module.css | 97 ++++++ .../core/components/QuerySidebar/Query.tsx | 267 +++++++++++++++ .../QuerySidebar/QueryFooter.module.css | 29 ++ .../components/QuerySidebar/QueryFooter.tsx | 99 ++++++ .../QuerySidebar/QueryPart.module.css | 33 ++ .../components/QuerySidebar/QueryPart.tsx | 75 +++++ .../QuerySidebar/QueryPartRow.module.css | 50 +++ .../components/QuerySidebar/QueryPartRow.tsx | 67 ++++ .../QuerySidebar/QuerySidebar.module.css | 142 ++++++++ .../core/components/QuerySidebar/index.tsx | 202 ++++++++++++ .../tutorials/FilterFiles.tsx | 8 +- .../tutorials/GenerateManifest.ts | 2 +- .../tutorials/ModifyColumns.ts | 0 .../tutorials/OpenFiles.ts | 0 .../QuerySidebar/tutorials/OrganizeFiles.ts | 17 + .../QuerySidebar/tutorials/ShareView.ts | 13 + .../QuerySidebar/tutorials/SortFiles.ts | 13 + .../QuerySidebar/tutorials/index.ts | 100 ++++++ .../SearchableDropdown.module.css | 16 - .../components/SearchableDropdown/index.tsx | 65 ---- .../test/SearchableDropdown.test.tsx | 85 ----- .../StatusMessage/StatusMessage.module.css | 39 +++ .../core/components/StatusMessage/index.tsx | 26 +- packages/core/components/SvgIcon/index.tsx | 8 +- .../TutorialTooltip.module.css | 6 +- .../core/components/TutorialTooltip/index.tsx | 5 - .../WindowActionButton.module.css | 13 +- .../components/WindowActionButton/index.tsx | 5 +- packages/core/constants/index.ts | 106 +----- packages/core/entity/Annotation/index.ts | 41 ++- .../entity/Annotation/test/annotation.test.ts | 29 +- packages/core/entity/FileDetail/index.ts | 66 +++- packages/core/entity/FileDetail/mocks.ts | 7 +- packages/core/entity/FileExplorerURL/index.ts | 80 ++--- .../test/fileexplorerurl.test.ts | 282 +--------------- packages/core/entity/FileSelection/index.ts | 12 +- packages/core/entity/FileSet/index.ts | 44 +-- packages/core/entity/FileSort/index.ts | 6 +- packages/core/entity/SQLBuilder/index.ts | 66 ++++ packages/core/entity/Tutorial/index.ts | 11 +- packages/core/icons/index.ts | 5 + .../DatabaseAnnotationService/index.ts | 43 +-- .../HttpAnnotationService/index.ts | 12 +- packages/core/services/CsvService/index.ts | 27 +- .../core/services/DatasetService/index.ts | 85 +---- .../test/DatasetService.test.ts | 32 -- .../ExecutionEnvServiceNoop.ts | 6 +- .../services/ExecutionEnvService/index.ts | 2 - .../FileDownloadServiceNoop.ts | 4 + .../services/FileDownloadService/index.ts | 10 + .../FileService/DatabaseFileService/index.ts | 87 ++--- .../services/FileService/FileServiceNoop.ts | 5 +- .../FileService/HttpFileService/index.ts | 7 +- packages/core/services/FileService/index.ts | 21 +- .../services/PersistentConfigService/index.ts | 8 +- packages/core/state/index.ts | 13 +- packages/core/state/interaction/actions.ts | 244 ++------------ packages/core/state/interaction/logics.ts | 244 ++------------ packages/core/state/interaction/reducer.ts | 50 +-- packages/core/state/interaction/selectors.ts | 15 +- .../state/interaction/test/logics.test.ts | 311 +++--------------- packages/core/state/metadata/logics.ts | 15 +- packages/core/state/metadata/reducer.ts | 8 - packages/core/state/metadata/selectors.ts | 40 +-- .../state/metadata/test/selectors.test.ts | 63 ---- packages/core/state/selection/actions.ts | 117 ++++--- packages/core/state/selection/logics.ts | 89 +++-- packages/core/state/selection/reducer.ts | 66 ++-- packages/core/state/selection/selectors.ts | 24 +- .../core/state/selection/test/logics.test.ts | 3 +- .../core/state/selection/test/reducer.test.ts | 76 +---- .../state/selection/test/selectors.test.ts | 127 ------- packages/core/styles/global.css | 38 +++ packages/desktop/src/renderer/index.tsx | 13 +- .../src/services/DatabaseServiceElectron.ts | 10 +- .../services/ExecutionEnvServiceElectron.ts | 23 -- .../services/FileDownloadServiceElectron.ts | 40 ++- 181 files changed, 2955 insertions(+), 7376 deletions(-) delete mode 100644 packages/core/components/AnnotationHierarchy/AnnotationHierarchy.module.css delete mode 100644 packages/core/components/AnnotationHierarchy/HierarchyListItem.module.css delete mode 100644 packages/core/components/AnnotationHierarchy/HierarchyListItem.tsx delete mode 100644 packages/core/components/AnnotationHierarchy/index.tsx delete mode 100644 packages/core/components/AnnotationHierarchy/test/AnnotationHierarchy.test.tsx delete mode 100644 packages/core/components/AnnotationList/AnnotationList.module.css delete mode 100644 packages/core/components/AnnotationList/AnnotationListItem.module.css delete mode 100644 packages/core/components/AnnotationList/AnnotationListItem.tsx delete mode 100644 packages/core/components/AnnotationList/index.tsx delete mode 100644 packages/core/components/AnnotationList/test/AnnotationList.test.tsx create mode 100644 packages/core/components/AnnotationPicker/index.tsx delete mode 100644 packages/core/components/AnnotationSidebar/AnnotationFilter.tsx delete mode 100644 packages/core/components/AnnotationSidebar/AnnotationSidebar.module.css delete mode 100644 packages/core/components/AnnotationSidebar/index.tsx delete mode 100644 packages/core/components/AnnotationSidebar/selectors.ts delete mode 100644 packages/core/components/AnnotationSidebar/test/selectors.test.ts create mode 100644 packages/core/components/ContextMenu/ContextMenu.module.css delete mode 100644 packages/core/components/FileExplorerURLBar/FileExplorerURLBar.module.css delete mode 100644 packages/core/components/FileExplorerURLBar/index.tsx delete mode 100644 packages/core/components/FileExplorerURLBar/test/FileExplorerURLBar.test.tsx create mode 100644 packages/core/components/FileList/ColumnPicker.tsx delete mode 100644 packages/core/components/FileList/FileListColumnPicker.tsx 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 delete mode 100644 packages/core/components/FilterDisplayBar/FilterDisplayBar.module.css delete mode 100644 packages/core/components/FilterDisplayBar/FilterMedallion.module.css delete mode 100644 packages/core/components/FilterDisplayBar/FilterMedallion.tsx delete mode 100644 packages/core/components/FilterDisplayBar/index.tsx delete mode 100644 packages/core/components/FilterDisplayBar/test/FilterDisplayBar.test.tsx delete mode 100644 packages/core/components/HeaderRibbon/CollectionControl.tsx delete mode 100644 packages/core/components/HeaderRibbon/HeaderRibbon.module.css delete mode 100644 packages/core/components/HeaderRibbon/HelpControl.tsx delete mode 100644 packages/core/components/HeaderRibbon/ViewControl.tsx delete mode 100644 packages/core/components/HeaderRibbon/index.tsx delete mode 100644 packages/core/components/HeaderRibbon/test/CollectionControl.test.tsx delete mode 100644 packages/core/components/HeaderRibbon/test/HeaderRibbon.test.tsx delete mode 100644 packages/core/components/HeaderRibbon/test/ViewControl.test.tsx delete mode 100644 packages/core/components/HeaderRibbon/tutorials/CreateCollection.tsx delete mode 100644 packages/core/components/HeaderRibbon/tutorials/OrganizeFiles.ts delete mode 100644 packages/core/components/HeaderRibbon/tutorials/ShareView.ts delete mode 100644 packages/core/components/HeaderRibbon/tutorials/SortFiles.ts delete mode 100644 packages/core/components/HeaderRibbon/tutorials/index.ts delete mode 100644 packages/core/components/Modal/AnnotationSelector/AnnotationSelector.module.css delete mode 100644 packages/core/components/Modal/AnnotationSelector/index.tsx delete mode 100644 packages/core/components/Modal/CollectionForm/CollectionForm.module.css delete mode 100644 packages/core/components/Modal/CollectionForm/index.tsx delete mode 100644 packages/core/components/Modal/CollectionForm/test/CollectionForm.test.tsx create mode 100644 packages/core/components/Modal/CsvManifest/CsvManifest.module.css delete mode 100644 packages/core/components/Modal/TipsAndTricks/index.tsx delete mode 100644 packages/core/components/Modal/TipsAndTricks/test/TipsAndTricks.test.tsx delete mode 100644 packages/core/components/Modal/test/selectors.test.ts create mode 100644 packages/core/components/QuerySidebar/Query.module.css create mode 100644 packages/core/components/QuerySidebar/Query.tsx create mode 100644 packages/core/components/QuerySidebar/QueryFooter.module.css create mode 100644 packages/core/components/QuerySidebar/QueryFooter.tsx create mode 100644 packages/core/components/QuerySidebar/QueryPart.module.css create mode 100644 packages/core/components/QuerySidebar/QueryPart.tsx create mode 100644 packages/core/components/QuerySidebar/QueryPartRow.module.css create mode 100644 packages/core/components/QuerySidebar/QueryPartRow.tsx create mode 100644 packages/core/components/QuerySidebar/QuerySidebar.module.css create mode 100644 packages/core/components/QuerySidebar/index.tsx rename packages/core/components/{HeaderRibbon => QuerySidebar}/tutorials/FilterFiles.tsx (74%) rename packages/core/components/{HeaderRibbon => QuerySidebar}/tutorials/GenerateManifest.ts (69%) rename packages/core/components/{HeaderRibbon => QuerySidebar}/tutorials/ModifyColumns.ts (100%) rename packages/core/components/{HeaderRibbon => QuerySidebar}/tutorials/OpenFiles.ts (100%) create mode 100644 packages/core/components/QuerySidebar/tutorials/OrganizeFiles.ts create mode 100644 packages/core/components/QuerySidebar/tutorials/ShareView.ts create mode 100644 packages/core/components/QuerySidebar/tutorials/SortFiles.ts create mode 100644 packages/core/components/QuerySidebar/tutorials/index.ts delete mode 100644 packages/core/components/SearchableDropdown/SearchableDropdown.module.css delete mode 100644 packages/core/components/SearchableDropdown/index.tsx delete mode 100644 packages/core/components/SearchableDropdown/test/SearchableDropdown.test.tsx create mode 100644 packages/core/entity/SQLBuilder/index.ts delete mode 100644 packages/core/state/metadata/test/selectors.test.ts delete mode 100644 packages/core/state/selection/test/selectors.test.ts diff --git a/packages/core/App.module.css b/packages/core/App.module.css index b6d558f2c..96478877d 100644 --- a/packages/core/App.module.css +++ b/packages/core/App.module.css @@ -1,3 +1,7 @@ +.light-theme { + color: orange; +} + .absolute { position: absolute; } @@ -7,22 +11,8 @@ } .root { - --annotation-hierarchy-max-width: 350px; - --blue: steelblue; - --grey: #c8c8c8; - --white: #ffffff; - --url-bar-height: 40px; - --file-details-width: 20%; - --margin: 12px; - --transition-duration: 0.5s; - --smaller-font-size: small; - /* AICS brand colors pulled from AICS style guide */ - --primary-brand-dark-blue: #003057; - --primary-brand-purple: #827aa3; - --primary-brand-grey: #7c7d7f; - --secondary-brand-purple: #bab5c9; - --brand-error: #c23030; - + background-color: var(--secondary-background-color); + color: var(--secondary-text-color); composes: relative; margin: 0; border: 0; @@ -32,35 +22,30 @@ overflow: hidden; /* TODO; should be unnecessary */ } +.small-font { + font-size: smaller; +} + /* Globally remove the ability to select text. Individual elements that need this should opt-in. */ .root * { user-select: none; } -.content { - display: flex; - flex-direction: column; - height: 100%; -} - -.header-ribbon { - display: block; - width: 100%; -} - -.everything-except-header-ribbon { +.core-and-file-details { composes: relative; display: flex; flex: auto; - margin: var(--margin); + height: 100%; + /* margin: var(--margin); */ overflow: hidden; width: calc(100% - (2 * var(--margin))); } -.core { +.query-sidebar-and-file-list { margin-right: var(--margin); width: calc(80% - var(--margin)); height: 100%; + display: flex; /* flex child */ flex: 1 0 auto; @@ -68,30 +53,12 @@ /* flex parent */ display: flex; - flex-direction: column; } -.everything-except-header-ribbon .core .file-details{ - margin-right: 0; - width: 100%; -} - -.url-bar { - width: 100%; - height: var(--url-bar-height); - margin-bottom: var(--margin); -} - -.annotation-hierarchy-and-file-list { - display: flex; - width: 100%; - height: calc(100% - var(--url-bar-height) - var(--margin)); -} - -.annotation-hierarchy { +.query-sidebar { height: 100%; width: 30%; - max-width: var(--annotation-hierarchy-max-width); + max-width: var(--query-sidebar-max-width); } .file-list { @@ -100,13 +67,15 @@ flex: auto; height: 100%; width: calc(70% - var(--margin)); - margin-left: var(--margin); + margin: var(--margin) 0 var(--margin) var(--margin); } .file-details { height: 100%; transition: flex var(--transition-duration); + width: 100%; /* flex child */ flex: 0 0 var(--file-details-width); + margin: var(--margin) 0 var(--margin) 0; } diff --git a/packages/core/App.tsx b/packages/core/App.tsx index 55138d4c9..666af8546 100644 --- a/packages/core/App.tsx +++ b/packages/core/App.tsx @@ -1,17 +1,16 @@ import "normalize.css"; import { initializeIcons, loadTheme } from "@fluentui/react"; +import classNames from "classnames"; import * as React from "react"; import { batch, useDispatch, useSelector } from "react-redux"; -import AnnotationSidebar from "./components/AnnotationSidebar"; import ContextMenu from "./components/ContextMenu"; import Modal from "./components/Modal"; import DirectoryTree from "./components/DirectoryTree"; import FileDetails from "./components/FileDetails"; -import FileExplorerURLBar from "./components/FileExplorerURLBar"; -import HeaderRibbon from "./components/HeaderRibbon"; import StatusMessage from "./components/StatusMessage"; import TutorialTooltip from "./components/TutorialTooltip"; +import QuerySidebar from "./components/QuerySidebar"; import { FileExplorerServiceBaseUrl } from "./constants"; import { interaction, metadata, selection } from "./state"; import { PlatformDependentServices } from "./state/interaction/actions"; @@ -48,6 +47,8 @@ export default function App(props: AppProps) { } = props; const dispatch = useDispatch(); + const isDarkTheme = useSelector(selection.selectors.getIsDarkTheme); + const shouldDisplaySmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont); // Set platform-dependent services in state React.useEffect(() => { @@ -66,19 +67,19 @@ export default function App(props: AppProps) { }, [dispatch, fileExplorerServiceBaseUrl]); return ( -
    -
    - -
    -
    - -
    - - -
    -
    - +
    +
    +
    + +
    +
    diff --git a/packages/core/components/AggregateInfoBox/AggregateInfoBox.module.css b/packages/core/components/AggregateInfoBox/AggregateInfoBox.module.css index d2e900550..2f56574b4 100644 --- a/packages/core/components/AggregateInfoBox/AggregateInfoBox.module.css +++ b/packages/core/components/AggregateInfoBox/AggregateInfoBox.module.css @@ -1,7 +1,6 @@ .container { - background: var(--secondary-brand-purple); + background: var(--primary-background-color); opacity: 0.90; - border-radius: 3px; /* Allow the element to be fixed to the bottom of the file list */ position: fixed; bottom: 10px; diff --git a/packages/core/components/AnnotationFilterForm/index.tsx b/packages/core/components/AnnotationFilterForm/index.tsx index fddb21ec0..425516111 100644 --- a/packages/core/components/AnnotationFilterForm/index.tsx +++ b/packages/core/components/AnnotationFilterForm/index.tsx @@ -2,14 +2,14 @@ import { find, isNil } from "lodash"; import * as React from "react"; import { useDispatch, useSelector } from "react-redux"; +import useAnnotationValues from "./useAnnotationValues"; +import ListPicker, { ListItem } from "../ListPicker"; import { AnnotationType } from "../../entity/AnnotationFormatter"; import FileFilter from "../../entity/FileFilter"; -import ListPicker, { ListItem } from "../../components/ListPicker"; import { interaction, metadata, selection } from "../../state"; -import useAnnotationValues from "./useAnnotationValues"; interface AnnotationFilterFormProps { - annotationName: string; + name: string; } /** @@ -19,20 +19,18 @@ interface AnnotationFilterFormProps { * amongst its items; if the annotation is of type date, it will render a date input; etc. */ 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 fileFilters = useSelector(selection.selectors.getFileFilters); const annotationService = useSelector(interaction.selectors.getAnnotationService); const [annotationValues, isLoading, errorMessage] = useAnnotationValues( - annotationName, + props.name, annotationService ); const annotation = React.useMemo( - () => find(annotations, (annotation) => annotation.name === annotationName), - [annotations, annotationName] + () => find(annotations, (annotation) => annotation.name === props.name), + [annotations, props.name] ); const items = React.useMemo(() => { @@ -51,7 +49,7 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { const filters = items.map( (item) => new FileFilter( - annotationName, + props.name, isNil(annotation?.valueOf(item.value)) ? item.value : annotation?.valueOf(item.value) @@ -62,7 +60,7 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { const onDeselect = (item: ListItem) => { const fileFilter = new FileFilter( - annotationName, + props.name, isNil(annotation?.valueOf(item.value)) ? item.value : annotation?.valueOf(item.value) ); dispatch(selection.actions.removeFileFilter(fileFilter)); @@ -70,7 +68,7 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { const onSelect = (item: ListItem) => { const fileFilter = new FileFilter( - annotationName, + props.name, isNil(annotation?.valueOf(item.value)) ? item.value : annotation?.valueOf(item.value) ); dispatch(selection.actions.addFileFilter(fileFilter)); @@ -80,7 +78,7 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { const filters = items.map( (item) => new FileFilter( - annotationName, + props.name, isNil(annotation?.valueOf(item.value)) ? item.value : annotation?.valueOf(item.value) @@ -103,7 +101,7 @@ export default function AnnotationFilterForm(props: AnnotationFilterFormProps) { onDeselect={onDeselect} onDeselectAll={onDeselectAll} onSelect={onSelect} - onSelectAll={onSelectAll} + onSelectAll={items.length <= 100 ? onSelectAll : undefined} /> ); } diff --git a/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx b/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx index 9cfc52b41..edf2aaac9 100644 --- a/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx +++ b/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx @@ -54,7 +54,7 @@ describe("", () => { // act const { findAllByRole } = render( - + ); @@ -102,7 +102,7 @@ describe("", () => { // act const { getByLabelText } = render( - + ); @@ -152,7 +152,7 @@ describe("", () => { const { findAllByRole } = render( - + ); @@ -217,7 +217,7 @@ describe("", () => { // Act const { findAllByRole } = render( - + ); @@ -250,7 +250,7 @@ describe("", () => { // Act const { getByLabelText } = render( - + ); // Wait a couple render cycles for the async react hook to retrieve the annotation values @@ -315,7 +315,7 @@ describe("", () => { const { findAllByRole } = render( - + ); @@ -373,7 +373,7 @@ describe("", () => { const { findAllByRole } = render( - + ); diff --git a/packages/core/components/AnnotationHierarchy/AnnotationHierarchy.module.css b/packages/core/components/AnnotationHierarchy/AnnotationHierarchy.module.css deleted file mode 100644 index 92345510e..000000000 --- a/packages/core/components/AnnotationHierarchy/AnnotationHierarchy.module.css +++ /dev/null @@ -1,34 +0,0 @@ -.root { - /* flex parent */ - display: flex; - flex-direction: column; -} - -.small-font { - font-size: var(--smaller-font-size); -} - -.title { - color: var(--primary-brand-dark-blue); /* defined in App.module.css */ - margin: 0 0 0.25rem 0; - - /* flex child */ - flex: 0 0 auto; -} - -.description { - margin: 0 0 0.25rem 0; - font-weight: normal; - - /* flex child */ - flex: 0 0 auto; -} - -.hierarchy { - border: 2px dashed #ebebeb; - padding: 0.5rem; - margin: 0; - - /* flex child */ - flex: 1 1 auto; -} \ No newline at end of file diff --git a/packages/core/components/AnnotationHierarchy/HierarchyListItem.module.css b/packages/core/components/AnnotationHierarchy/HierarchyListItem.module.css deleted file mode 100644 index b05c36e13..000000000 --- a/packages/core/components/AnnotationHierarchy/HierarchyListItem.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.container { - composes: item from "../../components/DnDList/DnDListItem.module.css"; -} - -.title { - padding-left: 2px; - flex: 1 1 auto; - - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} - -.title[title] { - text-decoration: none; -} diff --git a/packages/core/components/AnnotationHierarchy/HierarchyListItem.tsx b/packages/core/components/AnnotationHierarchy/HierarchyListItem.tsx deleted file mode 100644 index af1b90c6c..000000000 --- a/packages/core/components/AnnotationHierarchy/HierarchyListItem.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from "react"; -import { useDispatch } from "react-redux"; - -import AnnotationFilter from "../AnnotationSidebar/AnnotationFilter"; -import { DnDItemRendererParams } from "../../components/DnDList/DnDList"; -import SvgIcon from "../../components/SvgIcon"; -import { REMOVE_ICON_PATH_DATA } from "../../icons"; -import { selection } from "../../state"; - -import styles from "./HierarchyListItem.module.css"; - -const MARGIN_STEP = 10; // in px - -/** - * A single, draggable/droppable annotation that affects how files are organized in the FileList (i.e., how they are filtered, grouped, and sorted). - */ -export default function HierarchyListItem(props: DnDItemRendererParams) { - const { - index, - item: { id, title }, - } = props; - const dispatch = useDispatch(); - - return ( -
    - - {title} - - - { - dispatch(selection.actions.removeFromAnnotationHierarchy(id)); - }} - pathData={REMOVE_ICON_PATH_DATA} - viewBox="0 0 20 20" - width={15} - /> -
    - ); -} diff --git a/packages/core/components/AnnotationHierarchy/index.tsx b/packages/core/components/AnnotationHierarchy/index.tsx deleted file mode 100644 index 4016f08bd..000000000 --- a/packages/core/components/AnnotationHierarchy/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import classNames from "classnames"; -import * as React from "react"; -import { useSelector } from "react-redux"; - -import DnDList from "../../components/DnDList"; -import Tutorial from "../../entity/Tutorial"; -import HierarchyListItem from "./HierarchyListItem"; -import * as annotationSelectors from "../AnnotationSidebar/selectors"; - -import styles from "./AnnotationHierarchy.module.css"; -import { selection } from "../../state"; - -export const DROPPABLE_ID = "HIERARCHY_LIST"; - -interface AnnotationHierarchyProps { - className?: string; - highlightDropZone: boolean; -} - -/** - * Ordered listing of metadata annotations (a.k.a., "keys", "attributes", etc) that a user has selected by which to group files in the FileList. - * A user can drag an annotation from AnnotationList into this component, and can later reorder the annotations already dropped into the component. - */ -export default function AnnotationHierarchy(props: AnnotationHierarchyProps) { - const { className, highlightDropZone } = props; - const annotationHierarchyListItems = useSelector(annotationSelectors.getHierarchyListItems); - const shouldDisplaySmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont); - - return ( -
    -

    Annotation Hierarchy

    -
    - Files will be grouped by the following annotations -
    - -
    - ); -} diff --git a/packages/core/components/AnnotationHierarchy/test/AnnotationHierarchy.test.tsx b/packages/core/components/AnnotationHierarchy/test/AnnotationHierarchy.test.tsx deleted file mode 100644 index 80c00b11c..000000000 --- a/packages/core/components/AnnotationHierarchy/test/AnnotationHierarchy.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { configureMockStore, mergeState } from "@aics/redux-utils"; -import { render } from "@testing-library/react"; -import { expect } from "chai"; -import * as React from "react"; -import { DragDropContext } from "react-beautiful-dnd"; -import { Provider } from "react-redux"; - -import { initialState } from "../../../state"; -import { DND_LIST_CONTAINER_ID } from "../../DnDList/DnDList"; -import AnnotationHierarchy from ".."; - -import styles from "../AnnotationHierarchy.module.css"; -import Annotation from "../../../entity/Annotation"; - -describe("", () => { - describe("Dynamic styling", () => { - [true, false].forEach((shouldDisplaySmallFont) => { - it(`Has ${ - shouldDisplaySmallFont ? "" : "no" - } small font style when shouldDisplaySmallFont is ${shouldDisplaySmallFont}`, () => { - // Arrange - const { store } = configureMockStore({ - state: mergeState(initialState, { - selection: { - annotationHierarchy: [ - new Annotation({ - annotationDisplayName: "any", - annotationName: "any", - description: "", - type: "", - }), - ], - shouldDisplaySmallFont, - }, - }), - }); - - // Act - const { getByTestId } = render( - - { - /* noop */ - }} - > - - - - ); - - // Assert - expect( - getByTestId(DND_LIST_CONTAINER_ID).classList.contains(styles.smallFont) - ).to.equal(shouldDisplaySmallFont); - }); - }); - }); -}); diff --git a/packages/core/components/AnnotationList/AnnotationList.module.css b/packages/core/components/AnnotationList/AnnotationList.module.css deleted file mode 100644 index 6f13048a5..000000000 --- a/packages/core/components/AnnotationList/AnnotationList.module.css +++ /dev/null @@ -1,106 +0,0 @@ -.root { - /* flex parent */ - display: flex; - flex-direction: column; -} - -.small-font { - font-size: var(--smaller-font-size); -} - -.title { - color: var(--primary-brand-dark-blue); /* defined in App.module.css */ - margin: 0 0 0.25rem 0; - - /* flex child */ - flex: 0 0 auto; -} - -.description { - margin: 0 0 0.25rem 0; - font-weight: normal; - - /* flex child */ - flex: 0 0 auto; -} - -.list-container { - border: 1px solid #ebebeb; - padding: 0.5rem; - - /* flex child */ - flex: 1 1 auto; - - /* flex parent */ - display: flex; - flex-direction: column; -} - -.search-box { - position: relative; -} - -.search-icon { - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - fill: var(--grey); /* defined in App.module.css */ -} - -.filter-input { - width: 100%; - border-color: #ebebeb; - border-style: solid; - border-radius: 3px; - border-width: 1px; - padding-left: 20px; - - /* flex child */ - flex: 0 0 auto; -} - -.filter-input::placeholder { - color: var(--grey); -} - -.list { - /* flex child */ - /* flex-basis is fairly arbitrary; setting value to something low like 20px allows the list to shrink as the user would expect */ - flex: 1 1 20px; -} - -.button-container { - display: flex; - align-items: center; -} - -.clear-filters-button, .clear-filters-button:hover, .clear-filters-button:active { - background: none; - border: none; - color: var(--primary-brand-dark-blue); - font-size: 10px; - margin: auto; - margin-top: 3px; - width: 110px; -} - -.clear-filters-button:hover { - cursor: pointer; - opacity: 0.5; -} - -.clear-filters-button:active { - opacity: 0.25; -} - -.divider-title { - color: var(--primary-brand-grey); - font-size: smaller; - padding-right: 2px; - margin-bottom: 3px; -} - -.divider-line { - border-top: 2px dashed var(--primary-brand-grey); -} diff --git a/packages/core/components/AnnotationList/AnnotationListItem.module.css b/packages/core/components/AnnotationList/AnnotationListItem.module.css deleted file mode 100644 index aacbb7794..000000000 --- a/packages/core/components/AnnotationList/AnnotationListItem.module.css +++ /dev/null @@ -1,29 +0,0 @@ -.container { - composes: item from "../../components/DnDList/DnDListItem.module.css"; - margin-right: 2px; -} - -.disabled { - color: gray; -} - -.info { - cursor: help; - margin-right: 0.25rem; - fill: var(--blue); /* defined in App.module.css */ -} - -.missing-description { - margin-left: 12px; -} - -.title { - flex: 1 1 auto; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} - -.title[title] { - text-decoration: none; -} \ No newline at end of file diff --git a/packages/core/components/AnnotationList/AnnotationListItem.tsx b/packages/core/components/AnnotationList/AnnotationListItem.tsx deleted file mode 100644 index f17613ab0..000000000 --- a/packages/core/components/AnnotationList/AnnotationListItem.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import classNames from "classnames"; -import { Spinner, SpinnerSize } from "@fluentui/react"; -import Tippy from "@tippyjs/react"; -import "tippy.js/dist/tippy.css"; // side-effect -import * as React from "react"; - -import AnnotationFilter from "../AnnotationSidebar/AnnotationFilter"; -import { DnDItem, DnDItemRendererParams } from "../../components/DnDList/DnDList"; -import SvgIcon from "../../components/SvgIcon"; - -import styles from "./AnnotationListItem.module.css"; - -// Designed Daniel Bruce (www.entypo.com) -// License: https://creativecommons.org/licenses/by-sa/4.0/ -const INFO_ICON_PATH_DATA = - "M12.432 0c1.34 0 2.010 0.912 2.010 1.957 0 1.305-1.164 2.512-2.679 2.512-1.269 0-2.009-0.75-1.974-1.99 0-1.043 0.881-2.479 2.643-2.479zM8.309 20c-1.058 0-1.833-0.652-1.093-3.524l1.214-5.092c0.211-0.814 0.246-1.141 0-1.141-0.317 0-1.689 0.562-2.502 1.117l-0.528-0.88c2.572-2.186 5.531-3.467 6.801-3.467 1.057 0 1.233 1.273 0.705 3.23l-1.391 5.352c-0.246 0.945-0.141 1.271 0.106 1.271 0.317 0 1.357-0.392 2.379-1.207l0.6 0.814c-2.502 2.547-5.235 3.527-6.291 3.527z"; - -export interface AnnotationListDnDItem extends DnDItem { - filtered?: boolean; -} - -interface AnnotationListItemProps extends DnDItemRendererParams { - item: AnnotationListDnDItem; -} - -/** - * A single, draggable/droppable annotation rendered into the AnnotationList. - * - * Export a memoized version of AnnotationListItem because Tippy is not cheap to initialize. - */ -export default React.memo(function AnnotationListItem(props: AnnotationListItemProps) { - const { - item: { id, description, title, disabled }, - loading, - } = props; - - return ( -
    - {description && ( - - - - )} - - {title} - - {loading && } - -
    - ); -}); diff --git a/packages/core/components/AnnotationList/index.tsx b/packages/core/components/AnnotationList/index.tsx deleted file mode 100644 index db0e15d3d..000000000 --- a/packages/core/components/AnnotationList/index.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import classNames from "classnames"; -import Fuse from "fuse.js"; -import * as React from "react"; -import { useDispatch, useSelector } from "react-redux"; - -import AnnotationListItem from "./AnnotationListItem"; -import DnDList from "../../components/DnDList"; -import { DnDListDividers } from "../../components/DnDList/DnDList"; -import SvgIcon from "../../components/SvgIcon"; -import Tutorial from "../../entity/Tutorial"; -import selection from "../../state/selection"; -import * as annotationSelectors from "../AnnotationSidebar/selectors"; - -import styles from "./AnnotationList.module.css"; - -export const DROPPABLE_ID = "ANNOTATION_LIST"; - -interface AnnotationListProps { - className?: string; -} - -const FUZZY_SEARCH_OPTIONS = { - // which keys on ListItemData to search - keys: [ - { name: "title", weight: 0.7 }, - { name: "description", weight: 0.3 }, - ], - - // return resulting matches sorted **by relevance score** - shouldSort: true, - - // arbitrarily tuned; 0.0 requires a perfect match, 1.0 would match anything - threshold: 0.2, -}; - -// Path data for icon taken from Material Design -// Apache License 2.0 (https://github.com/google/material-design-icons/blob/master/LICENSE) -const SEARCH_ICON_PATH_DATA = - "M9.516 14.016q1.875 0 3.188-1.313t1.313-3.188-1.313-3.188-3.188-1.313-3.188 1.313-1.313 3.188 1.313 3.188 3.188 1.313zM15.516 14.016l4.969 4.969-1.5 1.5-4.969-4.969v-0.797l-0.281-0.281q-1.781 1.547-4.219 1.547-2.719 0-4.617-1.875t-1.898-4.594 1.898-4.617 4.617-1.898 4.594 1.898 1.875 4.617q0 0.984-0.469 2.227t-1.078 1.992l0.281 0.281h0.797z"; - -/** - * Listing of all metadata annotations (a.k.a., "keys", "attributes", etc). Users can filter the list using the - * AnnotationFilter input box. Individual annotations can be inspected for their description, and can be dragged into - * the AnnotationGrouping component in order to effect how files in the FileList are displayed (grouped and filtered). - */ -export default function AnnotationList(props: AnnotationListProps) { - const dispatch = useDispatch(); - const filters = useSelector(selection.selectors.getAnnotationFilters); - const annotationsLoading = useSelector( - selection.selectors.getAvailableAnnotationsForHierarchyLoading - ); - const shouldDisplaySmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont); - const annotationListItems = useSelector(annotationSelectors.getAnnotationListItems); - const [searchValue, setSearchValue] = React.useState(""); - - let dividers: DnDListDividers = {}; - const firstDefaultItemIndex = annotationListItems.findIndex((item) => !item.filtered); - // We only want a divider if there are filtered items to divide & no search is active - if (!searchValue && firstDefaultItemIndex !== 0) { - const onClearFilters = () => { - dispatch(selection.actions.removeFileFilter(filters)); - }; - dividers = { - [0]: ( -
    - Filtered -
    - ), - [firstDefaultItemIndex]: ( -
    -
    - -
    -
    -
    - ), - }; - } - - // Perform fuzzy search using searchValue within annotation list items, considering the items - // title and description. If no searchValue has been entered, return full list of items. - const filteredListItems = React.useMemo(() => { - let items = annotationListItems; - if (searchValue) { - const fuse = new Fuse(annotationListItems, FUZZY_SEARCH_OPTIONS); - items = fuse.search(searchValue); - } - - return items; - }, [annotationListItems, searchValue]); - - return ( -
    -

    Available Annotations

    -
    Drag any annotation to the box above
    -
    -
    - - ) => { - setSearchValue(event.target.value); - }} - placeholder="Search..." - type="search" - /> -
    - -
    -
    - ); -} diff --git a/packages/core/components/AnnotationList/test/AnnotationList.test.tsx b/packages/core/components/AnnotationList/test/AnnotationList.test.tsx deleted file mode 100644 index 2047171d3..000000000 --- a/packages/core/components/AnnotationList/test/AnnotationList.test.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { configureMockStore, mergeState } from "@aics/redux-utils"; -import { fireEvent, render } from "@testing-library/react"; -import { assert, expect } from "chai"; -import * as React from "react"; -import { DragDropContext } from "react-beautiful-dnd"; -import { Provider } from "react-redux"; - -import AnnotationList from ".."; -import Annotation from "../../../entity/Annotation"; -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 styles from "../AnnotationList.module.css"; - -describe("", () => { - before(() => { - // HACK: disable all react-beautiful-dnd development warnings. There appears to be an issue with its ability to use document.querySelectorAll in a JSDOM/Enzyme.mount context. - (window as any)["__react-beautiful-dnd-disable-dev-warnings"] = true; - }); - - after(() => { - // remove hack applied in before() - delete (window as any)["__react-beautiful-dnd-disable-dev-warnings"]; - }); - - describe("Search behavior", () => { - it("filters list of annotations according to search input", () => { - // Arrange - const { store } = configureMockStore({ - state: mergeState(initialState, { - metadata: { - annotations: annotationsJson.map( - (annotation) => new Annotation(annotation) - ), - }, - }), - }); - - const { getAllByTestId, getByText, getByPlaceholderText } = render( - - { - /* noop */ - }} - > - - - - ); - const queryNumberListItems = () => getAllByTestId("annotation-list-item").length; - - // (Sanity-check) expect all annotations to be in the list - const allAnnotationDisplayNames = annotationsJson.map( - (annotation) => annotation.annotationDisplayName - ); - expect(queryNumberListItems()).to.equal(allAnnotationDisplayNames.length); - allAnnotationDisplayNames.forEach((annotation) => { - expect(getByText(annotation)).to.exist; - }); - - // Act: execute a search - fireEvent.change(getByPlaceholderText("Search..."), { - target: { value: "created" }, - }); - - // Assert: expect a filtered list, and for it to include only annotations similar to search input - expect(queryNumberListItems()).to.be.lessThan(allAnnotationDisplayNames.length); - expect(getByText("Date created")).to.exist; - expect(() => getByText("Size")).to.throw(); - }); - }); - - describe("Clear All Filters button", () => { - it("clears all filters", async () => { - // Arrange - const { store } = configureMockStore({ - reducer, - logics: reduxLogics, - state: mergeState(initialState, { - metadata: { - annotations: annotationsJson.map( - (annotation) => new Annotation(annotation) - ), - }, - selection: { - filters: [ - new FileFilter("cell_line", "AICS-0"), - new FileFilter("date_created", "01/01/10"), - ], - }, - }), - }); - const { getByText } = render( - - { - /* noop */ - }} - > - - - - ); - const button = getByText("CLEAR ALL FILTERS").closest("button"); - if (!button) { - assert.fail("Could not find 'Clear All Filters' button"); - } - - // (Sanity-check) Ensure the button isn't disabled - expect(button.disabled).to.be.false; - - // Act - fireEvent.click(button); - - // Assert - expect(selection.selectors.getAnnotationFilters(store.getState())).to.be.empty; - }); - }); - - describe("Filtered section of list", () => { - it("exists when annotations are filtered", () => { - // Arrange - const annotations = annotationsJson.map((annotation) => new Annotation(annotation)); - const { store } = configureMockStore({ - state: mergeState(initialState, { - metadata: { - annotations, - }, - selection: { - filters: [new FileFilter("cell_line", "AICS-11")], - }, - }), - }); - const { getByText } = render( - - { - /* noop */ - }} - > - - - - ); - - // Assert - expect(getByText("Filtered")).to.exist; - }); - - it("does not exist when annotations are filtered and search input exists", () => { - // Arrange - const annotations = annotationsJson.map((annotation) => new Annotation(annotation)); - const { store } = configureMockStore({ - state: mergeState(initialState, { - metadata: { - annotations, - }, - selection: { - filters: [new FileFilter("cell_line", "AICS-11")], - }, - }), - }); - const { getByText, getByPlaceholderText } = render( - - { - /* noop */ - }} - > - - - - ); - // (sanity-check) bifurcation existed before search - expect(getByText("Filtered")).to.exist; - - // Act - fireEvent.change(getByPlaceholderText("Search..."), { - target: { value: "created" }, - }); - - // Assert - expect(() => getByText("Filtered")).to.throw(); - }); - - it("does not exist when no annotations are filtered", () => { - // Arrange - const { store } = configureMockStore({ - state: mergeState(initialState, { - metadata: { - annotations: annotationsJson.map( - (annotation) => new Annotation(annotation) - ), - }, - }), - }); - const { getByText } = render( - - { - /* noop */ - }} - > - - - - ); - - // Assert - expect(() => getByText("Filtered")).to.throw(); - }); - }); - - describe("Dynamic styling", () => { - [true, false].forEach((shouldDisplaySmallFont) => { - it(`Has ${ - shouldDisplaySmallFont ? "" : "no" - } small font style when shouldDisplaySmallFont is ${shouldDisplaySmallFont}`, () => { - // Arrange - const { store } = configureMockStore({ - state: mergeState(initialState, { - selection: { - shouldDisplaySmallFont, - }, - }), - }); - - // Act - const { getByTestId } = render( - - { - /* noop */ - }} - > - - - - ); - - // Assert - expect( - getByTestId(DND_LIST_CONTAINER_ID).classList.contains(styles.smallFont) - ).to.equal(shouldDisplaySmallFont); - }); - }); - }); -}); diff --git a/packages/core/components/AnnotationPicker/index.tsx b/packages/core/components/AnnotationPicker/index.tsx new file mode 100644 index 000000000..520838c60 --- /dev/null +++ b/packages/core/components/AnnotationPicker/index.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { useSelector } from "react-redux"; + +import ListPicker, { ListItem } from "../ListPicker"; +import Annotation from "../../entity/Annotation"; +import { metadata, selection } from "../../state"; + +interface Props { + hasSelectAllCapability?: boolean; + disableUnavailableAnnotations?: boolean; + className?: string; + selections: Annotation[]; + annotationSubMenuRenderer?: ( + item: ListItem + ) => React.ReactElement>; + setSelections: (annotations: Annotation[]) => void; +} + +/** + * Form for selecting which annotations to use in some exterior context like + * downloading a manifest. + */ +export default function AnnotationPicker(props: Props) { + const annotations = useSelector(metadata.selectors.getSortedAnnotations); + const unavailableAnnotations = useSelector( + selection.selectors.getUnavailableAnnotationsForHierarchy + ); + const areAvailableAnnotationLoading = useSelector( + selection.selectors.getAvailableAnnotationsForHierarchyLoading + ); + + const items = annotations.map((annotation) => ({ + selected: props.selections.includes(annotation), + disabled: unavailableAnnotations.includes(annotation), + loading: areAvailableAnnotationLoading, + description: annotation.description, + data: annotation, + value: annotation.name, + displayValue: annotation.displayName, + })); + + const removeSelection = (item: ListItem) => { + props.setSelections(props.selections.filter((annotation) => annotation !== item.data)); + }; + + const addSelection = (item: ListItem) => { + // Should never be undefined, included as guard statement to satisfy compiler + if (item.data) { + props.setSelections([...props.selections, item.data]); + } + }; + + return ( + props.setSelections?.(annotations) : undefined + } + onDeselectAll={() => props.setSelections([])} + subMenuRenderer={props.annotationSubMenuRenderer} + /> + ); +} diff --git a/packages/core/components/AnnotationSidebar/AnnotationFilter.tsx b/packages/core/components/AnnotationSidebar/AnnotationFilter.tsx deleted file mode 100644 index 608cb48a6..000000000 --- a/packages/core/components/AnnotationSidebar/AnnotationFilter.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { IconButton, DirectionalHint, IButtonStyles } from "@fluentui/react"; -import * as React from "react"; -import { useSelector } from "react-redux"; - -import AnnotationFilterForm from "../AnnotationFilterForm"; -import { selection } from "../../state"; - -interface FilterProps { - annotationName: string; - iconColor?: string; - styleOverrides?: IButtonStyles; -} - -const FILTER_ICON = { iconName: "FilterSolid" }; - -// change the color of the icon from its default (black) to provide some indication -// to the user that a filter is applied -const FILTERS_APPLIED_COLOR_INDICATOR = "#0b9aab"; // style guide torquise - -/** - * A small icon button rendered inline next to annotation list items (and annotation hierarchy items). - * On click, it renders a small form that allows the user to select annotation values with which to - * filter the application's data. - */ -export default function AnnotationFilter(props: FilterProps) { - const { annotationName, iconColor, styleOverrides } = props; - - const fileFilters = useSelector(selection.selectors.getAnnotationFilters); - - const annotationIsFiltered = React.useMemo( - () => fileFilters.some((filter) => filter.name === annotationName), - [fileFilters, annotationName] - ); - - const menuProps = React.useMemo(() => { - return { - onRenderMenuList() { - return ; - }, - directionalHint: DirectionalHint.rightTopEdge, - title: "Exclusively Include", - shouldFocusOnMount: true, - items: [{ key: "placeholder" }], // necessary to have a non-empty items list to have `onRenderMenuList` called - }; - }, [annotationName]); - - // basic styling override improvements to @fluentui/react's iconbutton - // if this annotation is filtered, change the color of the filter icon as a subtle indication - const iconButtonStyles = React.useMemo(() => { - return { - icon: { - color: annotationIsFiltered ? FILTERS_APPLIED_COLOR_INDICATOR : iconColor, - fontSize: "10px", - }, - menuIcon: { - display: "none", - }, - root: { - height: 22, - }, - ...styleOverrides, - }; - }, [annotationIsFiltered, iconColor, styleOverrides]); - - return ( - - ); -} - -AnnotationFilter.defaultProps = { - iconColor: "black", - styleOverrides: {}, -}; diff --git a/packages/core/components/AnnotationSidebar/AnnotationSidebar.module.css b/packages/core/components/AnnotationSidebar/AnnotationSidebar.module.css deleted file mode 100644 index d50febe61..000000000 --- a/packages/core/components/AnnotationSidebar/AnnotationSidebar.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.root { - /* flex parent */ - display: flex; - flex-direction: column; -} - -.annotation-hierarchy { - /* flex child */ - flex: 1 1 40%; - padding-bottom: 1rem; -} - -.annotation-list { - /* flex child */ - flex: 1 1 60%; -} diff --git a/packages/core/components/AnnotationSidebar/index.tsx b/packages/core/components/AnnotationSidebar/index.tsx deleted file mode 100644 index 4af55cc27..000000000 --- a/packages/core/components/AnnotationSidebar/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import classNames from "classnames"; -import * as React from "react"; -import { DragDropContext, OnDragEndResponder, OnDragStartResponder } from "react-beautiful-dnd"; -import { useDispatch } from "react-redux"; - -import AnnotationHierarchy from "../AnnotationHierarchy"; -import AnnotationList, { DROPPABLE_ID as ANNOTATION_LIST_DROPPABLE_ID } from "../AnnotationList"; - -import { selection } from "../../state"; - -import styles from "./AnnotationSidebar.module.css"; - -interface AnnotationSidebarProps { - className?: string; -} - -/** - * Container for features related to viewing available metadata annotations, selecting and ordering those annotations - * by which to group files by, and filtering/sorting those annotations. - */ -export default function AnnotationSidebar(props: AnnotationSidebarProps) { - const [highlightDropZone, setHighlightDropZone] = React.useState(false); - const dispatch = useDispatch(); - - // On drag start of any draggable item within this DragDropContext, if the draggable comes from the list of all - // available annotations, show indicator of where the user can drop it - const onDragStart: OnDragStartResponder = (start) => { - if (start.source.droppableId === ANNOTATION_LIST_DROPPABLE_ID) { - setHighlightDropZone(true); - } - }; - - // On drag end of any draggable item within this DragDropContext, if it was dropped on the hierarchy list, tell Redux about it - const onDragEnd: OnDragEndResponder = (result) => { - const { destination, draggableId, source } = result; - const { itemId } = JSON.parse(draggableId); - - // dropped within drag and drop context - if (destination) { - if (source.droppableId === ANNOTATION_LIST_DROPPABLE_ID) { - // the draggable came from the list of all available annotations and was dropped on the hierarchy - dispatch(selection.actions.reorderAnnotationHierarchy(itemId, destination.index)); - } else { - // in every other case, the draggable came from the hierarchy itself (i.e., the hierarchy was reordered) - dispatch(selection.actions.reorderAnnotationHierarchy(itemId, destination.index)); - } - } - - // drag is finished, so if showDropZone is true, toggle the flag - if (highlightDropZone) { - setHighlightDropZone(false); - } - }; - - return ( -
    - - - - -
    - ); -} diff --git a/packages/core/components/AnnotationSidebar/selectors.ts b/packages/core/components/AnnotationSidebar/selectors.ts deleted file mode 100644 index 751b950f5..000000000 --- a/packages/core/components/AnnotationSidebar/selectors.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { map } from "lodash"; -import { createSelector } from "reselect"; - -import { AnnotationListDnDItem } from "../AnnotationList/AnnotationListItem"; -import { DnDItem } from "../../components/DnDList/DnDList"; -import Annotation from "../../entity/Annotation"; -import FileFilter from "../../entity/FileFilter"; -import { metadata, selection } from "../../state"; - -export const getAnnotationListItems = createSelector( - [ - metadata.selectors.getSortedAnnotations, - selection.selectors.getAvailableAnnotationsForHierarchy, - selection.selectors.getAnnotationHierarchy, - selection.selectors.getAnnotationFilters, - ], - ( - annotations: Annotation[], - availableAnnotationNames: string[], - hierarchy: Annotation[], - filters: FileFilter[] - ): AnnotationListDnDItem[] => { - const disabledAnnotations = hierarchy.length - ? annotations.filter( - (annotation) => !availableAnnotationNames.includes(annotation.name) - ) - : []; - const disabledAnnotationNames = new Set( - disabledAnnotations.map((annotation) => annotation.name) - ); - const filteredAnnotationNames = new Set(filters.map((filter) => filter.name)); - return ( - annotations - .map((annotation) => ({ - description: annotation.description, - disabled: disabledAnnotationNames.has(annotation.name), - filtered: filteredAnnotationNames.has(annotation.name), - id: annotation.name, - title: annotation.displayName, - })) - // Sort the filtered annotations to the top - .sort((a, b) => (a.filtered && !b.filtered ? -1 : 1)) - ); - } -); - -export const getHierarchyListItems = createSelector( - [selection.selectors.getAnnotationHierarchy], - (annotations: Annotation[]): DnDItem[] => { - return map(annotations, (annotation) => ({ - description: annotation.description, - id: annotation.name, - title: annotation.displayName, - })); - } -); diff --git a/packages/core/components/AnnotationSidebar/test/selectors.test.ts b/packages/core/components/AnnotationSidebar/test/selectors.test.ts deleted file mode 100644 index f9c83f703..000000000 --- a/packages/core/components/AnnotationSidebar/test/selectors.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { mergeState } from "@aics/redux-utils"; -import { expect } from "chai"; -import { map } from "lodash"; - -import Annotation from "../../../entity/Annotation"; -import { annotationsJson } from "../../../entity/Annotation/mocks"; -import * as annotationSelectors from "../selectors"; -import { initialState } from "../../../state"; -import FileFilter from "../../../entity/FileFilter"; - -describe(" selectors", () => { - describe("getAnnotationListItems", () => { - it("transforms available annotations into list item data", () => { - const state = mergeState(initialState, { - metadata: { - annotations: map(annotationsJson, (annotation) => new Annotation(annotation)), - }, - }); - - const listItems = annotationSelectors.getAnnotationListItems(state); - expect(listItems.length).to.equal(annotationsJson.length); - - const first = listItems[0]; // items are sorted according to Annotation::sort - expect(first).to.have.property("id"); - expect(first).to.have.property("description", "AICS cell line"); - expect(first).to.have.property("title", "Cell line"); - }); - - it("denotes filtered annotations as filtered", () => { - const filters = [ - 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)), - }, - selection: { - filters, - }, - }); - - const listItems = annotationSelectors.getAnnotationListItems(state); - expect(listItems.length).to.equal(annotationsJson.length); - - listItems.forEach((item) => { - const filtered = filters.findIndex((f) => f.name === item.id) !== -1; - expect(item).to.have.property("filtered", filtered); - }); - }); - - it("denotes non-available annotations as disabled", () => { - const annotations = map(annotationsJson, (annotation) => new Annotation(annotation)); - const availableAnnotationsForHierarchy = annotations.slice(1, 3).map((a) => a.name); - const availableAnnotationsForHierarchySet = new Set(availableAnnotationsForHierarchy); - const state = mergeState(initialState, { - metadata: { - annotations, - }, - selection: { - availableAnnotationsForHierarchy, - annotationHierarchy: annotations.slice(1, 2), - }, - }); - - const listItems = annotationSelectors.getAnnotationListItems(state); - expect(listItems.length).to.equal(annotationsJson.length); - - listItems.forEach((item) => { - const disabled = !availableAnnotationsForHierarchySet.has(item.id); - expect(item).to.have.property("disabled", disabled); - }); - }); - - it("denotes all annotations as enabled when hierarchy is empty", () => { - const annotations = map(annotationsJson, (annotation) => new Annotation(annotation)); - const availableAnnotationsForHierarchy = annotations.slice(1, 3).map((a) => a.name); - const state = mergeState(initialState, { - metadata: { - annotations, - }, - selection: { - availableAnnotationsForHierarchy, - }, - }); - - const listItems = annotationSelectors.getAnnotationListItems(state); - expect(listItems.length).to.equal(annotationsJson.length); - - listItems.forEach((item) => { - expect(item).to.have.property("disabled", false); - }); - }); - }); -}); diff --git a/packages/core/components/ContextMenu/ContextMenu.module.css b/packages/core/components/ContextMenu/ContextMenu.module.css new file mode 100644 index 000000000..9396d7105 --- /dev/null +++ b/packages/core/components/ContextMenu/ContextMenu.module.css @@ -0,0 +1,13 @@ +.container, .container button { + background-color: var(--primary-background-color); + color: var(--primary-text-color); +} + +.container i, .container li > div { + color: var(--primary-text-color); +} + +.container :is(a, button):hover, .container button:hover i { + background-color: var(--highlight-background-color); + color: var(--highlight-text-color) !important; +} diff --git a/packages/core/components/ContextMenu/index.tsx b/packages/core/components/ContextMenu/index.tsx index de3e918ff..ac5620478 100644 --- a/packages/core/components/ContextMenu/index.tsx +++ b/packages/core/components/ContextMenu/index.tsx @@ -9,6 +9,8 @@ import { useDispatch, useSelector } from "react-redux"; import { interaction } from "../../state"; +import styles from "./ContextMenu.module.css"; + export type ContextMenuItem = IContextualMenuItem; export type PositionReference = Target; export const ContextualMenuItemType = _ContextualMenuItemType; @@ -30,6 +32,7 @@ export default function ContextMenu() { return (