From d02013c174de2432cbc8387e227dfaa3cd092abe Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 31 Jul 2026 10:25:03 -0700 Subject: [PATCH 01/12] FileInput: configure required state --- .../components/forms/QueryFormInputs.tsx | 2 +- .../components/forms/input/FileInput.tsx | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/components/src/internal/components/forms/QueryFormInputs.tsx b/packages/components/src/internal/components/forms/QueryFormInputs.tsx index c1af3abb05..d1f65c6d7c 100644 --- a/packages/components/src/internal/components/forms/QueryFormInputs.tsx +++ b/packages/components/src/internal/components/forms/QueryFormInputs.tsx @@ -200,7 +200,7 @@ export class QueryFormInputs extends React.Component | string | undefined; @@ -269,20 +270,28 @@ class FileInputImpl extends DisableableInput { ); } - const labelOverlayProps = { + const labelOverlayProps: LabelOverlayProps = { addLabelAsterisk, dataKey: this.getInputName(), + inputId: this.inputId, // While this component supports binding Formsy, it does not use a Formsy component // to render the associated label. As such, the label overlay is always configured as isFormsy={false}. isFormsy: false, labelClass: labelClassName, + required: queryColumn?.required, }; + const hasCustomFieldLabel = !!renderFieldLabel; + return (
- {renderFieldLabel ? ( - renderFieldLabel(queryColumn) - ) : ( + {hasCustomFieldLabel && ( + + )} + {!hasCustomFieldLabel && ( = props => { } return ; }; - FileInput.displayName = 'FileInput'; From 78d172b08f371dfd39ffcb2c498eadd41507b49f Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 31 Jul 2026 11:49:05 -0700 Subject: [PATCH 02/12] GitHub Issue 1387: participate in Formsy validation --- .../components/forms/input/FileInput.test.tsx | 95 ++++++++++++++++++- .../components/forms/input/FileInput.tsx | 40 +++++--- 2 files changed, 121 insertions(+), 14 deletions(-) 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..50a3f677cb 100644 --- a/packages/components/src/internal/components/forms/input/FileInput.test.tsx +++ b/packages/components/src/internal/components/forms/input/FileInput.test.tsx @@ -2,9 +2,51 @@ * 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 { 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' }) + ); +} describe('FileInput', () => { test('initializeValue', () => { @@ -26,4 +68,55 @@ describe('FileInput', () => { formValue: '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(); + }); + }); }); diff --git a/packages/components/src/internal/components/forms/input/FileInput.tsx b/packages/components/src/internal/components/forms/input/FileInput.tsx index e458389150..e7b868c2b3 100644 --- a/packages/components/src/internal/components/forms/input/FileInput.tsx +++ b/packages/components/src/internal/components/forms/input/FileInput.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, { FC, ReactNode, RefObject } from 'react'; +import React, { FC, ReactNode, RefObject, useMemo } from 'react'; import classNames from 'classnames'; import { Map } from 'immutable'; @@ -57,6 +57,7 @@ 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; } @@ -100,7 +101,7 @@ class FileInputImpl extends DisableableInput { isHover: false, }; - if (formValue) { + if (!props.formsy && formValue) { props.setValue?.(formValue); } } @@ -193,14 +194,16 @@ class FileInputImpl extends DisableableInput { addLabelAsterisk, allowDisable, elementWrapperClassName, + hasMixedValue, labelClassName, queryColumn, renderFieldLabel, + required, showLabel, toggleDisabledTooltip, - hasMixedValue, } = this.props; const { data, error, file, isDisabled, isHover } = this.state; + const name = this.getInputName(); let body: ReactNode; @@ -237,7 +240,7 @@ class FileInputImpl extends DisableableInput { disabled={isDisabled} id={this.inputId} multiple={false} - name={this.getInputName()} + name={name} onChange={this.onChange} ref={this.fileInput} type="file" @@ -272,13 +275,13 @@ class FileInputImpl extends DisableableInput { const labelOverlayProps: LabelOverlayProps = { addLabelAsterisk, - dataKey: this.getInputName(), + dataKey: name, inputId: this.inputId, // While this component supports binding Formsy, it does not use a Formsy component // to render the associated label. As such, the label overlay is always configured as isFormsy={false}. isFormsy: false, - labelClass: labelClassName, - required: queryColumn?.required, + labelClass: allowDisable ? undefined : labelClassName, + required, }; const hasCustomFieldLabel = !!renderFieldLabel; @@ -286,14 +289,15 @@ class FileInputImpl extends DisableableInput { return (
{hasCustomFieldLabel && ( - + {required && *} + )} {!hasCustomFieldLabel && ( { const FileInputFormsy = withFormsy(FileInputImpl); export const FileInput: FC = props => { - const { formsy = false } = props; + const { formsy = false, initialValue, queryColumn, required = queryColumn?.required ?? false } = props; + + // GitHub Issue 1387: The Formsy value for a file field is either the path of the currently attached file or the + // File itself (once one has been selected). Seed the wrapper with the initial path so the field participates in + // validation from the outset without being marked as dirty. + const value = useMemo(() => { + if (!formsy) return undefined; + return initializeValue(initialValue).formValue; + }, [formsy, initialValue]); + if (formsy) { - return ; + return ; } - return ; + + return ; }; FileInput.displayName = 'FileInput'; From 0a57b9c8cf032944f2d81e3acdb80734e03204b6 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 31 Jul 2026 12:37:40 -0700 Subject: [PATCH 03/12] useDisableableInput --- .../forms/input/DisableableInput.test.tsx | 113 ++++++++++++++++++ .../forms/input/DisableableInput.tsx | 77 +++++++++++- 2 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 packages/components/src/internal/components/forms/input/DisableableInput.test.tsx diff --git a/packages/components/src/internal/components/forms/input/DisableableInput.test.tsx b/packages/components/src/internal/components/forms/input/DisableableInput.test.tsx new file mode 100644 index 0000000000..aba0aa9cf9 --- /dev/null +++ b/packages/components/src/internal/components/forms/input/DisableableInput.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright (c) 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 { act, renderHook } from '@testing-library/react'; + +import { useDisableableInput } from './DisableableInput'; + +describe('DisableableInput', () => { + 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..5f19601ec8 100644 --- a/packages/components/src/internal/components/forms/input/DisableableInput.tsx +++ b/packages/components/src/internal/components/forms/input/DisableableInput.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 from 'react'; +import React, { useCallback, useState } from 'react'; export interface DisableableInputProps { allowDisable?: boolean; @@ -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, + }; +} From 6358e4014e3cfe0b047c162923404a7063dc3068 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 31 Jul 2026 12:38:22 -0700 Subject: [PATCH 04/12] Convert FieldLabel to FC --- .../components/forms/FieldLabel.test.tsx | 91 ++++++++++--- .../internal/components/forms/FieldLabel.tsx | 124 ++++++++---------- 2 files changed, 132 insertions(+), 83 deletions(-) diff --git a/packages/components/src/internal/components/forms/FieldLabel.test.tsx b/packages/components/src/internal/components/forms/FieldLabel.test.tsx index f3d2b24b80..1e4cd84cdc 100644 --- a/packages/components/src/internal/components/forms/FieldLabel.test.tsx +++ b/packages/components/src/internal/components/forms/FieldLabel.test.tsx @@ -9,6 +9,8 @@ import { QueryColumn } from '../../../public/QueryColumn'; import { Formsy } from './formsy'; import { FieldLabel } from './FieldLabel'; +import { LabelOverlayProps } from './LabelOverlay'; +import { INPUT_LABEL_CLASS_NAME_WITH_TOGGLE } from './constants'; const queryColumn = new QueryColumn({ name: 'testColumn', @@ -21,20 +23,20 @@ describe('FieldLabel', () => { }); 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'; From 6e2e210a005b4f68efe45a38956e5205079f6edd Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 31 Jul 2026 12:41:28 -0700 Subject: [PATCH 05/12] Convert FileInput to FC - First usage of useDisableableInput --- .../components/forms/input/FileInput.test.tsx | 129 +++++ .../components/forms/input/FileInput.tsx | 441 +++++++++--------- 2 files changed, 344 insertions(+), 226 deletions(-) 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 50a3f677cb..2ad258cb40 100644 --- a/packages/components/src/internal/components/forms/input/FileInput.test.tsx +++ b/packages/components/src/internal/components/forms/input/FileInput.test.tsx @@ -9,6 +9,7 @@ import { Map } from 'immutable'; 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'; @@ -48,6 +49,37 @@ function selectFile(): Promise { ); } +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 by mutating the labelOverlayProps it is given +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 }); @@ -119,4 +151,101 @@ describe('FileInput', () => { expect(onInvalid).toHaveBeenCalled(); }); }); + + 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(); + + // Disabling the field reverts local edits for editable inputs, but a selected file is retained + 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 e7b868c2b3..bae36ccc8d 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, useMemo } 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,7 +29,7 @@ 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'; @@ -64,259 +73,239 @@ export interface FileInputProps extends DisableableInputProps { type FileInputImplProps = FileInputProps & FormsyInjectedProps; -interface State extends DisableableInputState { - data: FileInputData; - error: string; - file: File; - isHover: boolean; -} - -class FileInputImpl extends DisableableInput { - fileInput: RefObject; - inputId: string; - - static defaultProps = { - ...DisableableInput.defaultProps, - ...{ - changeDebounceInterval: 0, - elementWrapperClassName: INPUT_WRAPPER_CLASS_NAME, - showLabel: true, +const FileInputImpl: FC = props => { + const { + acceptedFormats, + addLabelAsterisk, + allowDisable = false, + elementWrapperClassName = INPUT_WRAPPER_CLASS_NAME, + emptyFileNotAllowed, + formsy, + hasMixedValue, + initialValue, + labelClassName, + maxFileSize, + name, + onChange, + queryColumn, + renderFieldLabel, + required, + setValue, + showLabel = true, + toggleDisabledTooltip, + } = props; + const { isDisabled, toggleDisabled } = useDisableableInput(props); + const [data, setData] = useState(() => initializeValue(initialValue).data); + const [error, setError] = useState(''); + const [file, setFile] = useState(null); + const [isHover, setIsHover] = useState(false); + const fileInput = useRef(null); + + // 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 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 (!props.formsy && formValue) { - props.setValue?.(formValue); - } - } - - 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; - } + [formsy, inputName, onChange, setValue] + ); - 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 }); - } - }; + const onDrop = useCallback>( + event => { + cancelEvent(event); - onDrop = (event: React.DragEvent): void => { - 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, - hasMixedValue, - labelClassName, - queryColumn, - renderFieldLabel, - required, - showLabel, - toggleDisabledTooltip, - } = this.props; - const { data, error, file, isDisabled, isHover } = this.state; - const name = this.getInputName(); - - 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 */} -