diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 00d6689300..8647578dbe 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.53.0", + "version": "7.54.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.53.0", + "version": "7.54.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index dbd60ccf22..44ae61b70a 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.53.0", + "version": "7.54.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 0dd92ffa59..ea1d53eba8 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -299,6 +299,7 @@ import { import { QueryFormInputs } from './internal/components/forms/QueryFormInputs'; import { LookupSelectInput } from './internal/components/forms/input/LookupSelectInput'; import { SelectInput } from './internal/components/forms/input/SelectInput'; +import { RequiredSymbol } from './internal/components/forms/input/RequiredSymbol'; import { dividedOptionsRenderer, filterDividedOptions } from './internal/components/forms/input/DividedOptionsRenderer'; import { DatePickerInput } from './internal/components/forms/input/DatePickerInput'; import { FileInput } from './internal/components/forms/input/FileInput'; @@ -1651,6 +1652,7 @@ export { ReportItemModal, ReportList, request, + RequiredSymbol, RequiresPermission, resolveDetailFieldValue, resolveDetailRenderer, diff --git a/packages/components/src/internal/components/editable/EditableGrid.tsx b/packages/components/src/internal/components/editable/EditableGrid.tsx index 872bf51cf5..5d87f17894 100644 --- a/packages/components/src/internal/components/editable/EditableGrid.tsx +++ b/packages/components/src/internal/components/editable/EditableGrid.tsx @@ -63,6 +63,7 @@ import { AddRowsControl, AddRowsControlProps, PlacementType } from './Controls'; import { CellMessage, EditableColumnMetadata, EditorModel, EditorModelProps, ValueDescriptor } from './models'; import { computeRangeChange, genCellKey, getValidatedEditableGridValue, parseCellKey } from './utils'; import { RemoveColumnMenuItem } from './RemoveColumnMenuItem'; +import { RequiredSymbol } from '../forms/input/RequiredSymbol'; function anyCell(values: List): boolean { return true; @@ -694,7 +695,10 @@ export class EditableGrid extends PureComponent = editorModel.getIn(keyPath); @@ -880,7 +884,7 @@ export class EditableGrid extends PureComponent {label} - {req && *} + )} {showOverlayFromMetadata && ( @@ -1023,6 +1027,22 @@ export class EditableGrid extends PureComponent { }); test("don't show label", () => { - render(); - expect(document.body.textContent).toBe(''); + render(); + expect(document.body).toHaveTextContent(''); }); test('without overlay, with label', () => { const label = This is the label; - render(); - expect(document.querySelector('span.label-span').textContent).toBe('This is the label'); + render(); + expect(document.querySelector('span.label-span')).toHaveTextContent('This is the label'); expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(0); }); test('without overlay, with column', () => { - render(); - expect(document.body.textContent).toBe(queryColumn.caption); + render(); + expect(document.body).toHaveTextContent(queryColumn.caption); expect(document.querySelectorAll('.span.label-span')).toHaveLength(0); expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(0); }); @@ -57,13 +59,25 @@ describe('FieldLabel', () => { test('showToggle', () => { render( - + ); expect(document.querySelectorAll('.toggle')).toHaveLength(1); expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); }); + test('showToggle requires a column or an id and fieldName', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => render()).toThrow( + 'FieldLabel: when showing the toggle, either a column or an id and fieldName must be provided.' + ); + expect(() => render()).toThrow(); + expect(() => render()).toThrow(); + + consoleError.mockRestore(); + }); + test('showToggle, with labelOverlayProps, not formsy', () => { const label = 'This is the label'; const props = { @@ -72,7 +86,7 @@ describe('FieldLabel', () => { }; render( - + ); expect(document.querySelectorAll('.toggle')).toHaveLength(1); @@ -80,6 +94,53 @@ describe('FieldLabel', () => { expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); }); + test('showToggle, with labelOverlayProps, not formsy, sizes the label and toggle columns', () => { + const props: LabelOverlayProps = { isFormsy: false, label: 'This is the label' }; + const { rerender } = render( + + + + ); + + const expectToggleColumns = (): void => { + expect(document.querySelector('.control-label')).toHaveClass(INPUT_LABEL_CLASS_NAME_WITH_TOGGLE); + expect(document.querySelector('.control-label-toggle-input')).toHaveClass( + 'control-label-toggle-input-size-fixed' + ); + expect(document.querySelector('.control-label-toggle-input').parentElement).toHaveClass('col-xs-1'); + }; + + expectToggleColumns(); + + // The labelOverlayProps supplied by the caller are not modified, so the columns are sized + // consistently no matter how many times the same props object is rendered. + expect(props.labelClass).toBeUndefined(); + + rerender( + + + + ); + + expectToggleColumns(); + }); + + test('showToggle, with labelOverlayProps, not formsy, respects a supplied labelClass', () => { + const props: LabelOverlayProps = { isFormsy: false, label: 'This is the label', labelClass: 'custom-label' }; + render( + + + + ); + + expect(document.querySelector('.custom-label')).toBeInTheDocument(); + expect(document.querySelector('.custom-label')).not.toHaveClass(INPUT_LABEL_CLASS_NAME_WITH_TOGGLE); + expect(document.querySelector('.control-label-toggle-input')).not.toHaveClass( + 'control-label-toggle-input-size-fixed' + ); + expect(document.querySelector('.control-label-toggle-input').parentElement).not.toHaveClass('col-xs-1'); + }); + test('showToggle, with labelOverlayProps, formsy', () => { const label = 'This is the label'; const props = { @@ -88,7 +149,7 @@ describe('FieldLabel', () => { }; render( - + ); expect(document.querySelectorAll('.toggle')).toHaveLength(1); @@ -105,10 +166,10 @@ describe('FieldLabel', () => { render( @@ -127,10 +188,10 @@ describe('FieldLabel', () => { render( @@ -143,7 +204,7 @@ describe('FieldLabel', () => { test('showToggle, toggleProps disabled', () => { render( - + ); expect(document.querySelectorAll('.toggle')).toHaveLength(1); @@ -154,7 +215,7 @@ describe('FieldLabel', () => { test('showToggle, toggleProps not disabled', () => { render( - + ); expect(document.querySelectorAll('.toggle')).toHaveLength(1); diff --git a/packages/components/src/internal/components/forms/FieldLabel.tsx b/packages/components/src/internal/components/forms/FieldLabel.tsx index cfb552a1a9..8a3f88223a 100644 --- a/packages/components/src/internal/components/forms/FieldLabel.tsx +++ b/packages/components/src/internal/components/forms/FieldLabel.tsx @@ -2,7 +2,7 @@ * Copyright (c) 2019-2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced in * any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ -import React, { Component, CSSProperties, ReactNode } from 'react'; +import React, { FC, memo, ReactNode } from 'react'; import classNames from 'classnames'; @@ -14,7 +14,7 @@ import { getFieldEnabledFieldName } from './utils'; import { LabelOverlay, LabelOverlayProps } from './LabelOverlay'; import { INPUT_LABEL_CLASS_NAME_WITH_TOGGLE } from './constants'; -interface ToggleProps { +export interface ToggleProps { onClick: () => void; toolTip?: string; } @@ -28,80 +28,68 @@ export interface FieldLabelProps { labelOverlayProps?: LabelOverlayProps; showLabel?: boolean; showToggle?: boolean; - style?: CSSProperties; toggleClassName?: string; toggleProps?: Partial; withLabelOverlay?: boolean; } -export class FieldLabel extends Component { - static defaultProps = { - showLabel: true, - withLabelOverlay: true, - }; +export const FieldLabel: FC = memo(props => { + const { + column, + fieldName, + id, + isDisabled, + label, + labelOverlayProps, + showLabel = true, + showToggle, + toggleClassName, + toggleProps, + withLabelOverlay = true, + } = props; - constructor(props: FieldLabelProps) { - super(props); - - if (props.showToggle && !props.column && (!props.id || !props.fieldName)) { - throw new Error( - 'FieldLabel: when showing the toggle, either a column or an id and fieldName must be provided.' - ); - } + if (showToggle && !column && (!id || !fieldName)) { + throw new Error( + 'FieldLabel: when showing the toggle, either a column or an id and fieldName must be provided.' + ); } - render() { - const { - label, - column, - fieldName, - id, - labelOverlayProps, - showLabel, - showToggle, - isDisabled, - toggleProps, - withLabelOverlay, - toggleClassName, - } = this.props; - - if (!showLabel) return null; + if (!showLabel) return null; - // when not displaying with Formsy and we are displaying the field toggle, we adjust - // the columns since the toggle appears outside the label. - let toggleContainerClassName, - toggleWrapperClassName = 'control-label-toggle-input'; - if (showToggle && labelOverlayProps && !labelOverlayProps.isFormsy && !labelOverlayProps.labelClass) { - labelOverlayProps.labelClass = INPUT_LABEL_CLASS_NAME_WITH_TOGGLE; - toggleContainerClassName = 'col-xs-1'; - toggleWrapperClassName += ' control-label-toggle-input-size-fixed'; - } + // When not displaying with Formsy and we are displaying the field toggle, we adjust + // the columns since the toggle appears outside the label. A label class supplied by the + // caller always takes precedence. + const adjustColumnsForToggle = !!( + showToggle && + labelOverlayProps && + !labelOverlayProps.isFormsy && + !labelOverlayProps.labelClass + ); - let labelBody; - if (withLabelOverlay) { - labelBody = ; - } else { - labelBody = label ? label : column ? column.caption : null; - } + const labelClass = adjustColumnsForToggle ? INPUT_LABEL_CLASS_NAME_WITH_TOGGLE : labelOverlayProps?.labelClass; + const toggleWrapperClassName = classNames(toggleClassName, 'control-label-toggle-input', { + 'control-label-toggle-input-size-fixed': adjustColumnsForToggle, + }); - return ( - <> - {labelBody} - {showToggle && ( - -
- -
-
- )} - - ); - } -} + return ( + <> + {withLabelOverlay && } + {!withLabelOverlay && (label ? label : column ? column.caption : null)} + {showToggle && ( + +
+ +
+
+ )} + + ); +}); +FieldLabel.displayName = 'FieldLabel'; diff --git a/packages/components/src/internal/components/forms/LabelOverlay.test.tsx b/packages/components/src/internal/components/forms/LabelOverlay.test.tsx index 08ba1fe64f..87caa7f735 100644 --- a/packages/components/src/internal/components/forms/LabelOverlay.test.tsx +++ b/packages/components/src/internal/components/forms/LabelOverlay.test.tsx @@ -9,52 +9,52 @@ import { LabelOverlay } from './LabelOverlay'; describe('LabelOverlay', () => { it('renders label with overlay, not formsy', () => { render(); - expect(document.querySelector('.control-label')?.textContent).toBe('Test Label '); + expect(document.querySelector('.control-label')).toHaveTextContent('Test Label'); expect(document.querySelectorAll('.fa-question-circle')).toHaveLength(1); }); it('renders label with overlay and required symbol when required, not formsy', () => { - render(); - expect(document.querySelector('.control-label')?.textContent).toBe('Test Label * '); + render(); + expect(document.querySelector('.control-label')).toHaveTextContent('Test Label *'); expect(document.querySelectorAll('.fa-question-circle')).toHaveLength(1); }); it('renders label with overlay and required, but addLabelAsterisk = false, not formsy', () => { - render(); - expect(document.querySelector('.control-label')?.textContent).toBe('Test Label * '); + render(); + expect(document.querySelector('.control-label')).toHaveTextContent('Test Label *'); expect(document.querySelectorAll('.fa-question-circle')).toHaveLength(1); }); it('renders label with overlay, required = false, and addLabelAsterisk = true, not formsy', () => { render(); - expect(document.querySelector('.control-label')?.textContent).toBe('Test Label * '); + expect(document.querySelector('.control-label')).toHaveTextContent('Test Label *'); expect(document.querySelectorAll('.fa-question-circle')).toHaveLength(1); }); it('renders label with no overlay and required symbol when required, not formsy', () => { - render(); - expect(document.querySelector('.control-label')?.textContent).toBe('Test Label * '); + render(); + expect(document.querySelector('.control-label')).toHaveTextContent('Test Label *'); expect(document.querySelectorAll('.fa-question-circle')).toHaveLength(0); }); it('renders label with overlay, isFormsy = true (default)', () => { render(); expect(document.querySelector('.control-label')).toBeNull(); - expect(document.querySelector('span')?.textContent).toBe('Test Label '); + expect(document.querySelector('span')).toHaveTextContent('Test Label'); expect(document.querySelectorAll('.fa-question-circle')).toHaveLength(1); }); it('renders label with overlay and required', () => { - render(); + render(); expect(document.querySelector('.control-label')).toBeNull(); - expect(document.querySelector('span')?.textContent).toBe('Test Label * '); + expect(document.querySelector('span')).toHaveTextContent('Test Label *'); expect(document.querySelectorAll('.fa-question-circle')).toHaveLength(1); }); it('renders label with overlay and addLabelAsterisk', () => { - render(); + render(); expect(document.querySelector('.control-label')).toBeNull(); - expect(document.querySelector('span')?.textContent).toBe('Test Label * '); + expect(document.querySelector('span')).toHaveTextContent('Test Label *'); expect(document.querySelectorAll('.fa-question-circle')).toHaveLength(1); }); }); diff --git a/packages/components/src/internal/components/forms/LabelOverlay.tsx b/packages/components/src/internal/components/forms/LabelOverlay.tsx index 49571edfc1..f7369cfe89 100644 --- a/packages/components/src/internal/components/forms/LabelOverlay.tsx +++ b/packages/components/src/internal/components/forms/LabelOverlay.tsx @@ -14,6 +14,7 @@ import { Placement } from '../../useOverlayPositioning'; import { HelpTipRenderer } from './HelpTipRenderer'; import { INPUT_LABEL_CLASS_NAME } from './constants'; import { DOMAIN_FIELD, DomainFieldHelpTipContents } from './DomainFieldHelpTipContents'; +import { RequiredSymbol } from './input/RequiredSymbol'; export interface LabelOverlayProps extends PropsWithChildren { addLabelAsterisk?: boolean; @@ -46,7 +47,7 @@ export class LabelOverlay extends React.Component { this._popoverId = generateId(); } - overlayBody = (): any => { + overlayBody = (): ReactNode => { const { column, required, description, type, children, helpTipRenderer } = this.props; if (column?.helpTipRenderer || helpTipRenderer) { @@ -97,15 +98,16 @@ export class LabelOverlay extends React.Component { const label = this.props.label ? this.props.label : column ? column.caption : null; const overlay = this.getOverlay(); + const isSymbolRequired = required || addLabelAsterisk; if (isFormsy) { - // when being used as a label for a formsy component directly this will use just a span without the + // When being used as a label for a formsy component directly, this will use just a span without the // classes applied as well as not needing to handle 'required' display return ( {label}  {overlay} - {required || addLabelAsterisk ? * : null} + ); } @@ -115,10 +117,11 @@ export class LabelOverlay extends React.Component { {label}  {overlay} - {required || addLabelAsterisk ? * : null} + ); } + return ( ); } diff --git a/packages/components/src/internal/components/forms/QueryFormInputs.tsx b/packages/components/src/internal/components/forms/QueryFormInputs.tsx index c1af3abb05..5039186017 100644 --- a/packages/components/src/internal/components/forms/QueryFormInputs.tsx +++ b/packages/components/src/internal/components/forms/QueryFormInputs.tsx @@ -200,10 +200,12 @@ export class QueryFormInputs extends React.Component @@ -267,7 +269,7 @@ export class QueryFormInputs extends React.Component {this.renderLabelField(col)} { @@ -135,7 +128,7 @@ describe('DetailDisplay', () => { render( { }); render(
{defaultTitleRenderer(col)}
); expect(document.querySelector('span').innerHTML).toEqual( - 'test 
* ' + 'test 
*' ); }); diff --git a/packages/components/src/internal/components/forms/input/CheckboxInput.tsx b/packages/components/src/internal/components/forms/input/CheckboxInput.tsx index ed38fec4a8..c21521c3ec 100644 --- a/packages/components/src/internal/components/forms/input/CheckboxInput.tsx +++ b/packages/components/src/internal/components/forms/input/CheckboxInput.tsx @@ -17,6 +17,7 @@ import { } from '../constants'; import { DisableableInput, DisableableInputProps, DisableableInputState } from './DisableableInput'; +import { RequiredSymbol } from './RequiredSymbol'; interface CheckboxInputProps extends DisableableInputProps { addLabelAsterisk?: boolean; @@ -99,14 +100,14 @@ class CheckboxInputImpl extends DisableableInput {renderFieldLabel ? ( ) : ( = props => { const ref = useRef(null); useEffect(() => { - if (ref?.current) - ref.current.indeterminate = true; + if (ref?.current) ref.current.indeterminate = true; }, [ref?.current]); return ; diff --git a/packages/components/src/internal/components/forms/input/DatePickerInput.tsx b/packages/components/src/internal/components/forms/input/DatePickerInput.tsx index f10dc3c676..3363512284 100644 --- a/packages/components/src/internal/components/forms/input/DatePickerInput.tsx +++ b/packages/components/src/internal/components/forms/input/DatePickerInput.tsx @@ -29,6 +29,7 @@ import { } from '../constants'; import { DisableableInput, DisableableInputProps, DisableableInputState } from './DisableableInput'; +import { RequiredSymbol } from './RequiredSymbol'; export interface DatePickerInputProps extends DisableableInputProps { addLabelAsterisk?: boolean; @@ -162,8 +163,7 @@ export class DatePickerInputImpl extends DisableableInput { this.input.current?.setFocus(); @@ -277,9 +280,7 @@ export class DatePickerInputImpl extends DisableableInput {renderFieldLabel(queryColumn)} - {queryColumn?.required && *} + ) : ( { + describe('useDisableableInput', () => { + test('inputValue only reflects local edits when allowDisable is true', () => { + const disableable = renderHook(() => + useDisableableInput({ allowDisable: true, value: 'fromProps' }) + ); + + expect(disableable.result.current.inputValue).toBe('fromProps'); + + act(() => { + disableable.result.current.setInputValue('edited'); + }); + + expect(disableable.result.current.inputValue).toBe('edited'); + + // Without allowDisable the value from props always wins, mirroring DisableableInput.getInputValue() + const notDisableable = renderHook(() => useDisableableInput({ value: 'fromProps' })); + + act(() => { + notDisableable.result.current.setInputValue('edited'); + }); + + expect(notDisableable.result.current.inputValue).toBe('fromProps'); + }); + + test('discards local edits when the input is disabled', () => { + const { result } = renderHook(() => + useDisableableInput({ allowDisable: true, value: 'fromProps' }) + ); + + act(() => { + result.current.setInputValue('edited'); + }); + + expect(result.current.inputValue).toBe('edited'); + + // Disabling reverts to the value from props ... + act(() => { + result.current.toggleDisabled(); + }); + + expect(result.current.isDisabled).toBe(true); + expect(result.current.inputValue).toBe('fromProps'); + + // ... and re-enabling does not resurrect the discarded edit + act(() => { + result.current.toggleDisabled(); + }); + + expect(result.current.isDisabled).toBe(false); + expect(result.current.inputValue).toBe('fromProps'); + }); + + test('preserves a null local edit, falling back to props only for undefined', () => { + // FileInput uses null to mean "the attached file was removed", so a null edit must not be treated as + // an absent edit the way undefined is. + const { result } = renderHook(() => + useDisableableInput({ allowDisable: true, value: 'attachedFile.txt' }) + ); + + act(() => { + result.current.setInputValue(null); + }); + + expect(result.current.inputValue).toBeNull(); + + act(() => { + result.current.setInputValue(undefined); + }); + + expect(result.current.inputValue).toBe('attachedFile.txt'); + }); + + test('notifies onToggleDisable with the new disabled state', () => { + const onToggleDisable = jest.fn(); + const { result } = renderHook(() => + useDisableableInput({ + allowDisable: true, + initiallyDisabled: true, + onToggleDisable, + value: 'fromProps', + }) + ); + + // initiallyDisabled seeds the state without notifying + expect(result.current.isDisabled).toBe(true); + expect(onToggleDisable).not.toHaveBeenCalled(); + + act(() => { + result.current.toggleDisabled(); + }); + + expect(result.current.isDisabled).toBe(false); + expect(onToggleDisable).toHaveBeenLastCalledWith(false); + + act(() => { + result.current.toggleDisabled(); + }); + + expect(result.current.isDisabled).toBe(true); + expect(onToggleDisable).toHaveBeenLastCalledWith(true); + expect(onToggleDisable).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/components/src/internal/components/forms/input/DisableableInput.tsx b/packages/components/src/internal/components/forms/input/DisableableInput.tsx index d366d8a3fe..95c95907d6 100644 --- a/packages/components/src/internal/components/forms/input/DisableableInput.tsx +++ b/packages/components/src/internal/components/forms/input/DisableableInput.tsx @@ -2,14 +2,14 @@ * Copyright (c) 2019-2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ -import React from 'react'; +import React, { useCallback, useState } from 'react'; -export interface DisableableInputProps { +export interface DisableableInputProps { allowDisable?: boolean; hasMixedValue?: boolean; initiallyDisabled?: boolean; onToggleDisable?: (disabled: boolean) => void; - value?: any; + value?: V; } export interface DisableableInputState { @@ -17,7 +17,9 @@ export interface DisableableInputState { isDisabled?: boolean; } -// TODO: convert this to a hook, use the hook instead of inheriting from this class +/** + * @deprecated use the useDisableableInput() hook from a function component instead of inheriting from this class. + */ export class DisableableInput

extends React.Component< P, S @@ -48,3 +50,74 @@ export class DisableableInput

{ + /** + * The value the input should render. This accounts for the disabled state, falling back to the value from props + * when the input is not disableable or when the user has not yet edited it. Equivalent to the + * DisableableInput class's getInputValue(). + */ + inputValue: V; + isDisabled: boolean; + /** + * Records the value as the user edits it so it can be reverted when the input is subsequently disabled. + * Call this from the input's onChange handler. + */ + setInputValue: (value: V) => void; + /** Toggles the disabled state, notifying onToggleDisable with the new state. */ + toggleDisabled: () => void; +} + +/** + * React hook that provides the "disableable input" behavior offered by the DisableableInput class to function + * components. Field labels render a toggle when allowDisable is true (see FieldLabel's showToggle/toggleProps); + * wiring that toggle to toggleDisabled lets the user turn the field off, which reverts any local edits back to the + * value from props. + * + * Example: + * ```tsx + * const MyInput: FC = props => { + * const { onChange, queryColumn } = props; + * const { inputValue, isDisabled, setInputValue, toggleDisabled } = useDisableableInput(props); + * + * const onInputChange = useCallback>(event => { + * setInputValue(event.target.value); + * onChange?.(event.target.value); + * }, [onChange, setInputValue]); + * + * return ( + * <> + * + * + * + * ); + * }; + * ``` + */ +export function useDisableableInput(props: DisableableInputProps): UseDisableableInput { + const { allowDisable = false, initiallyDisabled = false, onToggleDisable, value } = props; + const [isDisabled, setIsDisabled] = useState(initiallyDisabled); + const [inputValue, setInputValue] = useState(value); + + const toggleDisabled = useCallback(() => { + const disabled = !isDisabled; + + // When disabling, discard any local edits so the value from props is restored + if (disabled) setInputValue(value); + + setIsDisabled(disabled); + onToggleDisable?.(disabled); + }, [isDisabled, onToggleDisable, value]); + + return { + inputValue: !allowDisable || inputValue === undefined ? value : inputValue, + isDisabled, + setInputValue, + toggleDisabled, + }; +} diff --git a/packages/components/src/internal/components/forms/input/FileInput.test.tsx b/packages/components/src/internal/components/forms/input/FileInput.test.tsx index 60032225e9..375f8bd662 100644 --- a/packages/components/src/internal/components/forms/input/FileInput.test.tsx +++ b/packages/components/src/internal/components/forms/input/FileInput.test.tsx @@ -2,28 +2,313 @@ * Copyright (c) 2025-2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; import { Map } from 'immutable'; -import { initializeValue } from './FileInput'; +import { QueryColumn } from '../../../../public/QueryColumn'; +import { Formsy } from '../formsy'; +import { INPUT_LABEL_CLASS_NAME_WITH_TOGGLE, MIXED_VALUE_DISPLAY } from '../constants'; + +import { FileInput, FileInputProps, initializeValue } from './FileInput'; + +const FILE_COLUMN = new QueryColumn({ + caption: 'Attached File', + fieldKey: 'attachedFile', + inputType: 'file', + name: 'attachedFile', +}); + +const REQUIRED_FILE_COLUMN = FILE_COLUMN.mutate({ required: true }) as QueryColumn; + +interface FormsyResult extends RenderResult { + onChange: jest.Mock; + onInvalid: jest.Mock; + onValid: jest.Mock; +} + +function renderInForm(props?: Partial): FormsyResult { + const onChange = jest.fn(); + const onInvalid = jest.fn(); + const onValid = jest.fn(); + + const result = render( + + + + ); + + return { ...result, onChange, onInvalid, onValid }; +} + +function selectFile(): Promise { + return userEvent.upload( + document.querySelector('input[type="file"]'), + new File(['file contents'], 'attachment.txt', { type: 'text/plain' }) + ); +} + +const ENABLED_FIELD_SELECTOR = `input[name="${FILE_COLUMN.fieldKey}::enabled"]`; +const FILE_INPUT_SELECTOR = 'input[type="file"]'; + +function clickToggle(): Promise { + return userEvent.click(document.querySelector('.toggle-group-icon button')); +} + +// FieldLabel sizes the label/toggle columns for a disableable input, deriving the classes from the +// labelOverlayProps it is given without modifying them +function expectToggleLayout(container: HTMLElement): void { + expect(container.querySelector('.control-label')).toHaveClass(INPUT_LABEL_CLASS_NAME_WITH_TOGGLE); + expect(container.querySelector('.control-label-toggle-input')).toHaveClass('control-label-toggle-input-size-fixed'); +} + +function expectEnabled(container: HTMLElement): void { + expectToggleLayout(container); + expect(container.querySelector('.fa-toggle-on')).toBeInTheDocument(); + expect(container.querySelector('.fa-toggle-off')).not.toBeInTheDocument(); + expect(container.querySelector(ENABLED_FIELD_SELECTOR)).toHaveAttribute('value', 'true'); + expect(container.querySelector(FILE_INPUT_SELECTOR)).toBeEnabled(); + expect(container.querySelector('.file-upload--compact-label')).not.toHaveClass('file-upload--is-disabled'); +} + +function expectDisabled(container: HTMLElement): void { + expectToggleLayout(container); + expect(container.querySelector('.fa-toggle-off')).toBeInTheDocument(); + expect(container.querySelector('.fa-toggle-on')).not.toBeInTheDocument(); + expect(container.querySelector(ENABLED_FIELD_SELECTOR)).toHaveAttribute('value', 'false'); + expect(container.querySelector(FILE_INPUT_SELECTOR)).toBeDisabled(); + expect(container.querySelector('.file-upload--compact-label')).toHaveClass('file-upload--is-disabled'); +} describe('FileInput', () => { test('initializeValue', () => { - expect(initializeValue(undefined)).toEqual({ data: undefined, formValue: undefined }); - expect(initializeValue(null)).toEqual({ data: undefined, formValue: undefined }); - expect(initializeValue('')).toEqual({ data: undefined, formValue: undefined }); - expect(initializeValue(' ')).toEqual({ data: undefined, formValue: undefined }); + expect(initializeValue(undefined)).toEqual({ data: undefined, value: undefined }); + expect(initializeValue(null)).toEqual({ data: undefined, value: undefined }); + expect(initializeValue('')).toEqual({ data: undefined, value: undefined }); + expect(initializeValue(' ')).toEqual({ data: undefined, value: undefined }); expect(initializeValue(' some/file/path1 ')).toEqual({ data: 'some/file/path1', - formValue: 'some/file/path1', + value: 'some/file/path1', }); - expect(initializeValue(Map())).toEqual({ data: Map(), formValue: undefined }); + expect(initializeValue(Map())).toEqual({ data: Map(), value: undefined }); expect(initializeValue(Map({ path: 'some/file/path' }))).toEqual({ data: Map({ path: 'some/file/path' }), - formValue: undefined, + value: undefined, }); expect(initializeValue(Map({ value: 'some/file/path' }))).toEqual({ data: Map({ value: 'some/file/path' }), - formValue: 'some/file/path', + value: 'some/file/path', + }); + }); + + describe('required', () => { + test('invalidates the form when a required field does not have a value', async () => { + const { container, onInvalid, onValid } = renderInForm(); + + expect(onInvalid).toHaveBeenCalled(); + expect(onValid).not.toHaveBeenCalled(); + expect(container.querySelector('.required-symbol')).toBeInTheDocument(); + + // Selecting a file supplies the value the "required" validation is looking for + await selectFile(); + + expect(onValid).toHaveBeenCalled(); + }); + + test('does not invalidate the form when the field is not required', () => { + const { container, onInvalid, onValid } = renderInForm({ queryColumn: FILE_COLUMN }); + + expect(onValid).toHaveBeenCalled(); + expect(onInvalid).not.toHaveBeenCalled(); + expect(container.querySelector('.required-symbol')).not.toBeInTheDocument(); + }); + + test('respects the "required" prop when a queryColumn is not supplied', () => { + const { onInvalid, onValid } = renderInForm({ queryColumn: undefined, required: true }); + + expect(onInvalid).toHaveBeenCalled(); + expect(onValid).not.toHaveBeenCalled(); + }); + + test('an initial value satisfies a required field without dirtying the form', () => { + const { onChange, onInvalid, onValid } = renderInForm({ + initialValue: Map({ value: 'some/file/path.txt' }), + }); + + expect(onValid).toHaveBeenCalled(); + expect(onInvalid).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('invalidates the form when the value of a required field is removed', async () => { + const { container, onInvalid, onValid } = renderInForm({ initialValue: 'some/file/path.txt' }); + + expect(onValid).toHaveBeenCalled(); + expect(onInvalid).not.toHaveBeenCalled(); + + await userEvent.click(container.querySelector('.attached-file__remove-icon')); + + expect(onInvalid).toHaveBeenCalled(); + }); + + // Formsy does not produce a message for a bare "required" violation, so an empty required field is + // communicated by the asterisk and by the form being invalid, not by an error on the input itself. + test('does not display an error for an empty required field', async () => { + const { container } = renderInForm({ initialValue: 'some/file/path.txt' }); + + expect(container.querySelector('.has-error')).not.toBeInTheDocument(); + + await userEvent.click(container.querySelector('.attached-file__remove-icon')); + + expect(container.querySelector('.has-error')).not.toBeInTheDocument(); + expect(container.querySelector('.help-block')).not.toBeInTheDocument(); + }); + }); + + describe('validation errors', () => { + const VALIDATION_ERROR = 'Unable to attach this file.'; + + function renderWithFormErrors(validationErrors?: Record): RenderResult { + return render( + + + + ); + } + + test('displays an error supplied by the form', () => { + const { container, rerender } = renderWithFormErrors(); + + expect(container.querySelector('.has-error')).not.toBeInTheDocument(); + + // Errors are pushed down by Formsy after the initial render, e.g., in response to a failed submission + rerender( + + + + ); + + expect(container.querySelector('.has-error')).toBeInTheDocument(); + expect(container.querySelector('.help-block')).toHaveTextContent(VALIDATION_ERROR); + }); + + test('does not display an error belonging to a different field', () => { + const { container, rerender } = renderWithFormErrors(); + + rerender( + + + + ); + + expect(container.querySelector('.has-error')).not.toBeInTheDocument(); + }); + + test('does not display an error when not bound to formsy', () => { + const { container } = render(); + + expect(container.querySelector('.has-error')).not.toBeInTheDocument(); + }); + }); + + describe('disabled state', () => { + const DISABLEABLE_PROPS: Partial = { allowDisable: true, queryColumn: FILE_COLUMN }; + + test('does not render a toggle when allowDisable is not specified', () => { + const { container } = renderInForm({ queryColumn: FILE_COLUMN }); + + expect(container.querySelector('.toggle-group-icon')).not.toBeInTheDocument(); + expect(container.querySelector(ENABLED_FIELD_SELECTOR)).not.toBeInTheDocument(); + expect(container.querySelector(FILE_INPUT_SELECTOR)).toBeEnabled(); + }); + + test('renders an enabled field when allowDisable', () => { + const { container } = renderInForm(DISABLEABLE_PROPS); + expectEnabled(container); + }); + + test('renders a disabled field when initiallyDisabled', () => { + const { container } = renderInForm({ ...DISABLEABLE_PROPS, initiallyDisabled: true }); + expectDisabled(container); + }); + + test('toggling notifies onToggleDisable with the new disabled state', async () => { + const onToggleDisable = jest.fn(); + const { container } = renderInForm({ ...DISABLEABLE_PROPS, onToggleDisable }); + + await clickToggle(); + + expect(onToggleDisable).toHaveBeenLastCalledWith(true); + expectDisabled(container); + + await clickToggle(); + + expect(onToggleDisable).toHaveBeenLastCalledWith(false); + expectEnabled(container); + expect(onToggleDisable).toHaveBeenCalledTimes(2); + }); + + test('does not allow toggling when toggleDisabledTooltip is supplied', async () => { + const onToggleDisable = jest.fn(); + const { container } = renderInForm({ + ...DISABLEABLE_PROPS, + onToggleDisable, + toggleDisabledTooltip: 'Cannot be updated', + }); + + expect(container.querySelector('.toggle-group-icon')).toHaveClass('disabled'); + expect(container.querySelector('.label-help-target')).toBeInTheDocument(); + + await clickToggle(); + + expect(onToggleDisable).not.toHaveBeenCalled(); + expectEnabled(container); + }); + + test('displays mixed values only while disabled', async () => { + const { container } = renderInForm({ ...DISABLEABLE_PROPS, hasMixedValue: true, initiallyDisabled: true }); + + expect(container.querySelector('.field__un-editable')).toHaveTextContent(MIXED_VALUE_DISPLAY); + expect(container.querySelector('.fa-cloud-upload')).not.toBeInTheDocument(); + + await clickToggle(); + + expect(container.querySelector('.field__un-editable')).not.toBeInTheDocument(); + expect(container.querySelector('.fa-cloud-upload')).toBeInTheDocument(); + }); + + test('retains a selected file when the field is subsequently disabled', async () => { + const { container, onChange: onFormChange } = renderInForm(DISABLEABLE_PROPS); + + await selectFile(); + await clickToggle(); + + // Unlike other disableable inputs, which revert to the value from props when toggled off, FileInput + // retains the selected file. This intentionally preserves the behavior it had before it adopted + // useDisableableInput(): it never recorded the selection via setInputValue()/getInputValue(). The retained + // value is still excluded when submitting by getUpdatedFields(). + expect(container.querySelector('.attached-file__inline-container')).toHaveTextContent('attachment.txt'); + expect(onFormChange).toHaveBeenLastCalledWith( + expect.objectContaining({ [FILE_COLUMN.fieldKey]: expect.any(File) }), + expect.anything() + ); + }); + + test('does not allow removing an existing attachment while disabled', async () => { + const { container } = renderInForm({ + ...DISABLEABLE_PROPS, + initialValue: Map({ value: 'some/file/path.txt' }), + initiallyDisabled: true, + }); + + await userEvent.click(container.querySelector('.attachment-card__menu button')); + expect(document.querySelector('.dropdown-menu')).not.toHaveTextContent('Remove'); + + await clickToggle(); + await userEvent.click(container.querySelector('.attachment-card__menu button')); + + expect(document.querySelector('.dropdown-menu')).toHaveTextContent('Remove'); }); }); }); diff --git a/packages/components/src/internal/components/forms/input/FileInput.tsx b/packages/components/src/internal/components/forms/input/FileInput.tsx index 721ea587ff..a53e143862 100644 --- a/packages/components/src/internal/components/forms/input/FileInput.tsx +++ b/packages/components/src/internal/components/forms/input/FileInput.tsx @@ -2,13 +2,22 @@ * Copyright (c) 2019-2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ -import React, { FC, ReactNode, RefObject } from 'react'; +import React, { + DragEventHandler, + FC, + FormEventHandler, + ReactNode, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; import classNames from 'classnames'; import { Map } from 'immutable'; import { FormsyInjectedProps, withFormsy } from '../formsy'; import { INPUT_WRAPPER_CLASS_NAME, MIXED_VALUE_DISPLAY } from '../constants'; -import { FieldLabel } from '../FieldLabel'; +import { FieldLabel, ToggleProps } from '../FieldLabel'; import { cancelEvent } from '../../../events'; import { QueryColumn } from '../../../../public/QueryColumn'; @@ -20,29 +29,33 @@ import { fileMatchesAcceptedFormat } from '../../files/actions'; import { getTransferItemDirectoryEntry } from '../../files/FileAttachmentContainer'; -import { DisableableInput, DisableableInputProps, DisableableInputState } from './DisableableInput'; +import { DisableableInputProps, useDisableableInput } from './DisableableInput'; import { generateId } from '../../../util/utils'; +import { LabelOverlayProps } from '../LabelOverlay'; +import { RequiredSymbol } from './RequiredSymbol'; type FileInputData = Map | string | undefined; +type FileInputValue = File | null | string | undefined; -export function initializeValue(initialValue: any): { data: FileInputData; formValue: string | undefined } { +export function initializeValue(initialValue: any): { data: FileInputData; value: string | undefined } { let data: Map | string | undefined; - let formValue: string; + let value: string; + if (Map.isMap(initialValue)) { data = initialValue; - formValue = initialValue.get('value'); + value = initialValue.get('value'); } else if (typeof initialValue === 'string') { const trimmedValue = initialValue.trim(); if (trimmedValue !== '') { data = trimmedValue; - formValue = trimmedValue; + value = trimmedValue; } } - return { data, formValue }; + return { data, value }; } -export interface FileInputProps extends DisableableInputProps { +export interface FileInputProps extends DisableableInputProps { acceptedFormats?: string; addLabelAsterisk?: boolean; changeDebounceInterval?: number; @@ -56,263 +69,268 @@ export interface FileInputProps extends DisableableInputProps { onChange?: (fileMap: Record) => void; queryColumn?: QueryColumn; renderFieldLabel?: (queryColumn: QueryColumn, label?: string, description?: string) => ReactNode; + required?: boolean; showLabel?: boolean; toggleDisabledTooltip?: string; } -type FileInputImplProps = FileInputProps & FormsyInjectedProps; - -interface State extends DisableableInputState { - data: FileInputData; - error: string; - file: File; - isHover: boolean; +interface FileInputImplOwnProps { + initialData: FileInputData; } -class FileInputImpl extends DisableableInput { - fileInput: RefObject; - inputId: string; - - static defaultProps = { - ...DisableableInput.defaultProps, - ...{ - changeDebounceInterval: 0, - elementWrapperClassName: INPUT_WRAPPER_CLASS_NAME, - showLabel: true, +type FileInputImplProps = FileInputImplOwnProps & FileInputProps & FormsyInjectedProps; + +const FileInputImpl: FC = props => { + const { + acceptedFormats, + addLabelAsterisk, + allowDisable = false, + initialData, + elementWrapperClassName = INPUT_WRAPPER_CLASS_NAME, + emptyFileNotAllowed, + errorMessage, + formsy, + hasMixedValue, + labelClassName, + maxFileSize, + name, + onChange, + queryColumn, + renderFieldLabel, + required, + setValue, + showLabel = true, + toggleDisabledTooltip, + } = props; + // The initialValue prop is only respected on mount. + const [data, setData] = useState(initialData); + const [error, setError] = useState(''); + const [file, setFile] = useState(null); + const [isHover, setIsHover] = useState(false); + const fileInput = useRef(null); + const { isDisabled, toggleDisabled } = useDisableableInput(props); + + // Issue 53394: Distinct input ID so it does not collide with other elements on the page + const inputId = useMemo(() => generateId('fileUpload-'), []); + const inputName = name ?? queryColumn.fieldKey; + const hasCustomFieldLabel = !!renderFieldLabel; + const hasError = formsy && !!errorMessage; + + const setFormValue = useCallback( + (file_: File): void => { + setData(undefined); + setError(''); + setFile(file_); + onChange?.({ [inputName]: file_ }); + + if (formsy) { + setValue?.(file_); + } }, - }; - - constructor(props: FileInputImplProps) { - super(props); - this.toggleDisabled = this.toggleDisabled.bind(this); - - // Issue 53394: Distinct input ID so it does not collide with other elements on the page - this.inputId = generateId('fileUpload-'); - this.fileInput = React.createRef(); - const { data, formValue } = initializeValue(props.initialValue); - - this.state = { - data, - file: null, - error: '', - isDisabled: props.initiallyDisabled, - isHover: false, - }; - - if (formValue) { - props.setValue?.(formValue); - } - } + [formsy, inputName, onChange, setValue] + ); - getInputName(): string { - return this.props.name ?? this.props.queryColumn.fieldKey; - } - - processFiles = (fileList: FileList, transferItems?: DataTransferItemList): void => { - const { acceptedFormats, maxFileSize, emptyFileNotAllowed } = this.props; - if (fileList.length > 1) { - this.setState({ error: 'Only one file allowed' }); - return; - } - - if (getTransferItemDirectoryEntry(transferItems, 0)) { - this.setState({ error: 'Folders are not supported, only one file allowed' }); - return; - } - - const file = fileList[0]; - if (acceptedFormats) { - const formatCheck = fileMatchesAcceptedFormat(file.name, acceptedFormats); - if (!formatCheck.isMatch) { - this.setState({ error: 'Invalid file type.' }); + const processFiles = useCallback( + (fileList: FileList, transferItems?: DataTransferItemList): void => { + if (fileList.length > 1) { + setError('Only one file allowed'); return; } - } - if (maxFileSize && file.size > maxFileSize) { - this.setState({ - error: `File size must not exceed ${Math.round(maxFileSize / 1024).toLocaleString()} KB.`, - }); - return; - } - if (emptyFileNotAllowed && file.size === 0) { - this.setState({ error: 'Empty file is not allowed.' }); - return; - } - this.setFormValue(file); - }; + if (getTransferItemDirectoryEntry(transferItems, 0)) { + setError('Folders are not supported, only one file allowed'); + return; + } - setFormValue = (file: File): void => { - const { formsy, onChange, setValue } = this.props; - this.setState({ data: undefined, file, error: '' }); - onChange?.({ [this.getInputName()]: file }); + const file_ = fileList[0]; + if (acceptedFormats) { + const formatCheck = fileMatchesAcceptedFormat(file_.name, acceptedFormats); + if (!formatCheck.isMatch) { + setError('Invalid file type.'); + return; + } + } - if (formsy) { - setValue?.(file); - } - }; + if (maxFileSize && file_.size > maxFileSize) { + setError(`File size must not exceed ${Math.round(maxFileSize / 1024).toLocaleString()} KB.`); + return; + } + if (emptyFileNotAllowed && file_.size === 0) { + setError('Empty file is not allowed.'); + return; + } + setFormValue(file_); + }, + [acceptedFormats, emptyFileNotAllowed, maxFileSize, setFormValue] + ); - onChange = (event: React.FormEvent): void => { - cancelEvent(event); - this.processFiles(this.fileInput.current.files); - }; + const onInputChange = useCallback>( + event => { + cancelEvent(event); + processFiles(fileInput.current.files); + }, + [processFiles] + ); - onDrag = (event: React.DragEvent): void => { + const onDrag = useCallback>(event => { cancelEvent(event); + setIsHover(true); + }, []); - if (!this.state.isHover) { - this.setState({ isHover: true }); - } - }; - - onDragLeave = (event: React.DragEvent): void => { + const onDragLeave = useCallback>(event => { cancelEvent(event); + setIsHover(false); + }, []); - if (this.state.isHover) { - this.setState({ isHover: false }); - } - }; - - onDrop = (event: React.DragEvent): void => { - cancelEvent(event); + const onDrop = useCallback>( + event => { + cancelEvent(event); - if (event.dataTransfer && event.dataTransfer.files) { - this.processFiles(event.dataTransfer.files, event.dataTransfer.items); - this.setState({ isHover: false }); - } - }; + if (event.dataTransfer && event.dataTransfer.files) { + processFiles(event.dataTransfer.files, event.dataTransfer.items); + setIsHover(false); + } + }, + [processFiles] + ); - onRemove = (): void => { + const onRemove = useCallback((): void => { // A value of null is supported by server APIs to clear/remove a file field's value. - this.setFormValue(null); - }; + setFormValue(null); + }, [setFormValue]); - render() { - const { + const labelOverlayProps = useMemo(() => { + if (hasCustomFieldLabel) return undefined; + return { addLabelAsterisk, - allowDisable, - elementWrapperClassName, - labelClassName, - queryColumn, - renderFieldLabel, - showLabel, - toggleDisabledTooltip, - hasMixedValue, - } = this.props; - const { data, error, file, isDisabled, isHover } = this.state; - - let body: ReactNode; - - if (file || typeof data === 'string') { - body = ( -

- - - {file ? file.name : (data as string)} -
{error}
-
- ); - } else if (Map.isMap(data) && data.get('value')) { - body = ( - - ); - } else { - body = ( - <> - - - {/* We render a label here, so click and drag events propagate to the input above */} -