diff --git a/packages/core/components/ChoiceGroup/ChoiceGroup.module.css b/packages/core/components/ChoiceGroup/ChoiceGroup.module.css index 8cdd99e0f..93c4111d8 100644 --- a/packages/core/components/ChoiceGroup/ChoiceGroup.module.css +++ b/packages/core/components/ChoiceGroup/ChoiceGroup.module.css @@ -8,7 +8,7 @@ } .choice-group label > span { - font-size: var(--l-paragraph-size); + font-size: var(--s-paragraph-size); margin-right: 5px; margin-top: 1px; padding-left: 24px !important; diff --git a/packages/core/components/ComboBox/ComboBox.module.css b/packages/core/components/ComboBox/ComboBox.module.css index 978210316..c358be944 100644 --- a/packages/core/components/ComboBox/ComboBox.module.css +++ b/packages/core/components/ComboBox/ComboBox.module.css @@ -65,7 +65,8 @@ bottom: 5px; } -.combo-box-item :is(input, button, label){ +.combo-box-item :is(input, button, label), +.options-container :is(input, button, label) { color: var(--primary-text-color); } @@ -93,7 +94,9 @@ .combo-box-item button:not(:disabled):hover, .combo-box-item > div:hover, -.combo-box-item > div:hover :is(input, label) { +.combo-box-item > div:hover :is(input, label), +.options-container button:not(:disabled):hover, +.options-container > div:hover :is(input, label){ background-color: var(--highlight-background-color); color: var(--highlight-text-color); } diff --git a/packages/core/components/ComboBox/index.tsx b/packages/core/components/ComboBox/index.tsx index 4937b4f58..2ec2aa7dd 100644 --- a/packages/core/components/ComboBox/index.tsx +++ b/packages/core/components/ComboBox/index.tsx @@ -12,7 +12,6 @@ interface Props { multiSelect?: boolean; options: IComboBoxOption[]; placeholder: string; - useComboBoxAsMenuWidth?: boolean; onChange?: (option: IComboBoxOption | undefined, value?: string | undefined) => void; } @@ -67,7 +66,7 @@ export default function BaseComboBox(props: Props) { comboBoxOptionStyles={{ rootChecked: styles.comboBoxItemChecked, }} - useComboBoxAsMenuWidth={props?.useComboBoxAsMenuWidth} + useComboBoxAsMenuWidth /> ); } diff --git a/packages/core/components/DateRangePicker/DateRangePicker.module.css b/packages/core/components/DateRangePicker/DateRangePicker.module.css index a20482901..801b9265a 100644 --- a/packages/core/components/DateRangePicker/DateRangePicker.module.css +++ b/packages/core/components/DateRangePicker/DateRangePicker.module.css @@ -31,10 +31,6 @@ margin: 0 10px 8px; } -.date-range-root { - flex-grow: 1; -} - .text-field div { background-color: var(--secondary-background-color); border: none; @@ -54,9 +50,3 @@ margin: 0; padding-bottom: var(--margin); } - -.read-only-placeholder { - margin-right: 20px; - font-style: italic; - color: var(--primary-text-color); -} diff --git a/packages/core/components/DateRangePicker/DateTimePicker.module.css b/packages/core/components/DateRangePicker/DateTimePicker.module.css new file mode 100644 index 000000000..39ec47a7a --- /dev/null +++ b/packages/core/components/DateRangePicker/DateTimePicker.module.css @@ -0,0 +1,47 @@ +.display-block { + display: block; +} + +.dateTimeWrapper { + display: flex; +} + +.date-range-root { + width: 300px; + min-width: 145px; +} + +.date-range-text-field div, .time-picker { + background-color: var(--secondary-background-color); + border: none; + border-radius: var(--small-border-radius); + color: var(--primary-text-color); +} + +.date-range-text-field div::after, .time-picker:focus-visible { + border: 1px solid var(--aqua); + outline: none; +} + +.date-range-text-field > div, .time-picker { + border: 1px solid var(--border-color) +} + +.date-range-text-field i, .time-picker i { + color: var(--primary-text-color); +} + +.read-only-placeholder { + margin-right: 20px; + font-style: italic; + font-size: var(--s-paragraph-size) !important; /* override FluentUI button callout font size*/ + color: var(--primary-text-color); +} + +.time-picker { + height: 34px; + margin: 0 0 5px 5px; + color-scheme: dark; + width: 150px; + padding: 0 5px; +} \ No newline at end of file diff --git a/packages/core/components/DateRangePicker/DateTimePicker.tsx b/packages/core/components/DateRangePicker/DateTimePicker.tsx new file mode 100644 index 000000000..7eb1600ee --- /dev/null +++ b/packages/core/components/DateRangePicker/DateTimePicker.tsx @@ -0,0 +1,63 @@ +import { DatePicker } from "@fluentui/react"; +import * as React from "react"; + +import styles from "./DateTimePicker.module.css"; + +interface DateTimePickerProps { + className?: string; + placeholder: string; + defaultDate?: Date; + onSelectDate: (date: Date | null | undefined) => void; + showTimeSelection?: boolean; // Show time input; default false +} + +/** + * DatePicker that can also function as DateTimePicker + * by including showTimeSelection prop + */ +export default function DateTimePicker(props: DateTimePickerProps) { + const { onSelectDate } = props; + const [date, setDate] = React.useState(props?.defaultDate); + const [time, setTime] = React.useState(""); + + React.useEffect(() => { + if (!date && !time) return; + // Prioritize the date from datePicked, otherwise set to today + const combinedDateTime: Date = date || new Date(); + if (time) { + combinedDateTime.setHours(Number(time.split(":")[0])); + combinedDateTime.setMinutes(Number(time.split(":")[1])); + combinedDateTime.setSeconds(Number(time.split(":")[2])); + } + onSelectDate(combinedDateTime); + }, [date, time, onSelectDate]); + + return ( + <> + setDate(date || undefined)} + value={date} + /> + {props?.showTimeSelection && ( + setTime(ev.target.value)} + value={time} + /> + )} + + ); +} + +DateTimePicker.defaultProps = { + showTimeSelection: false, +}; diff --git a/packages/core/components/DateRangePicker/index.tsx b/packages/core/components/DateRangePicker/index.tsx index dad0ce4b1..0e0be77b7 100644 --- a/packages/core/components/DateRangePicker/index.tsx +++ b/packages/core/components/DateRangePicker/index.tsx @@ -1,4 +1,4 @@ -import { DatePicker, Icon } from "@fluentui/react"; +import { Icon } from "@fluentui/react"; import * as React from "react"; import { TertiaryButton } from "../Buttons"; @@ -6,6 +6,7 @@ import FileFilter from "../../entity/FileFilter"; import { extractDatesFromRangeOperatorFilterString } from "../../entity/AnnotationFormatter/date-time-formatter"; import styles from "./DateRangePicker.module.css"; +import DateTimePicker from "./DateTimePicker"; interface DateRangePickerProps { className?: string; @@ -69,30 +70,18 @@ export default function DateRangePicker(props: DateRangePickerProps) {

{props.title}

- (v ? onDateRangeSelection(v, null) : onReset())} - value={extractDateFromDateString(startDate?.toISOString())} + defaultDate={extractDateFromDateString(startDate?.toISOString())} />
- (v ? onDateRangeSelection(null, v) : onReset())} - value={extractDateFromDateString(endDate?.toISOString())} + defaultDate={extractDateFromDateString(endDate?.toISOString())} /> ", () => { it("renders inputs for start and end dates with selectable date pickers", () => { // Arrange const onSearch = sinon.spy(); - const { getAllByLabelText, getAllByRole, getByLabelText, getByRole } = render( + const { getAllByText, getAllByRole, getByRole, getByText } = 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); + expect(getAllByText(/Start/).length).to.equal(1); + expect(getAllByText(/End/).length).to.equal(1); // Select a start date expect(onSearch.called).to.equal(false); - fireEvent.click(getByLabelText(/start/)); + fireEvent.click(getByText(/Start/)); fireEvent.click(getByRole("button", { name: /^18,\s/ })); expect(onSearch.called).to.equal(true); @@ -31,7 +31,7 @@ describe("", () => { // Select an end date expect(onSearch.called).to.equal(false); - fireEvent.click(getByLabelText(/end/)); + fireEvent.click(getByText(/End/)); fireEvent.click(getByRole("button", { name: /^20,\s/ })); expect(onSearch.called).to.equal(true); }); diff --git a/packages/core/components/DurationForm/DurationForm.module.css b/packages/core/components/DurationForm/DurationForm.module.css new file mode 100644 index 000000000..37011ca09 --- /dev/null +++ b/packages/core/components/DurationForm/DurationForm.module.css @@ -0,0 +1,14 @@ +.input-wrapper { + display: flex; +} + +.input-field { + margin-right: 3px; + max-height: fit-content; +} + +.input-field > input { + padding: 6px; + font-size: var(--s-paragraph-size); + max-width: 70px; +} diff --git a/packages/core/components/DurationForm/index.tsx b/packages/core/components/DurationForm/index.tsx new file mode 100644 index 000000000..f2e2eefe8 --- /dev/null +++ b/packages/core/components/DurationForm/index.tsx @@ -0,0 +1,79 @@ +import * as React from "react"; + +import NumberField from "../NumberRangePicker/NumberField"; +import annotationFormatterFactory, { AnnotationType } from "../../entity/AnnotationFormatter"; + +import styles from "./DurationForm.module.css"; + +interface DurationFormProps { + className?: string; + onChange: (totalDuration: number) => void; + title?: string; +} + +/** + * This component renders a simple form for entering durations + */ +export default function DurationForm(props: DurationFormProps) { + const { onChange } = props; + const [days, setDurationDays] = React.useState("0"); + const [hours, setDurationHours] = React.useState("0"); + const [minutes, setDurationMinutes] = React.useState("0"); + const [seconds, setDurationSeconds] = React.useState("0"); + const durationFormatter = annotationFormatterFactory(AnnotationType.DURATION); + + React.useEffect(() => { + const durationString = `${Number(days) || 0}D ${Number(hours) || 0}H ${ + Number(minutes) || 0 + }M ${Number(seconds) || 0}S`; + const totalDurationInMs = Number(durationFormatter.valueOf(durationString)); + onChange(totalDurationInMs); + }, [days, hours, minutes, seconds, durationFormatter, onChange]); + + return ( +
+

{props?.title}

+
+ setDurationDays(event?.target?.value || "")} + defaultValue={days} + min={0} + /> + setDurationHours(event?.target?.value || "")} + defaultValue={hours} + min={0} + max={23} + /> + setDurationMinutes(event?.target?.value || "")} + defaultValue={minutes} + min={0} + max={59} + /> + setDurationSeconds(event?.target?.value || "")} + defaultValue={seconds} + min={0} + max={59} + /> +
+
+ ); +} diff --git a/packages/core/components/EditMetadata/EditMetadata.module.css b/packages/core/components/EditMetadata/EditMetadata.module.css index 4c4c0c93e..68512067f 100644 --- a/packages/core/components/EditMetadata/EditMetadata.module.css +++ b/packages/core/components/EditMetadata/EditMetadata.module.css @@ -81,7 +81,7 @@ .text-field { padding-bottom: var(--margin); - max-width: 300px;; + width: 300px; } .text-field > div > label, .text-field > div > label::after { diff --git a/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx b/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx index fe663851a..e04194dfd 100644 --- a/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx +++ b/packages/core/components/EditMetadata/ExistingAnnotationPathway.tsx @@ -5,13 +5,14 @@ import * as React from "react"; import MetadataDetails, { ValueCountItem } from "./MetadataDetails"; import { PrimaryButton, SecondaryButton } from "../Buttons"; import ComboBox from "../ComboBox"; +import { AnnotationType } from "../../entity/AnnotationFormatter"; import styles from "./EditMetadata.module.css"; interface ExistingAnnotationProps { onDismiss: () => void; annotationValueMap: Map | undefined; - annotationOptions: IComboBoxOption[]; + annotationOptions: { key: string; text: string; data: string }[]; selectedFileCount: number; } @@ -23,6 +24,7 @@ export default function ExistingAnnotationPathway(props: ExistingAnnotationProps const [newValues, setNewValues] = React.useState(); const [valueCount, setValueCount] = React.useState(); const [selectedAnnotation, setSelectedAnnotation] = React.useState(); + const [annotationType, setAnnotationType] = React.useState(); const onSelectMetadataField = ( option: IComboBoxOption | undefined, @@ -61,6 +63,7 @@ export default function ExistingAnnotationPathway(props: ExistingAnnotationProps ]; } setSelectedAnnotation(selectedFieldName); + setAnnotationType(option?.data); setValueCount(valueMap); }; @@ -74,16 +77,16 @@ export default function ExistingAnnotationPathway(props: ExistingAnnotationProps {!!selectedAnnotation && ( setNewValues(value)} items={valueCount || []} + fieldType={annotationType} /> )}
diff --git a/packages/core/components/EditMetadata/MetadataDetails.module.css b/packages/core/components/EditMetadata/MetadataDetails.module.css index 35a8a2c65..9a0bb6b2f 100644 --- a/packages/core/components/EditMetadata/MetadataDetails.module.css +++ b/packages/core/components/EditMetadata/MetadataDetails.module.css @@ -43,7 +43,12 @@ } .table-title { - padding-bottom: 5px + padding-bottom: 5px; +} + +.values-title { + padding-bottom: 5px; + padding-left: 35px; } .stack { @@ -60,5 +65,43 @@ } .stack-item-right { - width: 275px; + width: 300px; +} + +.read-only-placeholder { + margin-right: 20px; + font-style: italic; + color: var(--primary-text-color); +} + +.input-field input { + background-color: var(--secondary-background-color); + border-radius: var(--small-border-radius); + border: 1px solid var(--border-color); + color: var(--secondary-text-color); + outline: none; + padding: 6px; + font-size: var(--s-paragraph-size); + width: 100%; + min-width: fit-content; +} + +.input-field:active > input, .input-field > input:active, +.input-field:focus > input, .input-field > input:focus, +.input-field:focus-within > input, .input-field > input:focus-within { + border: 1px solid var(--aqua); +} + +.input-wrapper { + margin-top: 4px; + display: flex; + align-items: center; +} + +.no-padding { + padding-bottom: 0; +} + +.forward-icon { + padding-right: 20px; } diff --git a/packages/core/components/EditMetadata/MetadataDetails.tsx b/packages/core/components/EditMetadata/MetadataDetails.tsx index c3c30c57f..d83a09df2 100644 --- a/packages/core/components/EditMetadata/MetadataDetails.tsx +++ b/packages/core/components/EditMetadata/MetadataDetails.tsx @@ -1,6 +1,7 @@ import { DetailsList, IColumn, + IComboBoxOption, Icon, IDetailsRowProps, IRenderFunction, @@ -9,8 +10,16 @@ import { StackItem, TextField, } from "@fluentui/react"; +import classNames from "classnames"; import * as React from "react"; +import ChoiceGroup from "../ChoiceGroup"; +import ComboBox from "../ComboBox"; +import DateTimePicker from "../DateRangePicker/DateTimePicker"; +import DurationForm from "../DurationForm"; +import NumberField from "../NumberRangePicker/NumberField"; +import annotationFormatterFactory, { AnnotationType } from "../../entity/AnnotationFormatter"; + import rootStyles from "./EditMetadata.module.css"; import styles from "./MetadataDetails.module.css"; @@ -20,6 +29,8 @@ export interface ValueCountItem { } interface DetailsListProps { + dropdownOptions?: IComboBoxOption[]; + fieldType?: AnnotationType; items: ValueCountItem[]; onChange: (value: string | undefined) => void; newValues?: string; @@ -41,6 +52,9 @@ export default function EditMetadataDetailsList(props: DetailsListProps) { } return <>; }; + const annotationFormatter = annotationFormatterFactory( + props.fieldType || AnnotationType.STRING + ); function renderItemColumn( item: ValueCountItem, @@ -48,13 +62,87 @@ export default function EditMetadataDetailsList(props: DetailsListProps) { column: IColumn | undefined ) { const fieldContent = item[column?.fieldName as keyof ValueCountItem] as string; - if (!fieldContent) return "[No value] (blank)"; - if (column?.fieldName === "fileCount") { - return
{fieldContent}
; + if (column?.fieldName === "value") { + if (!fieldContent) return "[No value] (blank)"; + else return annotationFormatter.displayValue(fieldContent); + } else if (column?.fieldName === "fileCount") { + return
{fieldContent || 0}
; } return fieldContent; } + const inputField = () => { + switch (props.fieldType) { + case AnnotationType.DATE: + return ( + props.onChange(date?.toISOString())} + /> + ); + case AnnotationType.DATETIME: + return ( + props.onChange(date?.toISOString())} + showTimeSelection + /> + ); + case AnnotationType.NUMBER: + return ( + props.onChange(ev?.target?.value)} + /> + ); + case AnnotationType.BOOLEAN: + return ( + props.onChange(opt?.key)} + options={[ + { + key: "true", + text: "True", + }, + { + key: "false", + text: "False", + }, + ]} + /> + ); + case AnnotationType.DURATION: + return ( + props.onChange(duration.toString())} /> + ); + case AnnotationType.DROPDOWN: + if (props?.dropdownOptions) { + return ( + + ); + } + case AnnotationType.STRING: + default: + return ( + props.onChange(e?.currentTarget?.value)} + placeholder="Type in value(s)..." + defaultValue={props.newValues?.toString()} + /> + ); + } + }; + return (
@@ -94,20 +182,14 @@ export default function EditMetadataDetailsList(props: DetailsListProps) { onRenderItemColumn={renderItemColumn} /> - - - - {/* TODO: Display different entry types depending on datatype of annotation */} - - e.currentTarget.value && props.onChange(e.currentTarget.value) - } - placeholder="Value(s)" - defaultValue={props.newValues} - /> +

Replace with

+ { +
+ + {inputField()} +
+ }
diff --git a/packages/core/components/EditMetadata/NewAnnotationPathway.tsx b/packages/core/components/EditMetadata/NewAnnotationPathway.tsx index 50dfde50c..12b05a720 100644 --- a/packages/core/components/EditMetadata/NewAnnotationPathway.tsx +++ b/packages/core/components/EditMetadata/NewAnnotationPathway.tsx @@ -30,7 +30,7 @@ export default function NewAnnotationPathway(props: NewAnnotationProps) { const [step, setStep] = React.useState(EditStep.CREATE_FIELD); const [newValues, setNewValues] = React.useState(); const [newFieldName, setNewFieldName] = React.useState(""); - const [newFieldDataType, setNewFieldDataType] = React.useState(); + const [newFieldDataType, setNewFieldDataType] = React.useState(); const [newDropdownOption, setNewDropdownOption] = React.useState(""); const [dropdownOptions, setDropdownOptions] = React.useState([]); @@ -79,17 +79,20 @@ export default function NewAnnotationPathway(props: NewAnnotationProps) { <> { + const text = + type === AnnotationType.BOOLEAN ? "Boolean (true/false)" : type; return { - key: `datatype-${type}`, - text: type, + key: type, + text, }; })} - useComboBoxAsMenuWidth - onChange={(option) => setNewFieldDataType(option?.text || "")} + onChange={(option) => + setNewFieldDataType((option?.key as AnnotationType) || "") + } /> {newFieldDataType === AnnotationType.DROPDOWN && ( <> @@ -130,13 +133,15 @@ export default function NewAnnotationPathway(props: NewAnnotationProps) { )} {step === EditStep.EDIT_FILES && ( setNewValues(value)} + fieldType={newFieldDataType} items={[ { value: undefined, fileCount: props.selectedFileCount, } as ValueCountItem, ]} + dropdownOptions={dropdownOptions} + onChange={(value) => setNewValues(value)} /> )}
diff --git a/packages/core/components/EditMetadata/index.tsx b/packages/core/components/EditMetadata/index.tsx index 1bfdfe7a3..5e424f3e1 100644 --- a/packages/core/components/EditMetadata/index.tsx +++ b/packages/core/components/EditMetadata/index.tsx @@ -34,6 +34,7 @@ export default function EditMetadataForm(props: EditMetadataProps) { return { key: annotation.name, text: annotation.displayName, + data: annotation.type, }; }); const [editPathway, setEditPathway] = React.useState( diff --git a/packages/core/components/Modal/BaseModal/BaseModal.module.css b/packages/core/components/Modal/BaseModal/BaseModal.module.css index 72f9d064b..1ef0b70e1 100644 --- a/packages/core/components/Modal/BaseModal/BaseModal.module.css +++ b/packages/core/components/Modal/BaseModal/BaseModal.module.css @@ -4,7 +4,7 @@ padding: 2em 2em 2em 2em; min-width: 300px; /* per guidance from fluentui docs: https://developer.microsoft.com/en-us/fluentui#/controls/web/modal */ min-height: 175px; /* per guidance from fluentui docs: https://developer.microsoft.com/en-us/fluentui#/controls/web/modal */ - max-width: 40%; + max-width: 45%; overflow: hidden; /* flex parent */ diff --git a/packages/core/components/Modal/BaseModal/index.tsx b/packages/core/components/Modal/BaseModal/index.tsx index 6c4297860..1892add46 100644 --- a/packages/core/components/Modal/BaseModal/index.tsx +++ b/packages/core/components/Modal/BaseModal/index.tsx @@ -10,6 +10,7 @@ interface BaseModalProps { footer?: React.ReactNode; onDismiss?: () => void; title?: string; + isStatic?: boolean; // Not draggable } const DRAG_OPTIONS: IDragOptions = { @@ -31,7 +32,7 @@ export default function BaseModal(props: BaseModalProps) { isOpen onDismiss={onDismiss} containerClassName={styles.container} - dragOptions={DRAG_OPTIONS} + dragOptions={props?.isStatic ? undefined : DRAG_OPTIONS} scrollableContentClassName={styles.scrollableContainer} titleAriaId={titleId} overlay={{ className: styles.overlay }} @@ -53,4 +54,5 @@ export default function BaseModal(props: BaseModalProps) { BaseModal.defaultProps = { footer: null, onDismiss: noop, + isStatic: false, }; diff --git a/packages/core/components/Modal/EditMetadata/index.tsx b/packages/core/components/Modal/EditMetadata/index.tsx index 0ee579106..c9b97025d 100644 --- a/packages/core/components/Modal/EditMetadata/index.tsx +++ b/packages/core/components/Modal/EditMetadata/index.tsx @@ -57,11 +57,12 @@ export default function EditMetadata({ onDismiss }: ModalProps) {
} + isStatic onDismiss={onDismissWithWarning} title={ showWarning ? "Warning! Edits in progress." - : `Edit Metadata ${filesSelectedCountString}` + : `Edit metadata ${filesSelectedCountString}` } /> ); diff --git a/packages/core/components/NumberRangePicker/NumberField.module.css b/packages/core/components/NumberRangePicker/NumberField.module.css new file mode 100644 index 000000000..ae8a8859a --- /dev/null +++ b/packages/core/components/NumberRangePicker/NumberField.module.css @@ -0,0 +1,41 @@ +.input-field { + flex-grow: 1; +} + +.input-field input { + background-color: var(--secondary-background-color); + border-radius: var(--small-border-radius); + border: 1px solid var(--border-color); + color: var(--secondary-text-color); + outline: none; + padding: 6px; + font-size: var(--s-paragraph-size); + width: 100%; + min-width: fit-content; + text-align: right; +} + +.input-field > input::placeholder { + color: var(--secondary-text-color); + font-style: italic; + text-align: left; +} + +.input-field:active > input, +.input-field > input:active, +.input-field:focus > input, +.input-field > input:focus, +.input-field:focus-within > input, +.input-field > input:focus-within { + border: 1px solid var(--aqua); +} + +.input-field input::-webkit-outer-spin-button, +.input-field input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; /* <-- Margins are still present when hidden */ +} + +.input-field > label { + line-height: 1.5 +} diff --git a/packages/core/components/NumberRangePicker/NumberField.tsx b/packages/core/components/NumberRangePicker/NumberField.tsx new file mode 100644 index 000000000..b4266860b --- /dev/null +++ b/packages/core/components/NumberRangePicker/NumberField.tsx @@ -0,0 +1,49 @@ +import classNames from "classnames"; +import * as React from "react"; + +import styles from "./NumberField.module.css"; + +interface NumberFieldProps { + className?: string; + defaultValue?: string | number; + id: string; + label?: string; + max?: number; // inclusive + min?: number; // inclusive + onChange: (event?: React.ChangeEvent) => void; + placeholder?: string; +} + +/** + * A simple wrapper to provide a consistently styled numerical input field. + * FluentUI does not have an equivalent that fulfills our UX requirements, + * so we instead use the basic html input with styling applied + */ +export default function NumberField(props: NumberFieldProps) { + function validateInput(event?: React.ChangeEvent) { + if ( + event?.target?.value && + ((props?.max && Number(event.target.value) > props?.max) || + (props?.min && Number(event.target.value) < props?.min)) + ) { + return event.target.setCustomValidity("Value out of bounds"); + } + props.onChange(event); + } + return ( +
+ {props.label && } + +
+ ); +} diff --git a/packages/core/components/NumberRangePicker/NumberRangePicker.module.css b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css index 790cd5d9a..a67b9c164 100644 --- a/packages/core/components/NumberRangePicker/NumberRangePicker.module.css +++ b/packages/core/components/NumberRangePicker/NumberRangePicker.module.css @@ -70,32 +70,6 @@ display: flex; } -.input-field { - flex-grow: 1; -} - -.input-field { - flex-grow: 1; -} - -.input-field input { - background-color: var(--secondary-background-color); - border-radius: var(--small-border-radius); - border: 1px solid var(--border-color); - color: var(--secondary-text-color); - outline: none; - padding: 6px; - font-size: var(--s-paragraph-size); - width: 100%; - min-width: fit-content; -} - -.input-field:active > input, .input-field > input:active, -.input-field:focus > input, .input-field > input:focus, -.input-field:focus-within > input, .input-field > input:focus-within { - border: 1px solid var(--aqua); -} - .range-seperator { align-items: flex-end; display: flex; @@ -116,9 +90,3 @@ .container label,input{ display: block; } - -.container input::-webkit-outer-spin-button, -.container input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; /* <-- Apparently some margin are still there even though it's hidden */ -} diff --git a/packages/core/components/NumberRangePicker/index.tsx b/packages/core/components/NumberRangePicker/index.tsx index 40da2ff08..e8dcc3bcf 100644 --- a/packages/core/components/NumberRangePicker/index.tsx +++ b/packages/core/components/NumberRangePicker/index.tsx @@ -2,6 +2,7 @@ import { Icon, Spinner, SpinnerSize } from "@fluentui/react"; import classNames from "classnames"; import * as React from "react"; +import NumberField from "./NumberField"; import { PrimaryButton, TertiaryButton } from "../Buttons"; import FileFilter from "../../entity/FileFilter"; import { extractValuesFromRangeOperatorFilterString } from "../../entity/AnnotationFormatter/number-formatter"; @@ -106,37 +107,27 @@ export default function NumberRangePicker(props: NumberRangePickerProps) {

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

-
- - -
+
-
- - -
+
{ it("formats a duration with less than a second", () => { expect(durationFormatter.displayValue(125)).to.equal("0.125S"); }); + + it("extracts time in milliseconds from formatted duration strings", () => { + expect(durationFormatter.valueOf("1D 23H 45M 6.78S")).to.equal(171906780); + }); + + it("extracts time in milliseconds from formatted strings with only days", () => { + expect(durationFormatter.valueOf("100D")).to.equal(8.64e9); + }); + + it("extracts time in milliseconds from formatted strings with only some units", () => { + expect(durationFormatter.valueOf("12H 34S")).to.equal(43234000); + }); + + it("extracts time in milliseconds from non-formatted numerical-valued strings", () => { + expect(durationFormatter.valueOf("9876")).to.equal(9876); + }); + + it("returns NaN when string letters or order don't match duration pattern", () => { + expect(isNaN(durationFormatter.valueOf("4A"))).to.be.true; + expect(isNaN(durationFormatter.valueOf("4D 3M 2H 12S"))).to.be.true; + expect(isNaN(durationFormatter.valueOf("DHMS"))).to.be.true; + }); }); });