From 3c99147cc5090fe989ac78e20e58bb4f42d18ec1 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 14 Jul 2026 14:17:59 -0700 Subject: [PATCH 01/37] registerModalRenderer --- packages/components/src/index.ts | 3 ++ .../src/internal/ModalRenderFactory.ts | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 packages/components/src/internal/ModalRenderFactory.ts diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index bd738fbf34..df0032d2e9 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -876,6 +876,7 @@ import { import { PRIVATE_PICKLIST_CATEGORY, PUBLIC_PICKLIST_CATEGORY } from './internal/components/picklist/constants'; import { getDefaultAPIWrapper, getTestAPIWrapper } from './internal/APIWrapper'; import { FormButtons } from './internal/FormButtons'; +import { registerModalRenderer } from './internal/ModalRenderFactory'; import { ModalButtons } from './internal/ModalButtons'; import { getSecurityTestAPIWrapper } from './internal/components/security/APIWrapper'; import { getFolderTestAPIWrapper } from './internal/components/container/FolderAPIWrapper'; @@ -1625,6 +1626,7 @@ export { registerDefaultURLMappers, registerFilterType, registerInputRenderer, + registerModalRenderer, ReleaseNote, removeColumn, removeColumns, @@ -1910,6 +1912,7 @@ export type { BSStyle } from './internal/dropdowns'; export type { MenuSectionItem } from './internal/DropdownSection'; export type { UseTimeout } from './internal/hooks'; export type { ModalProps } from './internal/Modal'; +export type { ModalRendererProps } from './internal/ModalRenderFactory'; export type { TriggerType } from './internal/OverlayTrigger'; export type { ISelectRowsResult } from './internal/query/api'; export type { diff --git a/packages/components/src/internal/ModalRenderFactory.ts b/packages/components/src/internal/ModalRenderFactory.ts new file mode 100644 index 0000000000..d5fb8c29cc --- /dev/null +++ b/packages/components/src/internal/ModalRenderFactory.ts @@ -0,0 +1,40 @@ +/* + * 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 { ComponentType } from 'react'; +import { SchemaQuery } from '../public/SchemaQuery'; + +export interface ModalRendererProps { + schemaQuery: SchemaQuery; +} + +export type ModalRendererIdentifier = SchemaQuery | string; +export type ModalRendererComponent = ComponentType; + +const modalRenderers: Record = {}; + +export enum ModalRenderContext { + AddEntities = 'AddEntities', +} + +function getKey(identifier: ModalRendererIdentifier, modalRenderContext: ModalRenderContext): string { + const id_ = identifierToString(identifier); + return [id_.toLowerCase(), modalRenderContext].join('|'); +} + +function identifierToString(identifier: ModalRendererIdentifier): string { + return identifier instanceof SchemaQuery ? identifier.toString() : identifier; +} + +export function registerModalRenderer( + identifier: ModalRendererIdentifier, + renderer: ModalRendererComponent, + modalRenderContext = ModalRenderContext.AddEntities +): void { + modalRenderers[getKey(identifier, modalRenderContext)] = renderer; +} + +export function resolveModalRenderer(identifier: ModalRendererIdentifier): ModalRendererComponent { + return modalRenderers[identifierToString(identifier)]; +} From f2841fcc1145c6d5b6d8057c9c0351ff72f2bdf3 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 14 Jul 2026 15:42:00 -0700 Subject: [PATCH 02/37] SelectInput: menuFooter prop --- .../components/forms/input/SelectInput.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/components/src/internal/components/forms/input/SelectInput.tsx b/packages/components/src/internal/components/forms/input/SelectInput.tsx index f7ccbf47c6..5d4f2dcd64 100644 --- a/packages/components/src/internal/components/forms/input/SelectInput.tsx +++ b/packages/components/src/internal/components/forms/input/SelectInput.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, FC, FocusEvent, KeyboardEvent, ReactNode } from 'react'; +import React, { Component, ComponentType, CSSProperties, FC, FocusEvent, KeyboardEvent, ReactNode } from 'react'; import ReactSelect, { components } from 'react-select'; import AsyncSelect from 'react-select/async'; import AsyncCreatableSelect from 'react-select/async-creatable'; @@ -231,6 +231,7 @@ export interface SelectInputProps { labelClass?: string; labelKey?: string; loadOptions?: (input: string) => Promise; + menuFooter?: ReactNode; menuPlacement?: string; menuPosition?: string; multiple?: boolean; @@ -570,14 +571,14 @@ export class SelectInputImpl extends Component { getOptionValue = (option: SelectInputOption): any => option[this.props.valueKey]; Input = inputProps => { - // React-select in an async configuration has a bug where when a defaultInputValue prop is supplied it + // React-select in an async configuration has a bug where when a defaultInputValue prop is supplied, it // does not fire an onChange event on the underlying input which results in loadOptions() never being called // with the supplied value. Here we simulate an onChange() event ourselves to induce the expected loading logic. // See https://github.com/JedWatson/react-select/issues/3047 if (!this._defaultValueLoaded) { this._defaultValueLoaded = true; if (this.props.defaultInputValue && this.isAsync()) { - // To avoid performing updates during the render cycle utilize a setTimeout() to defer execution. + // To avoid performing updates during the render cycle, utilize a setTimeout() to defer execution. // Normally, this could be done in componentDidMount(), however, we need access to the inputProps. setTimeout(() => { const inputEl = document.getElementById(inputProps.id); @@ -595,10 +596,19 @@ export class SelectInputImpl extends Component { // Marking input as "required" is not natively supported by react-select post-v1. Here we can mark // the underlying input as required, however, this is not the value input but rather the user visible - // input so we manually check if a value is set. + // input, so we manually check if a value is set. return ; }; + MenuList = menuListProps => { + return ( + <> + {menuListProps.children} + {this.props.menuFooter} + + ); + }; + Option = optionProps => {this.props.optionRenderer(optionProps)}; noOptionsMessage = (): ReactNode => this.props.noResultsText; @@ -623,6 +633,7 @@ export class SelectInputImpl extends Component { isLoading, isValidNewOption, labelKey, + menuFooter, menuPlacement, menuPosition, multiple, @@ -654,6 +665,8 @@ export class SelectInputImpl extends Component { if (!showDropdownMenu) { components.Menu = nullComponent; + } else if (menuFooter) { + components.MenuList = this.MenuList; } if (optionRenderer) { From 89b4981a7dd8cc5a6689a42d26e193aacae3c3de Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 10:15:38 -0700 Subject: [PATCH 03/37] AmountUnitInput test updates --- .../forms/input/AmountUnitInput.test.tsx | 162 ++++++++---------- .../forms/input/AmountUnitInput.tsx | 9 +- 2 files changed, 78 insertions(+), 93 deletions(-) diff --git a/packages/components/src/internal/components/forms/input/AmountUnitInput.test.tsx b/packages/components/src/internal/components/forms/input/AmountUnitInput.test.tsx index 4c6c6e7678..e151e5b90d 100644 --- a/packages/components/src/internal/components/forms/input/AmountUnitInput.test.tsx +++ b/packages/components/src/internal/components/forms/input/AmountUnitInput.test.tsx @@ -4,67 +4,49 @@ */ import React from 'react'; import { render } from '@testing-library/react'; -import { AmountUnitInput } from './AmountUnitInput'; import { ExtendedMap } from '../../../../public/ExtendedMap'; import { QueryColumn } from '../../../../public/QueryColumn'; import { Formsy } from '../formsy/index'; +import { AmountUnitInput } from './AmountUnitInput'; +import { InputRendererProps } from './types'; describe('AmountUnitInput', () => { - const amountCol = { name: 'StoredAmount', caption: 'amount', fieldKey: 'amountKey' }; - const unitCol = { + const amountCol = new QueryColumn({ name: 'StoredAmount', caption: 'amount', fieldKey: 'amountKey' }); + const unitCol = new QueryColumn({ name: 'Units', caption: 'unit', fieldKey: 'unitKey', lookup: { hasQueryFilters: jest.fn(), - displayColumn: new QueryColumn({ caption: 'test' }), + displayColumn: 'test', }, - }; - const data = { StoredAmount: 12.5, Units: 'mg' }; - const allColumns = new ExtendedMap({ - [amountCol.fieldKey]: amountCol, - [unitCol.fieldKey]: unitCol, }); - const CAN_DISABLE: any = { - allowFieldDisable: true, - onSelectChange: jest.fn(), - onToggleDisable: jest.fn(), - initiallyDisabled: false, - containerFilter: undefined, - containerPath: undefined, - allColumns, - data, - queryFilters: {}, - }; - - const DISABLED: any = { - ...CAN_DISABLE, - initiallyDisabled: true, - }; - - const NOT_DISABLABLE: any = { - ...CAN_DISABLE, - allowFieldDisable: false, - }; + function defaultProps(): InputRendererProps { + return { + allColumns: new ExtendedMap({ + [amountCol.fieldKey]: amountCol, + [unitCol.fieldKey]: unitCol, + }), + allowFieldDisable: true, + col: undefined, + containerFilter: undefined, + containerPath: undefined, + data: { StoredAmount: 12.5, Units: 'mg' }, + initiallyDisabled: false, + onSelectChange: jest.fn(), + onToggleDisable: jest.fn(), + queryFilters: {}, + value: undefined, + }; + } test('returns null when required columns are missing', () => { // Missing unit column - const someColumns = new ExtendedMap({ [amountCol.fieldKey]: amountCol }); const { container } = render( - + ); @@ -75,102 +57,106 @@ describe('AmountUnitInput', () => { test('with amount and unit column, can disable', () => { render( - + ); expect(document.querySelectorAll('.form-group.row')).toHaveLength(1); expect(document.querySelectorAll('.control-label')).toHaveLength(1); - expect(document.querySelectorAll('.control-label')[0].textContent).toBe('Amount and Units'); + expect(document.querySelectorAll('.control-label')[0]).toHaveTextContent('Amount and Units'); expect(document.querySelectorAll('label')).toHaveLength(0); expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(1); expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(0); const inputs = document.querySelectorAll('input'); expect(inputs).toHaveLength(4); - expect(inputs[0].getAttribute('value')).toBe('true'); - expect(inputs[0].getAttribute('type')).toBe('hidden'); - expect(inputs[0].getAttribute('name')).toBe('StoredAmount::enabled'); - expect(inputs[1].getAttribute('value')).toBe('12.5'); - expect(inputs[1].getAttribute('name')).toBe('amountKey'); - expect(inputs[2].getAttribute('role')).toBe('combobox'); - expect(inputs[3].getAttribute('name')).toBe('Units::enabled'); - expect(inputs[3].getAttribute('value')).toBe('true'); - expect(inputs[3].getAttribute('type')).toBe('hidden'); - expect(inputs[1].getAttribute('placeholder')).toBe('Enter amount'); - expect(document.querySelector('.select-input__placeholder').textContent).toBe('Select or type to search...'); + expect(inputs[0]).toHaveAttribute('value', 'true'); + expect(inputs[0]).toHaveAttribute('type', 'hidden'); + expect(inputs[0]).toHaveAttribute('name', 'StoredAmount::enabled'); + expect(inputs[1]).toHaveAttribute('value', '12.5'); + expect(inputs[1]).toHaveAttribute('name', 'amountKey'); + expect(inputs[1]).toHaveAttribute('placeholder', 'Enter amount'); + expect(inputs[2]).toHaveAttribute('role', 'combobox'); + expect(inputs[3]).toHaveAttribute('name', 'Units::enabled'); + expect(inputs[3]).toHaveAttribute('value', 'true'); + expect(inputs[3]).toHaveAttribute('type', 'hidden'); + expect(document.querySelector('.select-input__placeholder')).toHaveTextContent('Select or type to search...'); }); test('with amount and unit column, can disable and disabled', () => { render( - + ); expect(document.querySelectorAll('.form-group.row')).toHaveLength(1); expect(document.querySelectorAll('.control-label')).toHaveLength(1); - expect(document.querySelectorAll('.control-label')[0].textContent).toBe('Amount and Units'); + expect(document.querySelectorAll('.control-label')[0]).toHaveTextContent('Amount and Units'); expect(document.querySelectorAll('label')).toHaveLength(0); expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(0); expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(1); const inputs = document.querySelectorAll('input'); expect(inputs).toHaveLength(4); - expect(inputs[0].getAttribute('value')).toBe('false'); - expect(inputs[0].getAttribute('type')).toBe('hidden'); - expect(inputs[0].getAttribute('name')).toBe('StoredAmount::enabled'); - expect(inputs[1].getAttribute('value')).toBe('12.5'); - expect(inputs[1].getAttribute('name')).toBe('amountKey'); - expect(inputs[1].getAttribute('placeholder')).toBe('Enter amount'); - expect(inputs[2].getAttribute('role')).toBe('combobox'); - expect(inputs[3].getAttribute('name')).toBe('Units::enabled'); - expect(inputs[3].getAttribute('value')).toBe('false'); - expect(inputs[3].getAttribute('type')).toBe('hidden'); - expect(document.querySelector('.select-input__placeholder').textContent).toBe('Select or type to search...'); + expect(inputs[0]).toHaveAttribute('value', 'false'); + expect(inputs[0]).toHaveAttribute('type', 'hidden'); + expect(inputs[0]).toHaveAttribute('name', 'StoredAmount::enabled'); + expect(inputs[1]).toHaveAttribute('value', '12.5'); + expect(inputs[1]).toHaveAttribute('name', 'amountKey'); + expect(inputs[1]).toHaveAttribute('placeholder', 'Enter amount'); + expect(inputs[2]).toHaveAttribute('role', 'combobox'); + expect(inputs[3]).toHaveAttribute('name', 'Units::enabled'); + expect(inputs[3]).toHaveAttribute('value', 'false'); + expect(inputs[3]).toHaveAttribute('type', 'hidden'); + expect(document.querySelector('.select-input__placeholder')).toHaveTextContent('Select or type to search...'); }); test('with amount and unit column, can disable and disabled, has mixed value', () => { render( - + ); expect(document.querySelectorAll('.form-group.row')).toHaveLength(1); expect(document.querySelectorAll('.control-label')).toHaveLength(1); - expect(document.querySelectorAll('.control-label')[0].textContent).toBe('Amount and Units'); + expect(document.querySelectorAll('.control-label')[0]).toHaveTextContent('Amount and Units'); expect(document.querySelectorAll('label')).toHaveLength(0); expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(0); expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(1); const inputs = document.querySelectorAll('input'); expect(inputs).toHaveLength(4); - expect(inputs[0].getAttribute('value')).toBe('false'); - expect(inputs[0].getAttribute('type')).toBe('hidden'); - expect(inputs[0].getAttribute('name')).toBe('StoredAmount::enabled'); - expect(inputs[1].getAttribute('value')).toBe('12.5'); - expect(inputs[1].getAttribute('name')).toBe('amountKey'); - expect(inputs[1].getAttribute('placeholder')).toBe('[Mixed]'); - expect(inputs[2].getAttribute('role')).toBe('combobox'); - expect(inputs[3].getAttribute('name')).toBe('Units::enabled'); - expect(inputs[3].getAttribute('value')).toBe('false'); - expect(inputs[3].getAttribute('type')).toBe('hidden'); - expect(document.querySelector('.select-input__placeholder').textContent).toBe('[Mixed]'); + expect(inputs[0]).toHaveAttribute('value', 'false'); + expect(inputs[0]).toHaveAttribute('type', 'hidden'); + expect(inputs[0]).toHaveAttribute('name', 'StoredAmount::enabled'); + expect(inputs[1]).toHaveAttribute('value', '12.5'); + expect(inputs[1]).toHaveAttribute('name', 'amountKey'); + expect(inputs[1]).toHaveAttribute('placeholder', '[Mixed]'); + expect(inputs[2]).toHaveAttribute('role', 'combobox'); + expect(inputs[3]).toHaveAttribute('name', 'Units::enabled'); + expect(inputs[3]).toHaveAttribute('value', 'false'); + expect(inputs[3]).toHaveAttribute('type', 'hidden'); + expect(document.querySelector('.select-input__placeholder')).toHaveTextContent('[Mixed]'); }); test('with amount and unit column, cannot disable', () => { render( - + ); expect(document.querySelectorAll('.form-group.row')).toHaveLength(1); expect(document.querySelectorAll('.control-label')).toHaveLength(1); - expect(document.querySelectorAll('.control-label')[0].textContent).toBe('Amount and Units'); + expect(document.querySelectorAll('.control-label')[0]).toHaveTextContent('Amount and Units'); expect(document.querySelectorAll('label')).toHaveLength(0); expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(0); expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(0); const inputs = document.querySelectorAll('input'); expect(inputs).toHaveLength(2); - expect(inputs[0].getAttribute('value')).toBe('12.5'); - expect(inputs[0].getAttribute('name')).toBe('amountKey'); - expect(inputs[1].getAttribute('role')).toBe('combobox'); - expect(inputs[0].getAttribute('placeholder')).toBe('Enter amount'); - expect(document.querySelector('.select-input__placeholder').textContent).toBe('Select or type to search...'); + expect(inputs[0]).toHaveAttribute('value', '12.5'); + expect(inputs[0]).toHaveAttribute('name', 'amountKey'); + expect(inputs[0]).toHaveAttribute('placeholder', 'Enter amount'); + expect(inputs[1]).toHaveAttribute('role', 'combobox'); + expect(document.querySelector('.select-input__placeholder')).toHaveTextContent('Select or type to search...'); }); }); diff --git a/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx index b6d4e2e36c..7ea8e33ef8 100644 --- a/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx +++ b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx @@ -52,7 +52,7 @@ export const AmountUnitInput: FC = memo(props => { onToggleDisable?.(newDisabled); return newDisabled; }); - }, [setDisabled]); + }, [onToggleDisable]); const onAmountChange = useCallback((name: string, value: any) => { const errorMsg = getInvalidSampleAmountMessage(value); @@ -99,14 +99,14 @@ export const AmountUnitInput: FC = memo(props => { hasMixedValue={hasMixedAmountValue} onChange={onAmountChange} queryColumn={amountCol} - rowClassName={'col-sm-5 col-xs-6'} + rowClassName="col-sm-5 col-xs-6" showLabel={false} type="number" validations="sampleAmount" value={amountValue ? String(amountValue) : amountValue} /> = memo(props => { hasMixedValue={hasMixedUnitValue} id={id} inputClass={''} - name={unitCol.fieldKey} maxRows={LOOKUP_DEFAULT_SIZE} + name={unitCol.fieldKey} onQSChange={onSelectChange} placeholder="Select or type to search..." queryFilters={queryFilter} @@ -141,5 +141,4 @@ export const AmountUnitInput: FC = memo(props => { ); }); - AmountUnitInput.displayName = 'AmountUnitInput'; From f33eb47c651e4dd1d5f092a5bc39f8a0d4f60d68 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 10:21:03 -0700 Subject: [PATCH 04/37] Formsy: do not propagate nested form events --- .../components/forms/formsy/Formsy.test.tsx | 27 +++++++++++++++++++ .../components/forms/formsy/Formsy.tsx | 23 ++++++++++------ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/components/src/internal/components/forms/formsy/Formsy.test.tsx b/packages/components/src/internal/components/forms/formsy/Formsy.test.tsx index 9d50a3ec9a..7ee494259e 100644 --- a/packages/components/src/internal/components/forms/formsy/Formsy.test.tsx +++ b/packages/components/src/internal/components/forms/formsy/Formsy.test.tsx @@ -2,6 +2,7 @@ // Credit: Christian Alfoni and the Formsy Authors // Repository: https://github.com/formsy/formsy-react/tree/0226fab133a25 import React, { act, FC, PropsWithChildren, memo, useCallback, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { createEvent, fireEvent, render } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; @@ -624,6 +625,32 @@ describe('Formsy', () => { expect(isCalled).toHaveBeenCalled(); }); + + it('should ignore submit events bubbled from a nested form rendered in a portal', () => { + const onOuterSubmit = jest.fn(); + const onInnerSubmit = jest.fn(); + + function TestForm() { + return ( + + + {createPortal( + + + , + document.body + )} + + ); + } + + const screen = render(); + + fireEvent.submit(screen.getByTestId('inner-form')); + + expect(onInnerSubmit).toHaveBeenCalled(); + expect(onOuterSubmit).not.toHaveBeenCalled(); + }); }); describe('value === false', () => { diff --git a/packages/components/src/internal/components/forms/formsy/Formsy.tsx b/packages/components/src/internal/components/forms/formsy/Formsy.tsx index e1ba6c0527..5372dedd05 100644 --- a/packages/components/src/internal/components/forms/formsy/Formsy.tsx +++ b/packages/components/src/internal/components/forms/formsy/Formsy.tsx @@ -8,13 +8,13 @@ import { debounce } from '../../../util/utils'; import { FormsyContext } from './FormsyContext'; import { FormsyContextInterface, + FormsyInjectedProps, IModel, InputComponent, IResetModel, IUpdateInputsWithError, IUpdateInputsWithValue, OnSubmitCallback, - FormsyInjectedProps, RunValidationResponse, Values, } from './types'; @@ -66,7 +66,7 @@ export class Formsy extends Component { validationErrors: {}, }; - inputs: Array>>; + inputs: InstanceType>[]; emptyArray: any[]; private _mounted = true; prevInputNames: any[] | null = null; @@ -116,7 +116,6 @@ export class Formsy extends Component { // Keep the disabled value in state/context the same as from props if (disabled !== prevProps.disabled) { if (!this._mounted) return; - // eslint-disable-next-line this.setState(state => ({ contextValue: { ...state.contextValue, isFormDisabled: disabled }, })); @@ -274,7 +273,11 @@ export class Formsy extends Component { this.setState({ isValid }); - isValid ? onValid() : onInvalid(); + if (isValid) { + onValid(); + } else { + onInvalid(); + } }; setInputValidationErrors = (errors): void => { @@ -296,6 +299,10 @@ export class Formsy extends Component { // Update model, submit to url prop and send the model submit = (event?: React.SyntheticEvent): void => { + // Ignore submit events bubbled from a nested form (e.g., a form rendered in a Modal, which propagates events + // through the React tree via its portal); this form's own submissions always have target === currentTarget. + if (event && event.target !== event.currentTarget) return; + const { onSubmit, onValidSubmit, onInvalidSubmit, preventDefaultSubmit } = this.props; const { isValid } = this.state; @@ -304,7 +311,7 @@ export class Formsy extends Component { } // Trigger form as not pristine. - // If any inputs have not been touched yet this will make them dirty + // If any inputs have not been touched yet, this will make them dirty, // so validation becomes visible (if based on isPristine) this.setFormPristine(false); const model = this.getModel(); @@ -323,8 +330,8 @@ export class Formsy extends Component { } }; - // Go through errors from server and grab the components - // stored in the inputs map. Change their state to invalid + // Go through errors from the server and grab the components + // stored in the input map. Change their state to invalid // and set the serverError message updateInputsWithError: IUpdateInputsWithError = (errors, invalidate): void => { if (!this._mounted) return; @@ -359,7 +366,7 @@ export class Formsy extends Component { }); }; - // Use the binded values and the actual input value to + // Use the bound values and the actual input value to // validate the input and set its state. Then check the // state of the form itself validate = (component: InputComponent): void => { From 5d8e9840a2038663e1b8966f3bbbab9c7c1dc40b Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:52:59 -0700 Subject: [PATCH 05/37] ExtendedMap: improve types --- .../components/src/public/ExtendedMap.test.ts | 8 ++-- packages/components/src/public/ExtendedMap.ts | 38 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/components/src/public/ExtendedMap.test.ts b/packages/components/src/public/ExtendedMap.test.ts index 5d00efcc56..944281a498 100644 --- a/packages/components/src/public/ExtendedMap.test.ts +++ b/packages/components/src/public/ExtendedMap.test.ts @@ -3,7 +3,7 @@ * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ import { OrderedMap } from 'immutable'; -import { ExtendedMap } from './ExtendedMap'; +import { ExtendedMap, KeyType } from './ExtendedMap'; const KEYS_ONE = ['one', 'two', 'three']; const KEYS_TWO = ['four', 'five', 'six']; @@ -28,14 +28,14 @@ ORDERED_TWO.set('five', 5); ORDERED_TWO.set('six', 6); describe('ExtendedMap', () => { - function expectOrder(map: ExtendedMap, keys: any[], values: any[]) { + function expectOrder(map: ExtendedMap, keys: K[], values: V[]) { expect(Array.from(map.keys())).toStrictEqual(keys); expect(Array.from(map.values())).toStrictEqual(values); expect(map.keyArray).toStrictEqual(keys); expect(map.valueArray).toStrictEqual(values); } - function expectValues(map, keys: any[], values: any[]) { + function expectValues(map: ExtendedMap, keys: K[], values: V[]) { let idx = 0; for (const key of keys) { expect(map.get(key)).toEqual(values[idx]); @@ -44,7 +44,7 @@ describe('ExtendedMap', () => { } test('Constructor - empty args', () => { - const em = new ExtendedMap(); + const em = new ExtendedMap(); expectOrder(em, [], []); em.set('one', 1); em.set('two', 2); diff --git a/packages/components/src/public/ExtendedMap.ts b/packages/components/src/public/ExtendedMap.ts index b04b72468a..03ee4fb8f7 100644 --- a/packages/components/src/public/ExtendedMap.ts +++ b/packages/components/src/public/ExtendedMap.ts @@ -2,20 +2,20 @@ * Copyright (c) 2023-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. */ -type KeyType = string | number | symbol; -type MapType = Record | Map; +export type KeyType = number | string | symbol; +type MapType = Map | Record; type Mapper = (value: V, key: K, original: ExtendedMap) => T; type ArrayMapper = (value: V, index: number, array: V[]) => T; type FilterFn = (value: V, key: K, original: ExtendedMap) => boolean; type Reducer = (result: T, value: V, key: K, original: ExtendedMap) => T; /** - * ExtendedMap is an extended version of the built in Map class. It has an improved constructor (that takes Records, + * ExtendedMap is an extended version of the built-in Map class. It has an improved constructor (that takes Records, * Map, or ExtendedMap objects), as well as several convenience methods for mapping, reducing, and filtering the map or - * values. This class is an Ordered Map, because it extends the Map class which is ordered. + * values. This class is an ordered map because it extends the Map class which is ordered. */ -export class ExtendedMap extends Map { - constructor(...data: Array | Map>) { +export class ExtendedMap extends Map { + constructor(...data: (Map | Record)[]) { super(); for (const dataObject of data) { @@ -23,12 +23,12 @@ export class ExtendedMap extends Map { if (dataObject instanceof Map) { for (const [key, value] of dataObject) { - this.set(key, value); + this.set(key as K, value); } } else { // Assume Record type for (const key of Object.keys(dataObject)) { - this.set(key, dataObject[key]); + this.set(key as K, dataObject[key]); } } } @@ -36,7 +36,7 @@ export class ExtendedMap extends Map { /** * Use this when you want to map or reduce over the values of the Map, or otherwise need an array. If you just need - * to iterate through the values you should be able to use the values() method. + * to iterate through the values, you should be able to use the values() method. */ get valueArray(): V[] { return Array.from(this.values()); @@ -44,7 +44,7 @@ export class ExtendedMap extends Map { /** * Use this when you want to map or reduce over the keys of the Map, or otherwise need an array. If you just need - * to iterate through the keys you should be able to use the keys() method. + * to iterate through the keys, you should be able to use the keys() method. */ get keyArray(): KeyType[] { return Array.from(this.keys()); @@ -67,7 +67,7 @@ export class ExtendedMap extends Map { /** * Iterates over the ExtendedMap, calling the provided Reducer function for each key/value. Allows you to completely - * transform the ExtendedMap object into something else (e.g. a string, a filtered version of the map). + * transform the ExtendedMap object into something else (e.g., a string, a filtered version of the map). * @param reducer * @param initialReduction */ @@ -82,8 +82,8 @@ export class ExtendedMap extends Map { } /** - * Creates a new ExtendedMap based on the filter function a passed in. Iterates through all of the values of the map - * and calls the filter function with the key, value, and whole map. If the filter function returns true we include + * Creates a new ExtendedMap based on the filter function a passed in. Iterates through all the values of the map + * and calls the filter function with the key, value, and whole map. If the filter function returns true, we include * the key/value pair in the new map. * @param filterFn */ @@ -113,18 +113,18 @@ export class ExtendedMap extends Map { * Creates a new ExtendedMap based on this map and the map passed in as an argument. * @param otherMap */ - merge(otherMap: MapType | ExtendedMap): ExtendedMap { + merge(otherMap: ExtendedMap | MapType): ExtendedMap { return new ExtendedMap(this, otherMap); } /** - * Inserts the contents of a map at the designated index. If the given index is out of range of the existing map we - * return a copy of the current map. If the given index is equal to the current size of the map we append the + * Inserts the contents of a map at the designated index. If the given index is out of range of the existing map, we + * return a copy of the current map. If the given index is equal to the current size of the map, we append the * incoming otherMap. - * @param index: the index where to insert the otherMap - * @param otherMap: the otherMap to insert + * @param index the index where to insert the otherMap + * @param otherMap the otherMap to insert */ - mergeAt(index: number, otherMap: MapType | ExtendedMap): ExtendedMap { + mergeAt(index: number, otherMap: ExtendedMap | MapType): ExtendedMap { // Invalid, return a copy of this map if (index < 0 || index > this.size) return new ExtendedMap(this); From 8acac015f1a444c3fb94b9861131ee87ef4ed9ce Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:53:19 -0700 Subject: [PATCH 06/37] SchemaQueryKey --- packages/components/src/internal/query/api.ts | 4 +-- packages/components/src/public/SchemaQuery.ts | 34 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/components/src/internal/query/api.ts b/packages/components/src/internal/query/api.ts index eb4a9719e7..8ffe6c7bde 100644 --- a/packages/components/src/internal/query/api.ts +++ b/packages/components/src/internal/query/api.ts @@ -10,7 +10,7 @@ import { ActionURL, Ajax, AuditBehaviorTypes, Filter, Query, QueryDOM, Utils } f import { ExtendedMap } from '../../public/ExtendedMap'; import { getQueryMetadata } from '../global'; -import { resolveKeyFromJson, SchemaQuery } from '../../public/SchemaQuery'; +import { resolveKeyFromJson, SchemaQuery, SchemaQueryKey } from '../../public/SchemaQuery'; import { isAllProductFoldersFilteringEnabled, isProductFoldersDataListingScopedToFolder, @@ -449,7 +449,7 @@ export function isSelectRowMetadataRequired(includeMetadata?: boolean, columns?: } export interface ISelectRowsResult { - key: string; + key: SchemaQueryKey; messages?: List>; models: any; orderedModels: List; diff --git a/packages/components/src/public/SchemaQuery.ts b/packages/components/src/public/SchemaQuery.ts index 4cbf503641..2a6496a4c8 100644 --- a/packages/components/src/public/SchemaQuery.ts +++ b/packages/components/src/public/SchemaQuery.ts @@ -16,7 +16,7 @@ function stripSelectionSnapshotId(value: string): string { return value; } -// 36009: Case-insensitive variant of QueryKey.decodePart +// Issue 36009: Case-insensitive variant of QueryKey.decodePart export function decodePart(s: string): string { if (!s) return s; @@ -30,7 +30,7 @@ export function decodePart(s: string): string { .replace(/\$D/gi, '$'); } -// 36009: Case-insensitive variant of QueryKey.encodePart +// Issue 36009: Case-insensitive variant of QueryKey.encodePart export function encodePart(s: string): string { if (!s) return s; @@ -44,7 +44,9 @@ export function encodePart(s: string): string { .replace(/\./gi, '$P'); } -export function resolveKey(schema: string, query: string, viewName?: string): string { +export type SchemaQueryKey = string & { __schemaQueryKey: never }; + +export function resolveKey(schema: string, query: string, viewName?: string): SchemaQueryKey { /* It's questionable if we really need to encodePart schema here and the suspicion is that this would result in double encoding. Since schema is not recognisable by api when not encoded, it would be reasonable to assume the @@ -53,23 +55,19 @@ export function resolveKey(schema: string, query: string, viewName?: string): st */ const parts = [encodePart(schema), encodePart(query)]; if (viewName) parts.push(encodePart(viewName)); - return parts.join('/').toLowerCase(); + return parts.join('/').toLowerCase() as SchemaQueryKey; } -export function resolveKeyFromJson(json: { queryName: string; schemaName: string[]; viewName?: string }): string { +export function resolveKeyFromJson(json: { + queryName: string; + schemaName: string[]; + viewName?: string; +}): SchemaQueryKey { // if schema parts contain '.', replace with $P, to distinguish from '.' used to separate schema parts // similarly, encode '/' in schema parts, to distinguish from '/' used to separate schema and query parts // schemaName ['assay', 'general', 'a.b/c'] will be will processed to 'assay.general.a$pb$sc' // resolveKey will then further encode schema to assay$pgeneral$pa$dpb$sc - return resolveKey( - json.schemaName - .map(schemaPart => { - return encodePart(schemaPart); - }) - .join('.'), - json.queryName, - json.viewName - ); + return resolveKey(json.schemaName.map(encodePart).join('.'), json.queryName, json.viewName); } export interface IParsedSelectionKey { @@ -107,15 +105,19 @@ export class SchemaQuery { return !!schemaName && equalsIgnoreCase(this.schemaName, schemaName); } - getKey(includeViewName = true): string { + getKey(includeViewName = true): SchemaQueryKey { return resolveKey(this.schemaName, this.queryName, includeViewName ? this.viewName : undefined); } + static fromKey(encodedKey: SchemaQueryKey): SchemaQuery { + return getSchemaQuery(encodedKey); + } + static parseSelectionKey(selectionKey: string): IParsedSelectionKey { selectionKey = stripSelectionSnapshotId(selectionKey); const parts = selectionKey.split('|'); // first part will be app page model key, which we skip - const schemaQueryKey = parts[1]; + const schemaQueryKey = parts[1] as SchemaQueryKey; // there may be a view name between the schemaQueryKey and the provided entity keys const keys = parts.length > 2 ? parts[parts.length - 1] : undefined; From 57b17b725396344ef80f98e9a8aebb71d5293e63 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:53:50 -0700 Subject: [PATCH 07/37] EditableGrid: disable allowAddEntities --- packages/components/src/internal/components/editable/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/src/internal/components/editable/utils.ts b/packages/components/src/internal/components/editable/utils.ts index 6ca5bf471f..3040a8d1f2 100644 --- a/packages/components/src/internal/components/editable/utils.ts +++ b/packages/components/src/internal/components/editable/utils.ts @@ -313,6 +313,7 @@ export const gridCellSelectInputProps: Partial = { export const gridCellQuerySelectProps: Partial = { ...gridCellSelectInputProps, + allowAddEntities: false, notFoundValuesEnabled: false, showLoading: false, }; From 011a99bc4423039599e218aaedcb5da00279a9f1 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:55:57 -0700 Subject: [PATCH 08/37] AddEntitiesModal --- .../src/internal/ModalRenderFactory.ts | 20 +++++-- .../components/forms/AddEntitiesModal.tsx | 56 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 packages/components/src/internal/components/forms/AddEntitiesModal.tsx diff --git a/packages/components/src/internal/ModalRenderFactory.ts b/packages/components/src/internal/ModalRenderFactory.ts index d5fb8c29cc..4652a6932e 100644 --- a/packages/components/src/internal/ModalRenderFactory.ts +++ b/packages/components/src/internal/ModalRenderFactory.ts @@ -3,9 +3,18 @@ * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ import { ComponentType } from 'react'; -import { SchemaQuery } from '../public/SchemaQuery'; +import { Query } from '@labkey/api'; +import { SchemaQuery, SchemaQueryKey } from '../public/SchemaQuery'; +import { ExtendedMap } from '../public/ExtendedMap'; +import { SelectRowsResponse } from './query/selectRows'; + +export type AddEntitiesComplete = (results: ExtendedMap) => void; export interface ModalRendererProps { + containerFilter: Query.ContainerFilter; + containerPath: string; + onCancel: () => void; + onComplete: AddEntitiesComplete; schemaQuery: SchemaQuery; } @@ -24,7 +33,7 @@ function getKey(identifier: ModalRendererIdentifier, modalRenderContext: ModalRe } function identifierToString(identifier: ModalRendererIdentifier): string { - return identifier instanceof SchemaQuery ? identifier.toString() : identifier; + return identifier instanceof SchemaQuery ? identifier.toString(false) : identifier; } export function registerModalRenderer( @@ -35,6 +44,9 @@ export function registerModalRenderer( modalRenderers[getKey(identifier, modalRenderContext)] = renderer; } -export function resolveModalRenderer(identifier: ModalRendererIdentifier): ModalRendererComponent { - return modalRenderers[identifierToString(identifier)]; +export function resolveModalRenderer( + identifier: SchemaQuery, + modalRenderContext = ModalRenderContext.AddEntities +): ModalRendererComponent { + return modalRenderers[getKey(identifier, modalRenderContext)]; } diff --git a/packages/components/src/internal/components/forms/AddEntitiesModal.tsx b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx new file mode 100644 index 0000000000..f94afb6826 --- /dev/null +++ b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx @@ -0,0 +1,56 @@ +/* + * 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 React, { createContext, FC, useContext, useMemo } from 'react'; +import { SchemaQuery } from '../../../public/SchemaQuery'; +import { Modal } from '../../Modal'; +import { ModalRendererProps, resolveModalRenderer } from '../../ModalRenderFactory'; + +const AddEntitiesModalContext = createContext(true); + +export function useIsAddEntitiesEnabled(schemaQuery: SchemaQuery): boolean { + const addEntitiesEnabled = useContext(AddEntitiesModalContext); + return useMemo( + // If the context it not available/rendered, then default to true + () => + schemaQuery !== undefined && + (addEntitiesEnabled ?? true) && + resolveModalRenderer(schemaQuery) !== undefined, + [addEntitiesEnabled, schemaQuery] + ); +} + +interface AddEntitiesMenuFooterProps { + openModal: () => void; +} + +export const AddEntitiesMenuFooter: FC = ({ openModal }) => { + return
Add New
; +}; +AddEntitiesMenuFooter.displayName = 'AddEntitiesMenuFooter'; + +export const AddEntitiesModal: FC = props => { + const { containerFilter, containerPath, onCancel, onComplete, schemaQuery } = props; + const ModalRenderer = useMemo(() => resolveModalRenderer(schemaQuery), [schemaQuery]); + + if (!ModalRenderer) { + return ( + + Add entities modal not registered for {schemaQuery.schemaName}.{schemaQuery.queryName} + + ); + } + + return ( + // eslint-disable-next-line react-hooks/static-components + + ); +}; +AddEntitiesModal.displayName = 'AddEntitiesModal'; From 38e692ef626db0a3e70af96d02fcb976cee776a6 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:56:02 -0700 Subject: [PATCH 09/37] Export types --- packages/components/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index df0032d2e9..e81c4ab1ed 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1912,7 +1912,7 @@ export type { BSStyle } from './internal/dropdowns'; export type { MenuSectionItem } from './internal/DropdownSection'; export type { UseTimeout } from './internal/hooks'; export type { ModalProps } from './internal/Modal'; -export type { ModalRendererProps } from './internal/ModalRenderFactory'; +export type { AddEntitiesComplete, ModalRendererProps } from './internal/ModalRenderFactory'; export type { TriggerType } from './internal/OverlayTrigger'; export type { ISelectRowsResult } from './internal/query/api'; export type { @@ -1955,3 +1955,4 @@ export type { QueryModelMap, RequiresModelAndActions, } from './public/QueryModel/withQueryModels'; +export type { SchemaQueryKey } from './public/SchemaQuery'; From 7ba0753deb0eb8c4bee169661333363cba5481fd Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:57:12 -0700 Subject: [PATCH 10/37] QuerySelect: introduce AddEntitiesModal --- .../internal/components/forms/QuerySelect.tsx | 108 ++++++++++++------ 1 file changed, 71 insertions(+), 37 deletions(-) diff --git a/packages/components/src/internal/components/forms/QuerySelect.tsx b/packages/components/src/internal/components/forms/QuerySelect.tsx index 7077547e5a..49c9dca2da 100644 --- a/packages/components/src/internal/components/forms/QuerySelect.tsx +++ b/packages/components/src/internal/components/forms/QuerySelect.tsx @@ -14,12 +14,12 @@ import { Row } from '../../query/selectRows'; import { QueryInfo } from '../../../public/QueryInfo'; -import { isTestEnv } from '../../util/utils'; +import { caseInsensitive, isTestEnv } from '../../util/utils'; -import { useTimeout } from '../../hooks'; +import { useModalState, useTimeout } from '../../hooks'; import { SelectInput, SelectInputChange, SelectInputOption, SelectInputProps } from './input/SelectInput'; -import { resolveDetailFieldLabel } from './utils'; +import { resolveDetailFieldLabel, resolveDetailFieldValue } from './utils'; import { fetchSearchResults, formatResults, @@ -31,6 +31,8 @@ import { setSelection, } from './model'; import { DELIMITER } from './constants'; +import { AddEntitiesMenuFooter, AddEntitiesModal, useIsAddEntitiesEnabled } from './AddEntitiesModal'; +import { AddEntitiesComplete } from '../../ModalRenderFactory'; function getValue(model: QuerySelectModel, multiple: boolean): any { const { rawSelectedValue } = model; @@ -97,10 +99,8 @@ const OptionRenderer: FC = props => { if (item !== undefined) { let text = resolveDetailFieldLabel(item.get(column.name)); if (!Utils.isString(text)) { - if (text == null) - text = ''; - else if (Array.isArray(text)) - text = text.join(', '); + if (text == null) text = ''; + else if (Array.isArray(text)) text = text.join(', '); } return ( @@ -148,8 +148,8 @@ type InheritedSelectInputProps = Omit< | 'allowCreate' | 'autoValue' | 'cacheOptions' - | 'defaultOptions' // utilized by QuerySelect to support "preLoad" and "loadOnFocus" behaviors. - | 'isLoading' // utilized by QuerySelect to support "loadOnFocus" behavior. + | 'defaultOptions' // used by QuerySelect to support "preLoad" and "loadOnFocus" behaviors. + | 'isLoading' // used by QuerySelect to support "loadOnFocus" behavior. | 'labelKey' | 'loadOptions' | 'onChange' // overridden by QuerySelect. See onQSChange(). @@ -160,6 +160,7 @@ type InheritedSelectInputProps = Omit< >; export interface QuerySelectOwnProps extends InheritedSelectInputProps { + allowAddEntities?: boolean; autoInit?: boolean; containerFilter?: Query.ContainerFilter; /** The path to the LK container that the queries should be scoped to. */ @@ -171,7 +172,7 @@ export interface QuerySelectOwnProps extends InheritedSelectInputProps { groupByColumn?: string; loadOnFocus?: boolean; maxRows?: number; - /** When enabled "not found" (i.e. unresolved) values will be processed as selectable items. */ + /** When enabled "not found" (i.e., unresolved) values will be processed as selectable items. */ notFoundValuesEnabled?: boolean; onInitValue?: (value: any, selectedValues: List) => void; onQSChange?: QuerySelectChange; @@ -195,7 +196,9 @@ type Search = { export const QuerySelect: FC = memo(props => { const { + /* eslint-disable @typescript-eslint/no-unused-vars */ OptionComponent, + allowAddEntities = true, // Prevent initialization in test environments in lieu of mocking APIWrapper in all test locations autoInit = !isTestEnv(), containerFilter, @@ -258,7 +261,7 @@ export const QuerySelect: FC = memo(props => { ); // This persists all searches done prior to the select being fully initialized. Once initialized, // these searches are cleared out and resolved. The reason we need to retain these is the underlying - // SelectInput retains these search results, however, we need be fully initialized to complete a search. + // SelectInput retains these search results; however, we need to be fully initialized to complete a search. const [searches, setSearches] = useState([]); const debounceTO = useTimeout(); const shouldLoadOnFocus = loadOnFocus && !loadOnFocusLock; @@ -280,6 +283,8 @@ export const QuerySelect: FC = memo(props => { return { notFoundValues: notFoundValues_, selectedOptions: options }; }, [model]); + const { close: closeModal, open: openModal, show: showModal } = useModalState(); + const isAddEntitiesEnabled = useIsAddEntitiesEnabled(schemaQuery) && allowAddEntities; useEffect(() => { if (!autoInit) return; @@ -345,7 +350,7 @@ export const QuerySelect: FC = memo(props => { }); }, []); - // Any searches (i.e. calls to loadOptions()) made prior to the select being fully + // Any searches (i.e., calls to loadOptions()) made prior to the select being fully // initialized are resolved here after the model has been initialized. useEffect(() => { if (model.isInit && searches.length > 0) { @@ -356,7 +361,7 @@ export const QuerySelect: FC = memo(props => { const loadOptions = useCallback( (input: string): Promise => { - // If loadOptions occurs prior to call to "onFocus" then there is no need to "loadOnFocus". + // If loadOptions occurs prior to call to "onFocus", then there is no need to "loadOnFocus". if (shouldLoadOnFocus) { setLoadOnFocusLock(true); } @@ -387,8 +392,25 @@ export const QuerySelect: FC = memo(props => { [model, onQSChange] ); + const onAddEntitiesComplete = useCallback( + async resultsMap => { + closeModal(); + + // TODO: This is not fully correct. Need to load the result into the model first, then select it. + const { displayColumn, valueColumn } = model; + + const result = resultsMap.get(schemaQuery.getKey()); + const label = resolveDetailFieldLabel(caseInsensitive(result.rows[0], displayColumn)) as string; + const value = resolveDetailFieldValue(caseInsensitive(result.rows[0], valueColumn)); + const option: SelectInputOption = { label, value }; + + onChange(name, option.value, option, undefined); + }, + [closeModal, model, name, onChange, schemaQuery] + ); + const onFocus = useCallback(async () => { - // NK: To support loading the select upon focus (a.k.a. "loadOnFocus") we have to explicitly utilize + // NK: To support loading the select upon focus (a.k.a. "loadOnFocus"), we have to explicitly use // the "defaultOptions" and "isLoading" properties of ReactSelect. These properties, in tandem with // "loadOptions", allow for an asynchronous ReactSelect to defer requesting the initial options until // desired. This follows the pattern outlined here: @@ -416,7 +438,7 @@ export const QuerySelect: FC = memo(props => { [OptionComponent, model] ); - // Issue 52773: If a value is specified, but we are unable to resolve the value then display a warning to the user. + // Issue 52773: If a value is specified, but we are unable to resolve the value, then display a warning to the user. const warning = useMemo(() => { if (notFoundValues.size === 0) return undefined; const warningValue = notFoundValues.size === 1 ? Array.from(notFoundValues)[0] : 'multiple values'; @@ -452,28 +474,40 @@ export const QuerySelect: FC = memo(props => { } return ( - 0 ? false : required} - selectedOptions={displaySelectedOptions ? selectedOptions : undefined} - value={getValue(model, multiple)} // needed to initialize the Formsy "value" properly - warning={warning} - /> + <> + } + onChange={onChange} + onFocus={onFocus} + optionRenderer={optionRenderer} + options={undefined} // prevent override + // Issue 52773: Allow for submission of required fields whose value is not found + required={notFoundValues.size > 0 ? false : required} + selectedOptions={displaySelectedOptions ? selectedOptions : undefined} + value={getValue(model, multiple)} // needed to initialize the Formsy "value" properly + warning={warning} + /> + {showModal && ( + + )} + ); }); QuerySelect.displayName = 'QuerySelect'; From 63a27286906cb8dd3e9413aeca94cb2491d8e7cb Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 16:14:48 -0700 Subject: [PATCH 11/37] ActionButton FC --- .../components/buttons/ActionButton.tsx | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/components/src/internal/components/buttons/ActionButton.tsx b/packages/components/src/internal/components/buttons/ActionButton.tsx index 18d34389fa..ef9972adb2 100644 --- a/packages/components/src/internal/components/buttons/ActionButton.tsx +++ b/packages/components/src/internal/components/buttons/ActionButton.tsx @@ -2,12 +2,12 @@ * Copyright (c) 2020-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, { PropsWithChildren, ReactNode } from 'react'; +import React, { FC, memo, PropsWithChildren, ReactNode } from 'react'; import classNames from 'classnames'; import { LabelHelpTip } from '../base/LabelHelpTip'; -export interface ActionButtonProps extends PropsWithChildren { +export interface ActionButtonProps { buttonClass?: string; containerClass?: string; disabled?: boolean; @@ -17,26 +17,28 @@ export interface ActionButtonProps extends PropsWithChildren { title?: string; } -export class ActionButton extends React.PureComponent { - static defaultProps = { - containerClass: 'form-group', - helperTitle: 'More Info', - }; +export const ActionButton: FC = memo(props => { + const { + buttonClass, + children, + containerClass = 'form-group', + disabled, + helperBody, + helperTitle = 'More Info', + onClick, + title, + } = props; + const buttonClasses = classNames('container--action-button btn btn-default', { disabled }); - render() { - const { buttonClass, containerClass, disabled, onClick, title, helperBody, helperTitle, children } = this.props; - - const buttonClasses = classNames('container--action-button btn btn-default', { disabled }); - - return ( -
-
- - {helperBody && {helperBody}} -
+ return ( +
+
+ + {helperBody && {helperBody}}
- ); - } -} +
+ ); +}); +ActionButton.displayName = 'ActionButton'; From da9dc957e5f92594a785ae78b3ef7e72ab576a26 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 16 Jul 2026 12:58:50 -0700 Subject: [PATCH 12/37] SelectInput.cacheKey --- .../forms/input/SelectInput.test.tsx | 77 +++++++++++++++---- .../components/forms/input/SelectInput.tsx | 21 +++-- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/packages/components/src/internal/components/forms/input/SelectInput.test.tsx b/packages/components/src/internal/components/forms/input/SelectInput.test.tsx index ed5b5d1b85..1227f02482 100644 --- a/packages/components/src/internal/components/forms/input/SelectInput.test.tsx +++ b/packages/components/src/internal/components/forms/input/SelectInput.test.tsx @@ -3,18 +3,29 @@ * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; - -import { initOptions, SelectInputImpl, SelectInputProps } from './SelectInput'; +import { initOptions, SelectInputImpl, SelectInputImplProps } from './SelectInput'; describe('SelectInput', () => { - function getDefaultProps(): Partial { + function defaultProps(): SelectInputImplProps { return { + errorMessage: undefined, + errorMessages: undefined, formsy: true, - getErrorMessage: jest.fn(), - getValue: jest.fn(), + hasValue: true, + isFormDisabled: false, + isFormSubmitted: false, + isPristine: true, + isRequired: false, + isValid: true, + isValidValue: jest.fn(), + name: 'select-input-field', + resetValue: jest.fn(), setValue: jest.fn(), + setValidations: jest.fn(), + showError: false, + showRequired: false, }; } @@ -22,11 +33,9 @@ describe('SelectInput', () => { const containerCls = 'container-class-test'; const inputCls = 'input-class-test'; - render( - - ); - expect(document.querySelectorAll('.' + containerCls).length).toBe(1); - expect(document.querySelectorAll('.' + inputCls).length).toBe(1); + render(); + expect(document.querySelectorAll('.' + containerCls)).toHaveLength(1); + expect(document.querySelectorAll('.' + inputCls)).toHaveLength(1); }); // TODO convert those 2 tests? @@ -81,26 +90,62 @@ describe('SelectInput', () => { const customLabel = 'Jest Custom Label Test'; test('renderFieldLabel', () => { - const component = render(); + const component = render(); validateFieldLabel(component, defaultLabel + ' '); }); test('renderFieldLabel, customLabel', () => { - const component = render(
{customLabel}
} />); + const component = render( +
{customLabel}
} + showLabel + /> + ); validateFieldLabel(component, customLabel); }); test('renderFieldLabel, required', () => { - const component = render(); + const component = render( + + ); validateFieldLabel(component, defaultLabel + ' * '); }); test('renderFieldLabel, showLabel=false', () => { - const component = render(); + const component = render( + + ); validateFieldLabel(component); }); - }); + describe('cacheKey', () => { + test('reloads async options when changed', async () => { + const loadOptions = jest.fn().mockResolvedValue([]); + const props = defaultProps(); + + const { rerender } = render(); + await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1)); + + // Re-rendering with an unchanged cacheKey should not reload options + rerender(); + expect(loadOptions).toHaveBeenCalledTimes(1); + + // Changing the cacheKey remounts the underlying async select, reloading the default options + rerender(); + await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2)); + }); + + test('ignored for non-async configurations', () => { + const props = defaultProps(); + + const { rerender } = render(); + rerender(); + + expect(document.querySelectorAll('.select-input')).toHaveLength(1); + }); + }); describe('initOptions', () => { test('empty values', () => { diff --git a/packages/components/src/internal/components/forms/input/SelectInput.tsx b/packages/components/src/internal/components/forms/input/SelectInput.tsx index 5d4f2dcd64..735d719cf7 100644 --- a/packages/components/src/internal/components/forms/input/SelectInput.tsx +++ b/packages/components/src/internal/components/forms/input/SelectInput.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, ComponentType, CSSProperties, FC, FocusEvent, KeyboardEvent, ReactNode } from 'react'; +import React, { Component, CSSProperties, FC, FocusEvent, KeyboardEvent, ReactNode } from 'react'; import ReactSelect, { components } from 'react-select'; import AsyncSelect from 'react-select/async'; import AsyncCreatableSelect from 'react-select/async-creatable'; @@ -161,7 +161,7 @@ function initOptionFromPrimitive(value: number | string, props: SelectInputProps } // Used to initialize the selected options in `state` when `autoValue` is enabled. -// This will accept a primitive value (e.g. 5) and resolve it to an option (e.g. { label: 'Awesome', value: 5 }) +// This will accept a primitive value (e.g., 5) and resolve it to an option (e.g., { label: 'Awesome', value: 5 }) // if the option is available. Supports mapping single or multiple values. export function initOptions(props: SelectInputProps): SelectInputOption | SelectInputOption[] { const { value, options } = props; @@ -200,6 +200,13 @@ export interface SelectInputProps { autoFocus?: boolean; autoValue?: boolean; backspaceRemovesValue?: boolean; + /** + * When the value of this prop changes, the underlying asynchronous React Select is remounted, clearing + * its cached options and reloading the default options. Use this to invalidate previously loaded options + * when the option set is known to have changed (e.g., a new option was created). Only applies to + * asynchronous configurations (i.e., when "loadOptions" is provided). + */ + cacheKey?: number | string; cacheOptions?: boolean; clearable?: boolean; clearCacheOnChange?: boolean; @@ -267,10 +274,10 @@ export interface SelectInputProps { warning?: ReactNode; } -type SelectInputImplProps = FormsyInjectedProps & SelectInputProps; +export type SelectInputImplProps = FormsyInjectedProps & SelectInputProps; interface State { - // This state property is used in conjunction with the prop "clearCacheOnChange" which when true + // This state property is used in conjunction with the prop "clearCacheOnChange", which when true, // is intended to clear the underlying asynchronous React Select's cache. // See https://github.com/JedWatson/react-select/issues/1879 asyncKey: number; @@ -300,7 +307,7 @@ export class SelectInputImpl extends Component { labelClass: INPUT_LABEL_CLASS_NAME, menuPlacement: 'auto', // Default to 'fixed' because 'absolute' causes issues in several scenarios (Modals, EditableGrid) but it's too - // difficult to manually set it to fixed in all of these situations (e.g. we don't always know we're in a modal) + // difficult to manually set it to fixed in all of these situations (e.g., we don't always know we're in a modal) menuPosition: 'fixed', openMenuOnFocus: false, saveOnBlur: false, @@ -348,6 +355,10 @@ export class SelectInputImpl extends Component { this.setState({ originalOptions: selectedOptions, selectedOptions }); } + if (this.isAsync() && prevProps.cacheKey !== this.props.cacheKey) { + this.setState(state => ({ asyncKey: state.asyncKey + 1 })); + } + this.CHANGE_LOCK = false; } From ebc868f975ecdd0eab48759b27c9252f5baf991c Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 16 Jul 2026 13:12:38 -0700 Subject: [PATCH 13/37] QuerySelect implement onAddEntitiesComplete --- .../internal/components/forms/QuerySelect.tsx | 61 ++++- .../internal/components/forms/model.test.ts | 221 +++++++++++++++++- .../src/internal/components/forms/model.ts | 171 +++++++++++--- 3 files changed, 414 insertions(+), 39 deletions(-) diff --git a/packages/components/src/internal/components/forms/QuerySelect.tsx b/packages/components/src/internal/components/forms/QuerySelect.tsx index 49c9dca2da..6d0ec3ee40 100644 --- a/packages/components/src/internal/components/forms/QuerySelect.tsx +++ b/packages/components/src/internal/components/forms/QuerySelect.tsx @@ -14,7 +14,7 @@ import { Row } from '../../query/selectRows'; import { QueryInfo } from '../../../public/QueryInfo'; -import { caseInsensitive, isTestEnv } from '../../util/utils'; +import { caseInsensitive, isTestEnv, joinMultiValueForExport } from '../../util/utils'; import { useModalState, useTimeout } from '../../hooks'; @@ -22,13 +22,17 @@ import { SelectInput, SelectInputChange, SelectInputOption, SelectInputProps } f import { resolveDetailFieldLabel, resolveDetailFieldValue } from './utils'; import { fetchSearchResults, + fetchSelectedValues, formatResults, formatSavedResults, initSelect, + parseRawValue, parseSelectedQuery, QuerySelectModel, saveSearchResults, setSelection, + setSelectionWithResults, + valuesAreLoaded, } from './model'; import { DELIMITER } from './constants'; import { AddEntitiesMenuFooter, AddEntitiesModal, useIsAddEntitiesEnabled } from './AddEntitiesModal'; @@ -147,6 +151,7 @@ type InheritedSelectInputProps = Omit< SelectInputProps, | 'allowCreate' | 'autoValue' + | 'cacheKey' // used by QuerySelect to invalidate cached options when new entities are added. | 'cacheOptions' | 'defaultOptions' // used by QuerySelect to support "preLoad" and "loadOnFocus" behaviors. | 'isLoading' // used by QuerySelect to support "loadOnFocus" behavior. @@ -247,6 +252,7 @@ export const QuerySelect: FC = memo(props => { // See note in onFocus() regarding support for "loadOnFocus" preLoad !== false ? true : loadOnFocus ? [] : true ); + const [cacheKey, setCacheKey] = useState(0); const [error, setError] = useState(); const [loadOnFocusLock, setLoadOnFocusLock] = useState(false); const [isLoading, setIsLoading] = useState(undefined); @@ -395,18 +401,54 @@ export const QuerySelect: FC = memo(props => { const onAddEntitiesComplete = useCallback( async resultsMap => { closeModal(); + const result = resultsMap.get(schemaQuery.getKey()); + if (!model.isInit || !result?.rows?.length) return; + + // For multiple, append the added values to the current selection and join them in the same manner + // as SelectInput resolves its form value upon interactive selection. + let nextValue: string | string[]; + { + const addedValues = result.rows.map(row => + resolveDetailFieldValue(caseInsensitive(row, model.valueColumn)) + ); + + if (model.multiple) { + nextValue = joinMultiValueForExport( + parseRawValue(model.rawSelectedValue, true, model.delimiter).concat(addedValues), + model.delimiter + ); + } else { + nextValue = addedValues[0]; + } + } - // TODO: This is not fully correct. Need to load the result into the model first, then select it. - const { displayColumn, valueColumn } = model; + try { + let model_: QuerySelectModel; - const result = resultsMap.get(schemaQuery.getKey()); - const label = resolveDetailFieldLabel(caseInsensitive(result.rows[0], displayColumn)) as string; - const value = resolveDetailFieldValue(caseInsensitive(result.rows[0], valueColumn)); - const option: SelectInputOption = { label, value }; + if (valuesAreLoaded(model, nextValue)) { + model_ = setSelection(model, nextValue); + } else { + // Load the added value(s) into the model, applying the model's configured columns and + // filters, and then select them. + setIsLoading(true); + const results = await fetchSelectedValues(model, nextValue); + model_ = setSelectionWithResults(model, results, nextValue, notFoundValuesEnabled); + } + + setModel(model_); - onChange(name, option.value, option, undefined); + // The underlying SelectInput's cached options do not include the newly added entities. + setCacheKey(k => k + 1); + setDefaultOptions(true); + + onQSChange?.(name, model_.rawSelectedValue, model_.selectedOptions, props, model_.selectedItems); + } catch (e) { + setError(resolveErrorMessage(e) ?? 'Failed to load the newly added value.'); + } finally { + setIsLoading(undefined); + } }, - [closeModal, model, name, onChange, schemaQuery] + [closeModal, model, name, notFoundValuesEnabled, onQSChange, props, schemaQuery] ); const onFocus = useCallback(async () => { @@ -482,6 +524,7 @@ export const QuerySelect: FC = memo(props => { {...selectInputProps} allowCreate={false} autoValue={false} // QuerySelect directly controls value of SelectInput via "selectedOptions" + cacheKey={cacheKey} cacheOptions defaultOptions={defaultOptions} delimiter={delimiter} diff --git a/packages/components/src/internal/components/forms/model.test.ts b/packages/components/src/internal/components/forms/model.test.ts index adc6311c2b..92f020f71f 100644 --- a/packages/components/src/internal/components/forms/model.test.ts +++ b/packages/components/src/internal/components/forms/model.test.ts @@ -2,14 +2,33 @@ * Copyright (c) 2022-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 { fromJS } from 'immutable'; +import { fromJS, List } from 'immutable'; import { Filter } from '@labkey/api'; import { QueryInfo } from '../../../public/QueryInfo'; import { ExtendedMap } from '../../../public/ExtendedMap'; import { QueryColumn } from '../../../public/QueryColumn'; - -import { buildValueFilter, findNotFoundValues, parseSelectedQuery, QuerySelectModel, queryColumnNames } from './model'; +import { SchemaQuery } from '../../../public/SchemaQuery'; + +import { ISelectRowsResult, selectRowsDeprecated } from '../../query/api'; + +import { + buildValueFilter, + fetchSelectedValues, + findNotFoundValues, + parseRawValue, + parseSelectedQuery, + queryColumnNames, + QuerySelectModel, + setSelection, + setSelectionWithResults, + valuesAreLoaded, +} from './model'; + +jest.mock('../../query/api', () => ({ + ...jest.requireActual('../../query/api'), + selectRowsDeprecated: jest.fn(), +})); describe('form actions', () => { const setSelectionModel = new QuerySelectModel({ @@ -209,4 +228,200 @@ describe('form actions', () => { expect(findNotFoundValues(mixedTypes, filter([1, 2, 3]), 'id')).toEqual(['3']); }); }); + + describe('parseRawValue', () => { + test('empty values', () => { + expect(parseRawValue(undefined, false, ',')).toEqual([]); + expect(parseRawValue(null, true, ',')).toEqual([]); + expect(parseRawValue('', true, ',')).toEqual([]); + }); + + test('scalar values', () => { + expect(parseRawValue(5, false, ',')).toEqual([5]); + expect(parseRawValue('word', false, ',')).toEqual(['word']); + expect(parseRawValue(false, false, ',')).toEqual([false]); + }); + + test('array and List values', () => { + expect(parseRawValue([1, 2], true, ',')).toEqual([1, 2]); + expect(parseRawValue(List([1, 2]), true, ',')).toEqual([1, 2]); + }); + + test('delimited string values', () => { + expect(parseRawValue('a,b', true, ',')).toEqual(['a', 'b']); + expect(parseRawValue('a;b', true, ';')).toEqual(['a', 'b']); + // when not multiple, strings are not split + expect(parseRawValue('a,b', false, ',')).toEqual(['a,b']); + }); + }); + + const loadedResults = fromJS({ + '1': { RowId: { value: 1 }, Name: { value: 'Alpha' } }, + '2': { RowId: { value: 2 }, Name: { value: 'Beta' } }, + }); + + const singleModel = new QuerySelectModel({ + allResults: loadedResults, + delimiter: ',', + displayColumn: 'Name', + isInit: true, + valueColumn: 'RowId', + }); + + const multiModel = singleModel.merge({ multiple: true }) as QuerySelectModel; + + const KEY = new SchemaQuery('test', 'query').getKey(); + + function makeResult(rows: Record): ISelectRowsResult { + return { + key: KEY, + models: { [KEY]: rows }, + orderedModels: List(Object.keys(rows)), + queries: {}, + rowCount: Object.keys(rows).length, + }; + } + + describe('valuesAreLoaded', () => { + test('empty value', () => { + expect(valuesAreLoaded(singleModel, undefined)).toBe(true); + expect(valuesAreLoaded(singleModel, null)).toBe(true); + expect(valuesAreLoaded(singleModel, '')).toBe(true); + }); + + test('single value', () => { + expect(valuesAreLoaded(singleModel, 1)).toBe(true); + expect(valuesAreLoaded(singleModel, '1')).toBe(true); + expect(valuesAreLoaded(singleModel, 3)).toBe(false); + }); + + test('multiple values', () => { + expect(valuesAreLoaded(multiModel, [1, 2])).toBe(true); + expect(valuesAreLoaded(multiModel, '1,2')).toBe(true); + expect(valuesAreLoaded(multiModel, [1, 3])).toBe(false); + expect(valuesAreLoaded(multiModel, '1,3')).toBe(false); + }); + + test('resolves against selectedItems', () => { + const model = new QuerySelectModel({ + delimiter: ',', + displayColumn: 'Name', + isInit: true, + selectedItems: fromJS({ '9': { RowId: { value: 9 }, Name: { value: 'Iota' } } }), + valueColumn: 'RowId', + }); + expect(valuesAreLoaded(model, 9)).toBe(true); + expect(valuesAreLoaded(model, 1)).toBe(false); + }); + }); + + describe('setSelection', () => { + test('resolves single value across types', () => { + const model = setSelection(singleModel, '2'); + expect(model.rawSelectedValue).toBe('2'); + expect(model.selectedItems.size).toBe(1); + expect(model.selectedItems.getIn(['2', 'RowId', 'value'])).toBe(2); + expect(model.selectedQuery).toBe('Beta'); + }); + + test('clears selection', () => { + const model = setSelection(setSelection(singleModel, 1), undefined); + expect(model.selectedItems.size).toBe(0); + expect(model.selectedQuery).toBe(''); + }); + }); + + describe('setSelectionWithResults', () => { + const gammaRow = { RowId: { value: 3 }, Name: { value: 'Gamma' } }; + + test('single value not previously loaded', () => { + const model = setSelectionWithResults(singleModel, makeResult({ '3': gammaRow }), 3, true); + + expect(model.rawSelectedValue).toBe(3); + expect(model.allResults.size).toBe(3); + expect(model.selectedItems.size).toBe(1); + expect(model.selectedItems.getIn(['3', 'Name', 'value'])).toBe('Gamma'); + expect(model.selectedQuery).toBe('Gamma'); + }); + + test('multiple values appended to loaded values', () => { + const model = setSelectionWithResults(multiModel, makeResult({ '3': gammaRow }), '1,3', true); + + expect(model.rawSelectedValue).toBe('1,3'); + expect(model.allResults.size).toBe(3); + expect(model.selectedItems.size).toBe(2); + expect(model.selectedQuery).toBe('Alpha,Gamma'); + // The previously loaded row resolves locally and is not marked as "not found" + expect(model.selectedItems.getIn(['1', 'RowId', 'notFound'])).toBeUndefined(); + }); + + test('unresolved value marked as not found', () => { + const model = setSelectionWithResults(singleModel, makeResult({}), 99, true); + + expect(model.rawSelectedValue).toBe(99); + expect(model.selectedItems.size).toBe(1); + expect(model.selectedItems.getIn(['99', 'RowId', 'notFound'])).toBe(true); + expect(model.selectedItems.getIn(['99', 'RowId', 'displayValue'])).toBe('<99>'); + }); + + test('unresolved value skipped when notFoundValuesEnabled is false', () => { + const model = setSelectionWithResults(singleModel, makeResult({}), 99, false); + + expect(model.rawSelectedValue).toBe(99); + expect(model.selectedItems.size).toBe(0); + }); + + test('partially resolved multiple values', () => { + const model = setSelectionWithResults(multiModel, makeResult({ '3': gammaRow }), '3,99', true); + + expect(model.selectedItems.size).toBe(2); + expect(model.selectedItems.getIn(['3', 'Name', 'value'])).toBe('Gamma'); + expect(model.selectedItems.getIn(['99', 'RowId', 'notFound'])).toBe(true); + }); + }); + + describe('fetchSelectedValues', () => { + const selectRowsDeprecatedMock = selectRowsDeprecated as jest.Mock; + + const fetchModel = singleModel.merge({ + containerPath: '/Fetch/Test', + queryInfo: new QueryInfo({ pkCols: ['RowId'] }), + schemaQuery: new SchemaQuery('exp', 'samples'), + }) as QuerySelectModel; + + beforeEach(() => { + selectRowsDeprecatedMock.mockReset(); + selectRowsDeprecatedMock.mockResolvedValue(makeResult({})); + }); + + test('single value', async () => { + await fetchSelectedValues(fetchModel, 3); + + expect(selectRowsDeprecatedMock).toHaveBeenCalledTimes(1); + const options = selectRowsDeprecatedMock.mock.calls[0][0]; + expect(options.schemaName).toBe('exp'); + expect(options.queryName).toBe('samples'); + expect(options.containerPath).toBe('/Fetch/Test'); + expect(options.columns).toEqual(expect.arrayContaining(['RowId', 'Name'])); + expect(options.filterArray).toHaveLength(1); + expect(options.filterArray[0].getColumnName()).toBe('RowId'); + expect(options.filterArray[0].getValue()).toBe(3); + }); + + test('multiple values with queryFilters', async () => { + const model = fetchModel.merge({ + multiple: true, + queryFilters: List([Filter.create('Status', 'Active')]), + }) as QuerySelectModel; + + await fetchSelectedValues(model, [1, 3]); + + const options = selectRowsDeprecatedMock.mock.calls[0][0]; + expect(options.filterArray).toHaveLength(2); + expect(options.filterArray[0].getColumnName()).toBe('Status'); + expect(options.filterArray[1].getColumnName()).toBe('RowId'); + expect(options.filterArray[1].getValue()).toEqual([1, 3]); + expect(options.filterArray[1].getFilterType().getURLSuffix()).toBe(Filter.Types.IN.getURLSuffix()); + }); + }); }); diff --git a/packages/components/src/internal/components/forms/model.ts b/packages/components/src/internal/components/forms/model.ts index c67bd10bc4..a895cf5073 100644 --- a/packages/components/src/internal/components/forms/model.ts +++ b/packages/components/src/internal/components/forms/model.ts @@ -94,7 +94,7 @@ function formatGroupedResults(model: QuerySelectModel, results: Map } /** - * Given a model this method returns "options" that are consumable by a ReactSelect. + * Given a model, this method returns "options" that are consumable by a ReactSelect. * @param model for which results are formatted * @param result select rows result * @param token an optional search token that will be used to sort the results @@ -130,6 +130,18 @@ export function saveSearchResults(model: QuerySelectModel, result: ISelectRowsRe }) as QuerySelectModel; } +/** + * Normalizes a raw selection value into an array of values. Handles arrays, Immutable Lists, + * delimited strings (when "multiple" is enabled), and scalar values. + */ +export function parseRawValue(value: any, multiple: boolean, delimiter: string): any[] { + if (!validValue(value)) return []; + if (Array.isArray(value)) return value; + if (List.isList(value)) return (value as List).toArray(); + if (multiple && typeof value === 'string') return splitMultiValueForImport(value, delimiter); + return [value]; +} + function getSelectedOptions(model: QuerySelectModel, value: any): Map { // if no "value", just return currently selectedItems if (value === undefined || value === null || value === '') { @@ -142,7 +154,7 @@ function getSelectedOptions(model: QuerySelectModel, value: any): Map v.toString()); return sources .filter(result => { const resultValue = result.getIn(keyPath); @@ -152,7 +164,7 @@ function getSelectedOptions(model: QuerySelectModel, value: any): Map source.getIn(keyPath) === value).toMap(); + return sources.filter(source => source.getIn(keyPath)?.toString() === value.toString()).toMap(); } // "selectedQuery" should match against displayColumn as that is what the user is typing against @@ -374,31 +386,132 @@ function validValue(value: any): boolean { return value !== undefined && value !== null && value !== ''; } -function initSelectedItems( - props: QuerySelectOwnProps, - queryInfo: QueryInfo, - valueColumn: string, - displayColumn: string, - groupByColumn: string, - filter: Filter.IFilter -): Promise { - const filters = props.queryFilters ? props.queryFilters.toArray() : []; +interface SelectValueRowsOptions { + columns: string[]; + containerFilter?: Query.ContainerFilter; + containerPath?: string; + queryFilters?: List; + queryParams?: Record; + schemaQuery: SchemaQuery; +} + +function selectValueRows(options: SelectValueRowsOptions, filter: Filter.IFilter): Promise { + const filters = options.queryFilters ? options.queryFilters.toArray() : []; filters.push(filter); - const { queryName, schemaName, viewName } = props.schemaQuery; + const { queryName, schemaName, viewName } = options.schemaQuery; return selectRowsDeprecated({ - columns: queryColumnNames(queryInfo, displayColumn, valueColumn, props.requiredColumns, groupByColumn), - containerFilter: props.containerFilter, - containerPath: props.containerPath, + columns: options.columns, + containerFilter: options.containerFilter, + containerPath: options.containerPath, filterArray: filters, - parameters: props.queryParams, + parameters: options.queryParams, queryName, schemaName, viewName, }); } +/** + * Fetches the row(s) matching the given selection value(s) using the model's configured columns and filters. + * Useful for resolving a value that is not present in the model's locally loaded results. + * @see setSelectionWithResults + */ +export function fetchSelectedValues(model: QuerySelectModel, value: any): Promise { + const { filter } = buildValueFilter(value, model.valueColumn, model.multiple, model.delimiter); + + return selectValueRows( + { + columns: model.queryColumnNames, + containerFilter: model.containerFilter, + containerPath: model.containerPath, + queryFilters: model.queryFilters, + queryParams: model.queryParams, + schemaQuery: model.schemaQuery, + }, + filter + ); +} + +function notFoundRow(valueColumn: string, value: string): Record { + return { [valueColumn]: { displayValue: `<${value}>`, notFound: true, value } }; +} + +/** Mutates "selectedRows" adding placeholder rows for any expected values that failed to resolve. */ +function applyNotFoundValues( + selectedRows: Record, + filter: Filter.IFilter, + valueColumn: string, + expectedValueCount: number +): void { + if (!selectedRows || Object.keys(selectedRows).length === expectedValueCount) return; + + findNotFoundValues(selectedRows, filter, valueColumn).forEach(v => { + if (!selectedRows.hasOwnProperty(v)) { + selectedRows[v] = notFoundRow(valueColumn, v); + } + }); +} + +/** + * Merges the rows from the given result into the model's results and then applies the selection of the + * given value. This leaves the model in a consistent state when selecting value(s) whose backing rows + * were not previously loaded into the model's local results. + * @see fetchSelectedValues + */ +export function setSelectionWithResults( + model: QuerySelectModel, + result: ISelectRowsResult, + value: any, + notFoundValuesEnabled?: boolean +): QuerySelectModel { + let allResults = model.allResults.merge(fromJS(result.models[result.key])); + let updated = setSelection(model.merge({ allResults }) as QuerySelectModel, value); + + if (notFoundValuesEnabled) { + const { expectedValueCount, filter } = buildValueFilter( + value, + model.valueColumn, + model.multiple, + model.delimiter + ); + + // Values that fail to resolve against the combined (fetched + previously loaded) results + // are marked with "not found" placeholder rows. + if (updated.selectedItems.size !== expectedValueCount) { + const placeholders: Record = {}; + findNotFoundValues(updated.selectedItems.toJS(), filter, model.valueColumn).forEach(v => { + placeholders[v] = notFoundRow(model.valueColumn, v); + }); + + if (Object.keys(placeholders).length > 0) { + allResults = allResults.merge(fromJS(placeholders)); + updated = setSelection(model.merge({ allResults }) as QuerySelectModel, value); + } + } + } + + return updated; +} + +/** Returns true if all the requested value(s) can be resolved from results already loaded into the model. */ +export function valuesAreLoaded(model: QuerySelectModel, value: any): boolean { + const values = parseRawValue(value, model.multiple, model.delimiter).filter(validValue); + if (values.length === 0) return true; + + // model.valueColumn is fieldKey, not column name + const keyPath = [QueryKey.decodePart(model.valueColumn), 'value']; + const loaded = model.allResults + .merge(model.selectedItems) + .map(row => row.getIn(keyPath)) + .filter(validValue) + .map(v => v.toString()) + .toSet(); + + return values.every(v => loaded.has(v.toString())); +} + export async function initSelect(props: QuerySelectOwnProps): Promise> { const { delimiter, multiple, notFoundValuesEnabled, value } = props; const { queryInfo, valueColumn, displayColumn, groupByColumn } = await initQueryInfoWithColumns(props); @@ -407,16 +520,20 @@ export async function initSelect(props: QuerySelectOwnProps): Promise { - if (!selectedRows.hasOwnProperty(v)) { - selectedRows[v] = { [valueColumn]: { displayValue: `<${v}>`, notFound: true, value: v } }; - } - }); + selectedItems = await selectValueRows( + { + columns: queryColumnNames(queryInfo, displayColumn, valueColumn, props.requiredColumns, groupByColumn), + containerFilter: props.containerFilter, + containerPath: props.containerPath, + queryFilters: props.queryFilters, + queryParams: props.queryParams, + schemaQuery: props.schemaQuery, + }, + filter + ); + + if (notFoundValuesEnabled) { + applyNotFoundValues(selectedItems.models[selectedItems.key], filter, valueColumn, expectedValueCount); } } From 96f1cf54e42ff2829346d45503783b03e9f6f5ca Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 16 Jul 2026 13:17:51 -0700 Subject: [PATCH 14/37] AddEntitiesModalContext.Provider --- .../components/forms/AddEntitiesModal.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/components/src/internal/components/forms/AddEntitiesModal.tsx b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx index f94afb6826..befee6d272 100644 --- a/packages/components/src/internal/components/forms/AddEntitiesModal.tsx +++ b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx @@ -43,14 +43,16 @@ export const AddEntitiesModal: FC = props => { } return ( - // eslint-disable-next-line react-hooks/static-components - + + {/* eslint-disable-next-line react-hooks/static-components */} + + ); }; AddEntitiesModal.displayName = 'AddEntitiesModal'; From 8fdd911b792adb12f1f830316905cf2fabb5c2fb Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 06:59:10 -0700 Subject: [PATCH 15/37] ModalRenderFactory register null --- .../src/internal/ModalRenderFactory.test.ts | 63 +++++++++++++++++++ .../src/internal/ModalRenderFactory.ts | 18 +++++- 2 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 packages/components/src/internal/ModalRenderFactory.test.ts diff --git a/packages/components/src/internal/ModalRenderFactory.test.ts b/packages/components/src/internal/ModalRenderFactory.test.ts new file mode 100644 index 0000000000..5efb9b8cf2 --- /dev/null +++ b/packages/components/src/internal/ModalRenderFactory.test.ts @@ -0,0 +1,63 @@ +/* + * 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 { SchemaQuery } from '../public/SchemaQuery'; + +import { + ModalRenderContext, + ModalRendererComponent, + registerModalRenderer, + resolveModalRenderer, +} from './ModalRenderFactory'; + +const ExactRenderer: ModalRendererComponent = () => null; +const SchemaRenderer: ModalRendererComponent = () => null; + +describe('ModalRenderFactory', () => { + test('resolves an exact SchemaQuery registration', () => { + const sq = new SchemaQuery('exact.schema', 'SomeQuery'); + registerModalRenderer(sq, ExactRenderer); + + expect(resolveModalRenderer(sq)).toBe(ExactRenderer); + expect(resolveModalRenderer(new SchemaQuery('exact.schema', 'OtherQuery'))).toBeUndefined(); + }); + + test('resolves case-insensitively', () => { + registerModalRenderer(new SchemaQuery('Case.Schema', 'MixedQuery'), ExactRenderer); + + expect(resolveModalRenderer(new SchemaQuery('case.schema', 'mixedquery'))).toBe(ExactRenderer); + }); + + test('falls back to a schema-wide string registration', () => { + registerModalRenderer('fallback.schema', SchemaRenderer); + + expect(resolveModalRenderer(new SchemaQuery('fallback.schema', 'AnyQuery'))).toBe(SchemaRenderer); + expect(resolveModalRenderer(new SchemaQuery('other.schema', 'AnyQuery'))).toBeUndefined(); + }); + + test('exact registration wins over the schema-wide fallback', () => { + const sq = new SchemaQuery('override.schema', 'SpecialQuery'); + registerModalRenderer('override.schema', SchemaRenderer); + registerModalRenderer(sq, ExactRenderer); + + expect(resolveModalRenderer(sq)).toBe(ExactRenderer); + expect(resolveModalRenderer(new SchemaQuery('override.schema', 'PlainQuery'))).toBe(SchemaRenderer); + }); + + test('explicit null registration excludes a query from the schema-wide fallback', () => { + const excluded = new SchemaQuery('excluded.schema', 'ExcludedQuery'); + registerModalRenderer('excluded.schema', SchemaRenderer); + registerModalRenderer(excluded, null); + + expect(resolveModalRenderer(excluded)).toBeUndefined(); + expect(resolveModalRenderer(new SchemaQuery('excluded.schema', 'IncludedQuery'))).toBe(SchemaRenderer); + }); + + test('registrations are scoped by ModalRenderContext', () => { + const sq = new SchemaQuery('context.schema', 'ContextQuery'); + registerModalRenderer(sq, ExactRenderer, ModalRenderContext.AddEntities); + + expect(resolveModalRenderer(sq, ModalRenderContext.AddEntities)).toBe(ExactRenderer); + }); +}); diff --git a/packages/components/src/internal/ModalRenderFactory.ts b/packages/components/src/internal/ModalRenderFactory.ts index 4652a6932e..6e76b302db 100644 --- a/packages/components/src/internal/ModalRenderFactory.ts +++ b/packages/components/src/internal/ModalRenderFactory.ts @@ -21,7 +21,7 @@ export interface ModalRendererProps { export type ModalRendererIdentifier = SchemaQuery | string; export type ModalRendererComponent = ComponentType; -const modalRenderers: Record = {}; +const modalRenderers: Record = {}; export enum ModalRenderContext { AddEntities = 'AddEntities', @@ -36,9 +36,14 @@ function identifierToString(identifier: ModalRendererIdentifier): string { return identifier instanceof SchemaQuery ? identifier.toString(false) : identifier; } +/** + * Register a modal renderer for a specific SchemaQuery, or for an entire schema by passing the schema name as a + * string (e.g. "exp.data"). Registering `null` for a specific identifier explicitly opts it out, taking precedence + * over any schema-wide registration. + */ export function registerModalRenderer( identifier: ModalRendererIdentifier, - renderer: ModalRendererComponent, + renderer: ModalRendererComponent | null, modalRenderContext = ModalRenderContext.AddEntities ): void { modalRenderers[getKey(identifier, modalRenderContext)] = renderer; @@ -48,5 +53,12 @@ export function resolveModalRenderer( identifier: SchemaQuery, modalRenderContext = ModalRenderContext.AddEntities ): ModalRendererComponent { - return modalRenderers[getKey(identifier, modalRenderContext)]; + const exactKey = getKey(identifier, modalRenderContext); + if (exactKey in modalRenderers) { + return modalRenderers[exactKey] ?? undefined; + } + if (identifier.schemaName) { + return modalRenderers[getKey(identifier.schemaName, modalRenderContext)] ?? undefined; + } + return undefined; } From 2753d21503e6fa9af0d7d425cddef667a44dd0a7 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 08:22:21 -0700 Subject: [PATCH 16/37] AddEntitiesFooter --- .../components/forms/AddEntitiesModal.tsx | 13 +++++++----- .../internal/components/forms/QuerySelect.tsx | 4 ++-- packages/components/src/theme/fields.scss | 21 ++++++++++++++++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/components/src/internal/components/forms/AddEntitiesModal.tsx b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx index befee6d272..70615b2238 100644 --- a/packages/components/src/internal/components/forms/AddEntitiesModal.tsx +++ b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx @@ -22,13 +22,16 @@ export function useIsAddEntitiesEnabled(schemaQuery: SchemaQuery): boolean { } interface AddEntitiesMenuFooterProps { - openModal: () => void; + onClick: () => void; } -export const AddEntitiesMenuFooter: FC = ({ openModal }) => { - return
Add New
; -}; -AddEntitiesMenuFooter.displayName = 'AddEntitiesMenuFooter'; +export const AddEntitiesFooter: FC = ({ onClick }) => ( +
+ + Add New +
+); +AddEntitiesFooter.displayName = 'AddEntitiesFooter'; export const AddEntitiesModal: FC = props => { const { containerFilter, containerPath, onCancel, onComplete, schemaQuery } = props; diff --git a/packages/components/src/internal/components/forms/QuerySelect.tsx b/packages/components/src/internal/components/forms/QuerySelect.tsx index 6d0ec3ee40..7c83f7e151 100644 --- a/packages/components/src/internal/components/forms/QuerySelect.tsx +++ b/packages/components/src/internal/components/forms/QuerySelect.tsx @@ -35,7 +35,7 @@ import { valuesAreLoaded, } from './model'; import { DELIMITER } from './constants'; -import { AddEntitiesMenuFooter, AddEntitiesModal, useIsAddEntitiesEnabled } from './AddEntitiesModal'; +import { AddEntitiesFooter, AddEntitiesModal, useIsAddEntitiesEnabled } from './AddEntitiesModal'; import { AddEntitiesComplete } from '../../ModalRenderFactory'; function getValue(model: QuerySelectModel, multiple: boolean): any { @@ -530,7 +530,7 @@ export const QuerySelect: FC = memo(props => { delimiter={delimiter} isLoading={isLoading} loadOptions={loadOptions} - menuFooter={isAddEntitiesEnabled && } + menuFooter={isAddEntitiesEnabled && } onChange={onChange} onFocus={onFocus} optionRenderer={optionRenderer} diff --git a/packages/components/src/theme/fields.scss b/packages/components/src/theme/fields.scss index 762352f9d8..58b17ffd37 100644 --- a/packages/components/src/theme/fields.scss +++ b/packages/components/src/theme/fields.scss @@ -179,7 +179,7 @@ button.view-field__action .fa { // GitHub Issue 985: Improve readability of identifying fields .select-input__option--is-selected .identifying_field_label { - color: #FFFFFF; + color: $white; } .folder-field_archived-tag { @@ -190,3 +190,22 @@ button.view-field__action .fa { padding: 2px 3px; } } + +.add-entities-footer { + background-color: $gray-shadow; + border-top: 1px solid $gray-border; + height: 34px; + display: flex; + align-items: center; + padding: 0 8px; + cursor: pointer; + + &:hover { + background-color: $gray-border-light; + } + + > .fa { + color: $brand-success; + padding-right: 4px; + } +} From 436ac0fc6d501080184557697338908862204ab1 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 09:00:17 -0700 Subject: [PATCH 17/37] Modal.showFooter --- packages/components/src/internal/Modal.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/components/src/internal/Modal.tsx b/packages/components/src/internal/Modal.tsx index 6b9c108cd4..c8f53b383f 100644 --- a/packages/components/src/internal/Modal.tsx +++ b/packages/components/src/internal/Modal.tsx @@ -125,6 +125,8 @@ export interface ModalProps extends BaseModalProps, ModalButtonsProps { * Note: You probably should not use header, instead use the other props to render the appropriate header. */ header?: ReactNode; + /** Disable rendering the footer. Defaults to true. */ + showFooter?: boolean; /** * Title passed to the default header (see ModalHeader). If a custom header is supplied, then this is ignored. */ @@ -150,6 +152,7 @@ export const Modal: FC = memo(props => { onCommentChange, onConfirm, requiresUserComment, + showFooter = true, title, } = props; const showHeader = !!(onCancel || title); @@ -160,7 +163,7 @@ export const Modal: FC = memo(props => {
{children}
- {!footer && ( + {showFooter && !footer && ( = memo(props => { )} - {footer &&
{footer}
} + {showFooter && footer &&
{footer}
} ); }); From 4d0a20e5cb7db70cfbdd5f559927bccf52456cba Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 09:00:23 -0700 Subject: [PATCH 18/37] Add test --- .../forms/AddEntitiesModal.test.tsx | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 packages/components/src/internal/components/forms/AddEntitiesModal.test.tsx diff --git a/packages/components/src/internal/components/forms/AddEntitiesModal.test.tsx b/packages/components/src/internal/components/forms/AddEntitiesModal.test.tsx new file mode 100644 index 0000000000..deb4456160 --- /dev/null +++ b/packages/components/src/internal/components/forms/AddEntitiesModal.test.tsx @@ -0,0 +1,142 @@ +/* + * 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 React, { FC } from 'react'; +import { render, renderHook, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { Query } from '@labkey/api'; + +import { SchemaQuery } from '../../../public/SchemaQuery'; +import { ModalRendererProps, registerModalRenderer } from '../../ModalRenderFactory'; + +import { AddEntitiesFooter, AddEntitiesModal, useIsAddEntitiesEnabled } from './AddEntitiesModal'; + +const RegisteredRenderer: FC = ({ schemaQuery }) => ( +
{schemaQuery.toString(false)}
+); + +describe('AddEntitiesModal', () => { + describe('useIsAddEntitiesEnabled', () => { + test('returns false when schemaQuery is undefined', () => { + const { result } = renderHook(() => useIsAddEntitiesEnabled(undefined)); + expect(result.current).toBe(false); + }); + + test('returns false when no modal renderer is registered', () => { + const { result } = renderHook(() => + useIsAddEntitiesEnabled(new SchemaQuery('hook.unregistered', 'NoRenderer')) + ); + expect(result.current).toBe(false); + }); + + test('returns true when a modal renderer is registered and no provider is rendered', () => { + const sq = new SchemaQuery('hook.registered', 'HasRenderer'); + registerModalRenderer(sq, RegisteredRenderer); + + const { result } = renderHook(() => useIsAddEntitiesEnabled(sq)); + expect(result.current).toBe(true); + }); + + test('returns false for a registered renderer rendered within an AddEntitiesModal', () => { + const sq = new SchemaQuery('hook.nested', 'NestedRenderer'); + const NestedRenderer: FC = ({ schemaQuery }) => { + const enabled = useIsAddEntitiesEnabled(schemaQuery); + return
{String(enabled)}
; + }; + registerModalRenderer(sq, NestedRenderer); + + render( + + ); + + expect(document.querySelector('.nested-enabled')).toHaveTextContent('false'); + }); + }); + + describe('AddEntitiesFooter', () => { + test('renders and invokes onClick', async () => { + const onClick = jest.fn(); + render(); + + const footer = document.querySelector('.add-entities-footer'); + expect(footer).toHaveTextContent('Add New'); + expect(footer.querySelector('.fa-plus-circle')).not.toBeNull(); + + await userEvent.click(footer); + expect(onClick).toHaveBeenCalledTimes(1); + }); + }); + + describe('component', () => { + test('renders a fallback modal when no renderer is registered', async () => { + const onCancel = jest.fn(); + render( + + ); + + expect(document.querySelector('.modal-title')).toHaveTextContent('Add New Entities'); + expect(screen.getByText(/not registered for/)).toHaveTextContent('modal.unregistered.MissingQuery'); + + await userEvent.click(document.querySelector('button.close')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + test('renders the registered renderer with all props passed through', () => { + const sq = new SchemaQuery('modal.registered', 'PassThrough'); + const onCancel = jest.fn(); + const onComplete = jest.fn(); + const PropCapture = jest.fn().mockReturnValue(null); + registerModalRenderer(sq, PropCapture); + + render( + + ); + + expect(PropCapture).toHaveBeenCalledTimes(1); + expect(PropCapture.mock.calls[0][0]).toEqual({ + containerFilter: Query.ContainerFilter.currentAndSubfolders, + containerPath: '/project/folder', + onCancel, + onComplete, + schemaQuery: sq, + }); + }); + + test('does not render the fallback modal when a renderer is registered', () => { + const sq = new SchemaQuery('modal.rendered', 'RendersRenderer'); + registerModalRenderer(sq, RegisteredRenderer); + + render( + + ); + + expect(document.querySelector('.registered-renderer')).toHaveTextContent(sq.toString(false)); + expect(document.querySelector('.modal-title')).toBeNull(); + }); + }); +}); From 1c1c9ff3dac26624f88e9a8fca132650d033d299 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 09:00:31 -0700 Subject: [PATCH 19/37] OptionRenderer --- .../internal/components/forms/QuerySelect.tsx | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/components/src/internal/components/forms/QuerySelect.tsx b/packages/components/src/internal/components/forms/QuerySelect.tsx index 7c83f7e151..57d51bdc06 100644 --- a/packages/components/src/internal/components/forms/QuerySelect.tsx +++ b/packages/components/src/internal/components/forms/QuerySelect.tsx @@ -89,45 +89,45 @@ const OptionRenderer: FC = props => { const { OptionComponent, label, model, value } = props; const { allResults, queryInfo } = model; - if (queryInfo && allResults.size) { - const columns = queryInfo.getLookupViewColumns(model.displayColumn); - const item = allResults.find(result => value === result.getIn([model.valueColumn, 'value'])); + if (!queryInfo || !allResults.size) { + return null; + } - if (OptionComponent) { - return ; - } + const item = allResults.find(result => value === result.getIn([model.valueColumn, 'value'])); - return ( - <> - {columns.map((column, i) => { - if (item !== undefined) { - let text = resolveDetailFieldLabel(item.get(column.name)); - if (!Utils.isString(text)) { - if (text == null) text = ''; - else if (Array.isArray(text)) text = text.join(', '); - } - - return ( -
- {columns.length > 1 && ( - {column.caption ?? column.name}: - )} - {text} -
- ); - } + if (OptionComponent) { + return ; + } - return ( -
- {label} -
- ); - })} - + if (!item) { + return ( +
+ {label} +
); } - return null; + const columns = queryInfo.getLookupViewColumns(model.displayColumn); + + return ( + <> + {columns.map(column => { + let text = resolveDetailFieldLabel(item.get(column.name)); + if (!Utils.isString(text)) { + text = Array.isArray(text) ? text.join(', ') : (text ?? ''); + } + + return ( +
+ {columns.length > 1 && ( + {column.caption ?? column.name}: + )} + {text} +
+ ); + })} + + ); }; OptionRenderer.displayName = 'OptionRenderer'; From 3f8f9cd0c1f409fb446f8402ed542b2fa6d4a11b Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 14:49:48 -0700 Subject: [PATCH 20/37] ModalFooterSlot --- packages/components/src/index.ts | 1 + .../src/internal/FormButtons.test.tsx | 69 ++++++++++++++ .../components/src/internal/FormButtons.tsx | 19 +++- .../components/src/internal/Modal.test.tsx | 81 +++++++++++++++- packages/components/src/internal/Modal.tsx | 74 +++++++++------ .../src/internal/ModalFooterSlot.test.tsx | 92 +++++++++++++++++++ .../src/internal/ModalFooterSlot.tsx | 25 +++++ .../buttons/WizardNavButtons.test.tsx | 23 ++++- .../components/buttons/WizardNavButtons.tsx | 22 +++-- .../components/forms/FormStep.test.tsx | 65 ++++++++++++- .../internal/components/forms/FormStep.tsx | 17 +++- packages/components/src/theme/form.scss | 6 ++ 12 files changed, 448 insertions(+), 46 deletions(-) create mode 100644 packages/components/src/internal/FormButtons.test.tsx create mode 100644 packages/components/src/internal/ModalFooterSlot.test.tsx create mode 100644 packages/components/src/internal/ModalFooterSlot.tsx diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index e81c4ab1ed..9fc77616fa 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1912,6 +1912,7 @@ export type { BSStyle } from './internal/dropdowns'; export type { MenuSectionItem } from './internal/DropdownSection'; export type { UseTimeout } from './internal/hooks'; export type { ModalProps } from './internal/Modal'; +export type { ModalFooterSlotElement } from './internal/ModalFooterSlot'; export type { AddEntitiesComplete, ModalRendererProps } from './internal/ModalRenderFactory'; export type { TriggerType } from './internal/OverlayTrigger'; export type { ISelectRowsResult } from './internal/query/api'; diff --git a/packages/components/src/internal/FormButtons.test.tsx b/packages/components/src/internal/FormButtons.test.tsx new file mode 100644 index 0000000000..5fc321d1d7 --- /dev/null +++ b/packages/components/src/internal/FormButtons.test.tsx @@ -0,0 +1,69 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; + +import { FormButtons } from './FormButtons'; +import { ModalFooterSlotContext } from './ModalFooterSlot'; + +describe('FormButtons', () => { + function renderButtons(): React.ReactElement { + return ( + + + + + ); + } + + test('renders inline and sticky by default', () => { + const { container } = render(renderButtons()); + const buttons = container.querySelector('.form-buttons'); + expect(buttons).not.toBeNull(); + expect(buttons.classList.contains('form-buttons--sticky')).toBe(true); + expect(buttons.querySelector('.form-buttons__left .test-cancel')).not.toBeNull(); + expect(buttons.querySelector('.form-buttons__right .test-submit')).not.toBeNull(); + }); + + test('respects sticky={false}', () => { + const { container } = render( + + + + ); + const buttons = container.querySelector('.form-buttons'); + expect(buttons).not.toBeNull(); + expect(buttons.classList.contains('form-buttons--sticky')).toBe(false); + }); + + test('portals into the footer slot element and is never sticky there', () => { + const target = document.createElement('div'); + document.body.appendChild(target); + + const { container } = render( + {renderButtons()} + ); + + expect(container.querySelector('.form-buttons')).toBeNull(); + const buttons = target.querySelector('.form-buttons'); + expect(buttons).not.toBeNull(); + expect(buttons.classList.contains('form-buttons--sticky')).toBe(false); + expect(buttons.querySelector('.test-submit')).not.toBeNull(); + + target.remove(); + }); + + test('renders nothing while the slot element has not mounted (null)', () => { + const { container } = render( + {renderButtons()} + ); + expect(container.querySelector('.form-buttons')).toBeNull(); + expect(document.querySelector('.form-buttons')).toBeNull(); + }); +}); diff --git a/packages/components/src/internal/FormButtons.tsx b/packages/components/src/internal/FormButtons.tsx index a25d8b9946..225f28863a 100644 --- a/packages/components/src/internal/FormButtons.tsx +++ b/packages/components/src/internal/FormButtons.tsx @@ -4,18 +4,31 @@ */ import classNames from 'classnames'; import React, { Children, FC, memo, PropsWithChildren } from 'react'; +import { createPortal } from 'react-dom'; + +import { useModalFooterSlot } from './ModalFooterSlot'; +import { useFormStepActive } from './components/forms/FormStep'; interface Props extends PropsWithChildren { sticky?: boolean; } export const FormButtons: FC = memo(({ children, sticky = true }) => { - const className = classNames('form-buttons', { 'form-buttons--sticky': sticky }); + // When rendered inside a Modal with "footerSlot" enabled, the buttons portal into the modal footer instead + // of rendering inline. + const slot = useModalFooterSlot(); + const stepActive = useFormStepActive() ?? true; + const inSlotMode = slot !== undefined; + const className = classNames('form-buttons', { 'form-buttons--sticky': sticky && !inSlotMode }); let cancel; let secondary; let tertiary; let submit; + if (inSlotMode && (!stepActive || slot === null)) { + return null; + } + // Note: we have to filter children via forEach because doing something like {canSubmit && } // counts as a child, even when canSubmit is false, which results in a null child. const actualChildren = []; @@ -47,7 +60,7 @@ export const FormButtons: FC = memo(({ children, sticky = true }) => { return null; } - return ( + const content = (
{cancel}
@@ -57,5 +70,7 @@ export const FormButtons: FC = memo(({ children, sticky = true }) => {
); + + return inSlotMode ? createPortal(content, slot) : content; }); FormButtons.displayName = 'FormButtons'; diff --git a/packages/components/src/internal/Modal.test.tsx b/packages/components/src/internal/Modal.test.tsx index ffd53ac144..99471ecf96 100644 --- a/packages/components/src/internal/Modal.test.tsx +++ b/packages/components/src/internal/Modal.test.tsx @@ -2,11 +2,12 @@ * 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 React from 'react'; +import React, { FC } from 'react'; import { render } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { BaseModal, Modal, ModalHeader } from './Modal'; +import { useModalFooterSlot } from './ModalFooterSlot'; describe('Modal components', () => { describe('BaseModal', () => { @@ -191,4 +192,82 @@ describe('Modal components', () => { expect(dialog.classList.contains('my-modal')).toBe(true); }); }); + + describe('Modal footerSlot', () => { + interface SlotProbeProps { + className?: string; + } + const SlotProbe: FC = ({ className = 'slot-probe' }) => { + const slot = useModalFooterSlot(); + return ( +
+ ); + }; + SlotProbe.displayName = 'SlotProbe'; + + test('renders an empty footer slot and no default footer buttons', () => { + render( + + body + + ); + const slot = document.querySelector('.modal-footer.modal-footer--slot'); + expect(slot).not.toBeNull(); + expect(slot.childNodes).toHaveLength(0); + expect(document.querySelector('.modal-buttons')).toBeNull(); + }); + + test('explicit footer takes precedence over footerSlot', () => { + render( + f} footerSlot onCancel={jest.fn()}> + body + + ); + expect(document.querySelector('.modal-footer--slot')).toBeNull(); + expect(document.querySelector('.modal-footer .custom-footer').textContent).toEqual('f'); + }); + + test('no footerSlot renders no slot element', () => { + render( + + body + + ); + expect(document.querySelector('.modal-footer--slot')).toBeNull(); + }); + + test('provides the slot element to descendants', () => { + render( + + + + ); + expect(document.querySelector('.slot-probe').getAttribute('data-slot')).toEqual('element'); + }); + + test('provides undefined when footerSlot is not enabled', () => { + render( + + + + ); + expect(document.querySelector('.slot-probe').getAttribute('data-slot')).toEqual('undefined'); + }); + + test('nested Modal resets the slot context for its own children', () => { + render( + + + + + + + ); + expect(document.querySelector('.outer-probe').getAttribute('data-slot')).toEqual('element'); + expect(document.querySelector('.inner-probe').getAttribute('data-slot')).toEqual('undefined'); + }); + }); }); diff --git a/packages/components/src/internal/Modal.tsx b/packages/components/src/internal/Modal.tsx index c8f53b383f..fed7b9f53f 100644 --- a/packages/components/src/internal/Modal.tsx +++ b/packages/components/src/internal/Modal.tsx @@ -2,13 +2,14 @@ * Copyright (c) 2024-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, memo, PropsWithChildren, ReactNode, useEffect, useRef } from 'react'; +import React, { FC, memo, PropsWithChildren, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { usePortalRef } from './hooks'; import { ModalButtons, ModalButtonsProps } from './ModalButtons'; +import { ModalFooterSlotContext } from './ModalFooterSlot'; import { Key } from '../public/useEnterEscape'; const FOCUSABLE_SELECTORS = @@ -120,13 +121,21 @@ export interface ModalProps extends BaseModalProps, ModalButtonsProps { * Partial content of the default footer rendered by the Modal. It is ignored if a "footer" is supplied. */ footerContent?: ReactNode; + /** + * When true, the Modal renders an empty footer element and provides it to descendants via + * ModalFooterSlotContext; any FormButtons rendered in the modal body will portal into it (gated on the + * active FormStep when inside one). Use this when the modal body hosts a form or wizard whose steps render + * their own buttons (e.g. via WizardNavButtons) that belong in the modal footer. Note this applies to every + * FormButtons in the body, so don't combine it with body content that renders unrelated inline FormButtons. + * An explicit "footer" takes precedence over the slot; "footerContent" and the default footer buttons are + * not rendered when the slot is enabled. + */ + footerSlot?: boolean; /** * Custom header component. When this is supplied, the default header interactions will not be rendered. * Note: You probably should not use header, instead use the other props to render the appropriate header. */ header?: ReactNode; - /** Disable rendering the footer. Defaults to true. */ - showFooter?: boolean; /** * Title passed to the default header (see ModalHeader). If a custom header is supplied, then this is ignored. */ @@ -146,42 +155,51 @@ export const Modal: FC = memo(props => { confirmingText, footer, footerContent, + footerSlot, header, isConfirming, onCancel, onCommentChange, onConfirm, requiresUserComment, - showFooter = true, title, } = props; const showHeader = !!(onCancel || title); + const [footerEl, setFooterEl] = useState(null); + const footerRef = useCallback((el: HTMLDivElement) => setFooterEl(el), []); + const slotEnabled = footerSlot && !footer; return ( - {showHeader && !header && } - {header} - -
{children}
- - {showFooter && !footer && ( - - {footerContent} - - )} - - {showFooter && footer &&
{footer}
} + {/* Always provide a value (undefined when the slot is disabled) so a nested Modal resets the + context and its own footer buttons never portal into an enclosing Modal's slot. */} + + {showHeader && !header && } + {header} + +
{children}
+ + {slotEnabled &&
} + + {!slotEnabled && !footer && ( + + {footerContent} + + )} + + {!slotEnabled && footer &&
{footer}
} + ); }); diff --git a/packages/components/src/internal/ModalFooterSlot.test.tsx b/packages/components/src/internal/ModalFooterSlot.test.tsx new file mode 100644 index 0000000000..aaace8fa38 --- /dev/null +++ b/packages/components/src/internal/ModalFooterSlot.test.tsx @@ -0,0 +1,92 @@ +/* + * 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 React, { FC, useCallback } from 'react'; +import { render } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { Modal } from './Modal'; +import { WizardNavButtons } from './components/buttons/WizardNavButtons'; +import { FormStep, withFormSteps, WithFormStepsProps } from './components/forms/FormStep'; +import { Formsy } from './components/forms/formsy'; + +const FORM_ID = 'mini-wizard-form'; + +interface MiniWizardOwnProps { + onCancel: () => void; + onFinish: () => void; + onValidSubmit: jest.Mock; +} + +type MiniWizardProps = MiniWizardOwnProps & WithFormStepsProps; + +const MiniWizardImpl: FC = ({ nextStep, onCancel, onFinish, onValidSubmit, previousStep }) => { + const handleValidSubmit = useCallback( + (model: unknown): void => { + onValidSubmit(model); + nextStep(); + }, + [nextStep, onValidSubmit] + ); + + return ( + <> + + + + + + + + + + ); +}; +MiniWizardImpl.displayName = 'MiniWizard'; + +const MiniWizard = withFormSteps(MiniWizardImpl); + +describe('ModalFooterSlot', () => { + function footerButtons(): HTMLButtonElement[] { + return Array.from(document.querySelectorAll('.modal-footer--slot .form-buttons button')); + } + + test('active wizard step buttons render in the modal footer and navigate steps', async () => { + const onCancel = jest.fn(); + const onFinish = jest.fn(); + const onValidSubmit = jest.fn(); + + render( + + + + ); + + // Step 1: buttons are in the footer slot, not in the modal body, and not sticky + expect(document.querySelectorAll('.modal-footer--slot .form-buttons')).toHaveLength(1); + expect(document.querySelector('.modal-body .form-buttons')).toBeNull(); + expect(document.querySelector('.form-buttons--sticky')).toBeNull(); + let buttons = footerButtons(); + expect(buttons.map(b => b.textContent)).toEqual(['Cancel', 'Next']); + expect(buttons[1]).toHaveAttribute('form', FORM_ID); + + // The submit button lives outside the form's DOM, but the form attribute associates it; clicking it + // must submit the Formsy form (validating the form-attribute path through Formsy's submit guard) + await userEvent.click(buttons[1]); + expect(onValidSubmit).toHaveBeenCalledTimes(1); + + // Step 2: the footer now shows only the confirmation step's buttons + expect(document.querySelectorAll('.modal-footer--slot .form-buttons')).toHaveLength(1); + buttons = footerButtons(); + expect(buttons.map(b => b.textContent)).toEqual(['Cancel', 'Back', 'Finish']); + + await userEvent.click(buttons[2]); + expect(onFinish).toHaveBeenCalledTimes(1); + + // Back returns to step 1's buttons + await userEvent.click(buttons[1]); + buttons = footerButtons(); + expect(buttons.map(b => b.textContent)).toEqual(['Cancel', 'Next']); + }); +}); diff --git a/packages/components/src/internal/ModalFooterSlot.tsx b/packages/components/src/internal/ModalFooterSlot.tsx new file mode 100644 index 0000000000..ac582623dd --- /dev/null +++ b/packages/components/src/internal/ModalFooterSlot.tsx @@ -0,0 +1,25 @@ +/* + * 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 { createContext, useContext } from 'react'; + +/** + * The footer slot element provided by a Modal rendered with "footerSlot": + * - undefined: no slot-enabled Modal is an ancestor. Consumers (e.g., FormButtons) should render inline. + * - null: a slot-enabled Modal is an ancestor, but the slot element has not mounted yet. Consumers should + * render nothing for this transient state (prevents a flash of inline buttons before the slot mounts). + * - HTMLDivElement: portal content into this element. + */ +export type ModalFooterSlotElement = HTMLDivElement | null | undefined; + +export const ModalFooterSlotContext = createContext(undefined); + +/** + * Returns the footer slot element of the nearest enclosing Modal rendered with "footerSlot", null if that + * Modal's slot has not mounted yet, or undefined when no slot-enabled Modal is an ancestor. See + * ModalFooterSlotElement for how consumers are expected to react to each state. + */ +export function useModalFooterSlot(): ModalFooterSlotElement { + return useContext(ModalFooterSlotContext); +} diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx index bb8103f1d8..3e4ecb06ca 100644 --- a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx +++ b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx @@ -9,7 +9,7 @@ import { userEvent } from '@testing-library/user-event'; import { WizardNavButtons } from './WizardNavButtons'; -describe('', () => { +describe('WizardNavButtons', () => { test('default props', () => { render(); expect(document.querySelectorAll('button').length === 2); @@ -47,6 +47,27 @@ describe('', () => { expect(document.querySelectorAll('button')[1].textContent).toEqual('My Additional Button'); }); + test('formId applies the form attribute to the next button', () => { + render(); + const buttons = document.querySelectorAll('button'); + expect(buttons[1].textContent).toEqual('Next'); + expect(buttons[1].getAttribute('form')).toEqual('my-form'); + expect(buttons[0].hasAttribute('form')).toBe(false); + }); + + test('formId applies the form attribute to the finish button', () => { + render(); + const buttons = document.querySelectorAll('button'); + expect(buttons[1].textContent).toEqual('Finish'); + expect(buttons[1].getAttribute('form')).toEqual('my-form'); + }); + + test('no form attribute when formId is omitted', () => { + render(); + const buttons = document.querySelectorAll('button'); + expect(buttons[1].hasAttribute('form')).toBe(false); + }); + test('onClick handlers', async () => { const cancelFn = jest.fn(); const prevFn = jest.fn(); diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx index c5c63077f9..242ac142cb 100644 --- a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx +++ b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx @@ -2,19 +2,20 @@ * 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, memo, PropsWithChildren } from 'react'; +import React, { FC, memo, PropsWithChildren, ReactNode } from 'react'; import { FormButtons } from '../../FormButtons'; interface Props extends PropsWithChildren { canCancel?: boolean; + cancel: () => void; + cancelText?: string; canFinish?: boolean; canNextStep?: boolean; canPreviousStep?: boolean; - cancel: () => void; - cancelText?: string; finish?: boolean; finishText?: string; + formId?: string; isFinished?: boolean; isFinishedText?: string; isFinishing?: boolean; @@ -35,6 +36,7 @@ export const WizardNavButtons: FC = memo(props => { children, finish = false, finishText = 'Finish', + formId, isFinished, isFinishedText = 'Finished', isFinishing, @@ -44,18 +46,24 @@ export const WizardNavButtons: FC = memo(props => { singularNoun, } = props; - let submitButton; + let submitButton: ReactNode; if (finish) { submitButton = ( - ); } else { submitButton = ( - ); @@ -67,7 +75,7 @@ export const WizardNavButtons: FC = memo(props => { {cancelText} {previousStep !== undefined && ( - )} diff --git a/packages/components/src/internal/components/forms/FormStep.test.tsx b/packages/components/src/internal/components/forms/FormStep.test.tsx index 0ce4b8c31f..72ca504123 100644 --- a/packages/components/src/internal/components/forms/FormStep.test.tsx +++ b/packages/components/src/internal/components/forms/FormStep.test.tsx @@ -2,14 +2,15 @@ * 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 React from 'react'; +import React, { FC } from 'react'; import { render } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; -import { FormStep, FormTabs, withFormSteps, WithFormStepsProps } from './FormStep'; +import { FormStep, FormTabs, useFormStepActive, withFormSteps, WithFormStepsProps } from './FormStep'; interface OwnProps { - step?: number; initialStep?: number; + step?: number; } type Props = OwnProps & WithFormStepsProps; @@ -41,7 +42,7 @@ const FormStepTest = withFormSteps(FormStepTestImpl, { hasDependentSteps: true, }); -describe('', () => { +describe('FormStep', () => { test('default props', () => { const { container } = render(); const tabs = container.querySelectorAll('.form-step-tab'); @@ -80,4 +81,60 @@ describe('', () => { expect(tabs2[0].classList.contains('active')).toBe(false); expect(tabs2[1].classList.contains('active')).toBe(true); }); + + describe('useFormStepActive', () => { + const ActiveProbe: FC<{ id: string }> = ({ id }) => { + const active = useFormStepActive(); + return
; + }; + ActiveProbe.displayName = 'ActiveProbe'; + + class ActiveProbeImpl extends React.Component { + render() { + return ( + <> + + + + + + + + + ); + } + } + + const ActiveProbeTest = withFormSteps(ActiveProbeImpl, { + currentStep: 1, + furthestStep: 2, + hasDependentSteps: false, + }); + + const getActive = (id: string): string => document.querySelector(`.probe-${id}`).getAttribute('data-active'); + + test('returns undefined outside of a FormStep', () => { + render(); + expect(getActive('none')).toEqual('undefined'); + }); + + test('flips as steps change, despite blocked re-renders of inactive steps', async () => { + // FormStep keeps visited steps mounted (hidden via CSS) and ActiveStep blocks re-renders of inactive + // step content via shouldComponentUpdate. FormButtons' modal footer slot behavior depends on context + // updates reaching consumers anyway, so this test pins that semantic. + const { container } = render(); + expect(getActive('1')).toEqual('true'); + expect(getActive('2')).toEqual('false'); + + const tabs = container.querySelectorAll('.form-step-tab'); + await userEvent.click(tabs[1]); + expect(getActive('1')).toEqual('false'); + expect(getActive('2')).toEqual('true'); + + await userEvent.click(tabs[0]); + expect(getActive('1')).toEqual('true'); + expect(getActive('2')).toEqual('false'); + }); + }); + }); diff --git a/packages/components/src/internal/components/forms/FormStep.tsx b/packages/components/src/internal/components/forms/FormStep.tsx index d597f6a6e1..7ab9ba9280 100644 --- a/packages/components/src/internal/components/forms/FormStep.tsx +++ b/packages/components/src/internal/components/forms/FormStep.tsx @@ -18,6 +18,15 @@ const FormStepContext = React.createContext(undefined); const FormStepContextProvider = FormStepContext.Provider; const FormStepContextConsumer = FormStepContext.Consumer; +const FormStepActiveContext = React.createContext(undefined); + +/** + * Returns whether the nearest enclosing FormStep is the active step or undefined when not rendered inside a FormStep. + */ +export function useFormStepActive(): boolean { + return useContext(FormStepActiveContext); +} + interface ActiveStepProps extends PropsWithChildren { active?: boolean; } @@ -59,9 +68,11 @@ export class FormStep extends React.Component { if (furthestStep >= stepIndex) { return ( -
- {trackActive ? {children} : children} -
+ +
+ {trackActive ? {children} : children} +
+
); } diff --git a/packages/components/src/theme/form.scss b/packages/components/src/theme/form.scss index a0afd598e6..63c3b3cc43 100644 --- a/packages/components/src/theme/form.scss +++ b/packages/components/src/theme/form.scss @@ -382,6 +382,12 @@ textarea.form-control { text-align: left; } +.modal-footer--slot:empty { + display: none; + padding: 0; + border-top: none; +} + .query-info-form .toggle-group-icon { padding-top: 7px; } From 6ac48e26ce732bf84e136538bc11821aee6dfbfd Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Mon, 20 Jul 2026 09:40:25 -0700 Subject: [PATCH 21/37] Updates --- .../src/internal/FormButtons.test.tsx | 6 +-- .../components/src/internal/Modal.test.tsx | 48 +++++++++---------- packages/components/src/internal/Modal.tsx | 11 ++--- .../src/internal/ModalRenderFactory.ts | 4 +- 4 files changed, 33 insertions(+), 36 deletions(-) diff --git a/packages/components/src/internal/FormButtons.test.tsx b/packages/components/src/internal/FormButtons.test.tsx index 5fc321d1d7..0a233544b8 100644 --- a/packages/components/src/internal/FormButtons.test.tsx +++ b/packages/components/src/internal/FormButtons.test.tsx @@ -26,7 +26,7 @@ describe('FormButtons', () => { const { container } = render(renderButtons()); const buttons = container.querySelector('.form-buttons'); expect(buttons).not.toBeNull(); - expect(buttons.classList.contains('form-buttons--sticky')).toBe(true); + expect(buttons).toHaveClass('form-buttons--sticky'); expect(buttons.querySelector('.form-buttons__left .test-cancel')).not.toBeNull(); expect(buttons.querySelector('.form-buttons__right .test-submit')).not.toBeNull(); }); @@ -39,7 +39,7 @@ describe('FormButtons', () => { ); const buttons = container.querySelector('.form-buttons'); expect(buttons).not.toBeNull(); - expect(buttons.classList.contains('form-buttons--sticky')).toBe(false); + expect(buttons).not.toHaveClass('form-buttons--sticky'); }); test('portals into the footer slot element and is never sticky there', () => { @@ -53,7 +53,7 @@ describe('FormButtons', () => { expect(container.querySelector('.form-buttons')).toBeNull(); const buttons = target.querySelector('.form-buttons'); expect(buttons).not.toBeNull(); - expect(buttons.classList.contains('form-buttons--sticky')).toBe(false); + expect(buttons).not.toHaveClass('form-buttons--sticky'); expect(buttons.querySelector('.test-submit')).not.toBeNull(); target.remove(); diff --git a/packages/components/src/internal/Modal.test.tsx b/packages/components/src/internal/Modal.test.tsx index 99471ecf96..1aab49a0d2 100644 --- a/packages/components/src/internal/Modal.test.tsx +++ b/packages/components/src/internal/Modal.test.tsx @@ -24,38 +24,37 @@ describe('Modal components', () => { const dialog = document.querySelector('.modal-dialog'); expect(dialog).not.toBeNull(); - expect(dialog.classList.contains('modal-sm')).toBe(false); - expect(dialog.classList.contains('modal-lg')).toBe(false); + expect(dialog).not.toHaveClass('modal-sm', 'modal-lg'); - expect(document.querySelector('.modal-content .inner-content').textContent).toEqual('hello'); + expect(document.querySelector('.modal-content .inner-content')).toHaveTextContent('hello'); }); test('applies bsSize="sm" class', () => { render(child); const dialog = document.querySelector('.modal-dialog'); - expect(dialog.classList.contains('modal-sm')).toBe(true); - expect(dialog.classList.contains('modal-lg')).toBe(false); + expect(dialog).toHaveClass('modal-sm'); + expect(dialog).not.toHaveClass('modal-lg'); }); test('applies bsSize="lg" class', () => { render(child); const dialog = document.querySelector('.modal-dialog'); - expect(dialog.classList.contains('modal-lg')).toBe(true); - expect(dialog.classList.contains('modal-sm')).toBe(false); + expect(dialog).toHaveClass('modal-lg'); + expect(dialog).not.toHaveClass('modal-sm'); }); test('applies custom className', () => { render(child); const dialog = document.querySelector('.modal-dialog'); - expect(dialog.classList.contains('custom-class')).toBe(true); + expect(dialog).toHaveClass('custom-class'); }); test('toggles "no-scroll" on document.body while mounted', () => { expect(document.body.classList.contains('no-scroll')).toBe(false); const { unmount } = render(child); - expect(document.body.classList.contains('no-scroll')).toBe(true); + expect(document.body).toHaveClass('no-scroll'); unmount(); - expect(document.body.classList.contains('no-scroll')).toBe(false); + expect(document.body).not.toHaveClass('no-scroll'); }); }); @@ -64,7 +63,7 @@ describe('Modal components', () => { render(); const header = document.querySelector('.modal-header'); expect(header).not.toBeNull(); - expect(header.querySelector('.modal-title').textContent).toEqual('My Title'); + expect(header.querySelector('.modal-title')).toHaveTextContent('My Title'); expect(header.querySelector('button.close')).toBeNull(); }); @@ -73,7 +72,7 @@ describe('Modal components', () => { render(); const closeBtn = document.querySelector('button.close'); expect(closeBtn).not.toBeNull(); - expect(closeBtn.querySelector('.sr-only').textContent).toEqual('Close'); + expect(closeBtn.querySelector('.sr-only')).toHaveTextContent('Close'); await userEvent.click(closeBtn); expect(onCancel).toHaveBeenCalledTimes(1); }); @@ -92,7 +91,7 @@ describe('Modal components', () => { ); const header = document.querySelector('.modal-header'); - expect(header.querySelector('.extra-child').textContent).toEqual('extra'); + expect(header.querySelector('.extra-child')).toHaveTextContent('extra'); }); }); @@ -105,14 +104,14 @@ describe('Modal components', () => { ); const body = document.querySelector('.modal-body'); expect(body).not.toBeNull(); - expect(body.querySelector('.body-child').textContent).toEqual('body content'); + expect(body.querySelector('.body-child')).toHaveTextContent('body content'); }); test('renders default ModalHeader when title or onCancel is provided and no custom header', () => { render(); const header = document.querySelector('.modal-header'); expect(header).not.toBeNull(); - expect(header.querySelector('.modal-title').textContent).toEqual('Hello'); + expect(header.querySelector('.modal-title')).toHaveTextContent('Hello'); expect(header.querySelector('button.close')).not.toBeNull(); }); @@ -129,7 +128,7 @@ describe('Modal components', () => { ); // Default ModalHeader should not render when a custom header is supplied expect(document.querySelector('.modal-header')).toBeNull(); - expect(document.querySelector('.custom-header').textContent).toEqual('custom'); + expect(document.querySelector('.custom-header')).toHaveTextContent('custom'); }); test('renders custom footer when provided and skips ModalButtons', () => { @@ -140,7 +139,7 @@ describe('Modal components', () => { ); const footer = document.querySelector('.modal-footer'); expect(footer).not.toBeNull(); - expect(footer.querySelector('.custom-footer').textContent).toEqual('f'); + expect(footer.querySelector('.custom-footer')).toHaveTextContent('f'); // ModalButtons applies the 'modal-buttons' class — should not be present expect(document.querySelector('.modal-buttons')).toBeNull(); }); @@ -178,7 +177,7 @@ describe('Modal components', () => { ); const buttons = document.querySelector('.modal-footer.modal-buttons'); expect(buttons).not.toBeNull(); - expect(buttons.querySelector('.fc').textContent).toEqual('fc'); + expect(buttons.querySelector('.fc')).toHaveTextContent('fc'); }); test('passes bsSize and className down to BaseModal', () => { @@ -188,8 +187,7 @@ describe('Modal components', () => { ); const dialog = document.querySelector('.modal-dialog'); - expect(dialog.classList.contains('modal-lg')).toBe(true); - expect(dialog.classList.contains('my-modal')).toBe(true); + expect(dialog).toHaveClass('modal-lg', 'my-modal'); }); }); @@ -227,7 +225,7 @@ describe('Modal components', () => { ); expect(document.querySelector('.modal-footer--slot')).toBeNull(); - expect(document.querySelector('.modal-footer .custom-footer').textContent).toEqual('f'); + expect(document.querySelector('.modal-footer .custom-footer')).toHaveTextContent('f'); }); test('no footerSlot renders no slot element', () => { @@ -245,7 +243,7 @@ describe('Modal components', () => { ); - expect(document.querySelector('.slot-probe').getAttribute('data-slot')).toEqual('element'); + expect(document.querySelector('.slot-probe')).toHaveAttribute('data-slot', 'element'); }); test('provides undefined when footerSlot is not enabled', () => { @@ -254,7 +252,7 @@ describe('Modal components', () => { ); - expect(document.querySelector('.slot-probe').getAttribute('data-slot')).toEqual('undefined'); + expect(document.querySelector('.slot-probe')).toHaveAttribute('data-slot', 'undefined'); }); test('nested Modal resets the slot context for its own children', () => { @@ -266,8 +264,8 @@ describe('Modal components', () => { ); - expect(document.querySelector('.outer-probe').getAttribute('data-slot')).toEqual('element'); - expect(document.querySelector('.inner-probe').getAttribute('data-slot')).toEqual('undefined'); + expect(document.querySelector('.outer-probe')).toHaveAttribute('data-slot', 'element'); + expect(document.querySelector('.inner-probe')).toHaveAttribute('data-slot', 'undefined'); }); }); }); diff --git a/packages/components/src/internal/Modal.tsx b/packages/components/src/internal/Modal.tsx index fed7b9f53f..b86cfcffce 100644 --- a/packages/components/src/internal/Modal.tsx +++ b/packages/components/src/internal/Modal.tsx @@ -123,12 +123,11 @@ export interface ModalProps extends BaseModalProps, ModalButtonsProps { footerContent?: ReactNode; /** * When true, the Modal renders an empty footer element and provides it to descendants via - * ModalFooterSlotContext; any FormButtons rendered in the modal body will portal into it (gated on the - * active FormStep when inside one). Use this when the modal body hosts a form or wizard whose steps render - * their own buttons (e.g. via WizardNavButtons) that belong in the modal footer. Note this applies to every - * FormButtons in the body, so don't combine it with body content that renders unrelated inline FormButtons. - * An explicit "footer" takes precedence over the slot; "footerContent" and the default footer buttons are - * not rendered when the slot is enabled. + * ModalFooterSlotContext. Use this when the modal body hosts a form or wizard whose steps render their own + * buttons that belong in the modal footer. Note this applies to every FormButtons in the body, so do not + * combine it with body content that renders unrelated inline FormButtons. An explicit "footer" takes precedence + * over the slot; "footerContent" and the default footer buttons are not rendered when the slot is enabled. + * Defaults to false. */ footerSlot?: boolean; /** diff --git a/packages/components/src/internal/ModalRenderFactory.ts b/packages/components/src/internal/ModalRenderFactory.ts index 6e76b302db..7b7471f939 100644 --- a/packages/components/src/internal/ModalRenderFactory.ts +++ b/packages/components/src/internal/ModalRenderFactory.ts @@ -37,8 +37,8 @@ function identifierToString(identifier: ModalRendererIdentifier): string { } /** - * Register a modal renderer for a specific SchemaQuery, or for an entire schema by passing the schema name as a - * string (e.g. "exp.data"). Registering `null` for a specific identifier explicitly opts it out, taking precedence + * Register a modal renderer for a specific SchemaQuery or for an entire schema by passing the schema name as a + * string (e.g., "exp.data"). Registering `null` for a specific identifier explicitly opts it out, taking precedence * over any schema-wide registration. */ export function registerModalRenderer( From 75ea3201f27fd98f60bcd9f9928d36d62b7f363d Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 21 Jul 2026 09:01:11 -0700 Subject: [PATCH 22/37] nits --- packages/components/src/internal/Modal.tsx | 14 ++--- .../internal/components/forms/QuerySelect.tsx | 24 ++------ .../internal/components/forms/model.test.ts | 60 +++++++++++++++++++ .../src/internal/components/forms/model.ts | 37 ++++++++++-- 4 files changed, 103 insertions(+), 32 deletions(-) diff --git a/packages/components/src/internal/Modal.tsx b/packages/components/src/internal/Modal.tsx index b86cfcffce..8df18cb7de 100644 --- a/packages/components/src/internal/Modal.tsx +++ b/packages/components/src/internal/Modal.tsx @@ -95,6 +95,7 @@ interface ModalHeaderProps extends PropsWithChildren { onCancel?: () => void; title: ReactNode; } + export const ModalHeader: FC = ({ children, onCancel, title }) => { return (
@@ -154,7 +155,7 @@ export const Modal: FC = memo(props => { confirmingText, footer, footerContent, - footerSlot, + footerSlot = false, header, isConfirming, onCancel, @@ -163,22 +164,20 @@ export const Modal: FC = memo(props => { requiresUserComment, title, } = props; - const showHeader = !!(onCancel || title); + // State (not useRef) so consumers re-render and portal in once the slot element mounts; a ref mutation wouldn't. const [footerEl, setFooterEl] = useState(null); const footerRef = useCallback((el: HTMLDivElement) => setFooterEl(el), []); const slotEnabled = footerSlot && !footer; + const showHeader = !!(onCancel || title); + return ( - {/* Always provide a value (undefined when the slot is disabled) so a nested Modal resets the - context and its own footer buttons never portal into an enclosing Modal's slot. */} - + {showHeader && !header && } {header}
{children}
- {slotEnabled &&
} - {!slotEnabled && !footer && ( = memo(props => { )} + {slotEnabled &&
} {!slotEnabled && footer &&
{footer}
} diff --git a/packages/components/src/internal/components/forms/QuerySelect.tsx b/packages/components/src/internal/components/forms/QuerySelect.tsx index 57d51bdc06..a2d0b8eb61 100644 --- a/packages/components/src/internal/components/forms/QuerySelect.tsx +++ b/packages/components/src/internal/components/forms/QuerySelect.tsx @@ -14,19 +14,19 @@ import { Row } from '../../query/selectRows'; import { QueryInfo } from '../../../public/QueryInfo'; -import { caseInsensitive, isTestEnv, joinMultiValueForExport } from '../../util/utils'; +import { isTestEnv } from '../../util/utils'; import { useModalState, useTimeout } from '../../hooks'; import { SelectInput, SelectInputChange, SelectInputOption, SelectInputProps } from './input/SelectInput'; -import { resolveDetailFieldLabel, resolveDetailFieldValue } from './utils'; +import { resolveDetailFieldLabel } from './utils'; import { fetchSearchResults, fetchSelectedValues, formatResults, formatSavedResults, + getAddedSelectionValue, initSelect, - parseRawValue, parseSelectedQuery, QuerySelectModel, saveSearchResults, @@ -404,23 +404,7 @@ export const QuerySelect: FC = memo(props => { const result = resultsMap.get(schemaQuery.getKey()); if (!model.isInit || !result?.rows?.length) return; - // For multiple, append the added values to the current selection and join them in the same manner - // as SelectInput resolves its form value upon interactive selection. - let nextValue: string | string[]; - { - const addedValues = result.rows.map(row => - resolveDetailFieldValue(caseInsensitive(row, model.valueColumn)) - ); - - if (model.multiple) { - nextValue = joinMultiValueForExport( - parseRawValue(model.rawSelectedValue, true, model.delimiter).concat(addedValues), - model.delimiter - ); - } else { - nextValue = addedValues[0]; - } - } + const nextValue = getAddedSelectionValue(model, result.rows); try { let model_: QuerySelectModel; diff --git a/packages/components/src/internal/components/forms/model.test.ts b/packages/components/src/internal/components/forms/model.test.ts index 92f020f71f..48dfeda310 100644 --- a/packages/components/src/internal/components/forms/model.test.ts +++ b/packages/components/src/internal/components/forms/model.test.ts @@ -12,10 +12,14 @@ import { SchemaQuery } from '../../../public/SchemaQuery'; import { ISelectRowsResult, selectRowsDeprecated } from '../../query/api'; +import { Row } from '../../query/selectRows'; + import { + appendMultiValues, buildValueFilter, fetchSelectedValues, findNotFoundValues, + getAddedSelectionValue, parseRawValue, parseSelectedQuery, queryColumnNames, @@ -255,6 +259,40 @@ describe('form actions', () => { }); }); + describe('appendMultiValues', () => { + test('empty existing selection', () => { + expect(appendMultiValues(undefined, [1, 2], ',')).toEqual('1,2'); + expect(appendMultiValues(null, [5], ',')).toEqual('5'); + expect(appendMultiValues('', [5], ',')).toEqual('5'); + }); + + test('appends new values', () => { + expect(appendMultiValues('1,2', [3], ',')).toEqual('1,2,3'); + expect(appendMultiValues([1, 2], [3, 4], ',')).toEqual('1,2,3,4'); + }); + + test('skips values already selected (string/number equality)', () => { + // 2 is already selected (as the string "2"), so it is not appended again + expect(appendMultiValues('1,2', [2], ',')).toEqual('1,2'); + expect(appendMultiValues('1,2', [2, 3], ',')).toEqual('1,2,3'); + expect(appendMultiValues([1, 2], [2, 3], ',')).toEqual('1,2,3'); + }); + + test('de-dupes repeats within addedValues', () => { + expect(appendMultiValues('1', [4, 4], ',')).toEqual('1,4'); + }); + + test('empty additions returns the existing selection', () => { + expect(appendMultiValues('1,2', [], ',')).toEqual('1,2'); + expect(appendMultiValues('1,2', undefined, ',')).toEqual('1,2'); + }); + + test('respects the delimiter', () => { + expect(appendMultiValues('a;b', ['c'], ';')).toEqual('a;b;c'); + expect(appendMultiValues('a;b', ['b', 'c'], ';')).toEqual('a;b;c'); + }); + }); + const loadedResults = fromJS({ '1': { RowId: { value: 1 }, Name: { value: 'Alpha' } }, '2': { RowId: { value: 2 }, Name: { value: 'Beta' } }, @@ -315,6 +353,28 @@ describe('form actions', () => { }); }); + describe('getAddedSelectionValue', () => { + const makeResponse = (values: (number | string)[]): Row[] => values.map(value => ({ RowId: { value } })); + + test('single-select returns the first added value', () => { + expect(getAddedSelectionValue(singleModel, makeResponse([7, 8]))).toEqual(7); + }); + + test('multi-select appends added values to the current selection', () => { + const model = multiModel.merge({ rawSelectedValue: '1,2' }) as QuerySelectModel; + expect(getAddedSelectionValue(model, makeResponse([3, 4]))).toEqual('1,2,3,4'); + }); + + test('multi-select skips added values already selected', () => { + const model = multiModel.merge({ rawSelectedValue: '1,2' }) as QuerySelectModel; + expect(getAddedSelectionValue(model, makeResponse([2, 3]))).toEqual('1,2,3'); + }); + + test('multi-select with no current selection joins the added values', () => { + expect(getAddedSelectionValue(multiModel, makeResponse([3, 4]))).toEqual('3,4'); + }); + }); + describe('setSelection', () => { test('resolves single value across types', () => { const model = setSelection(singleModel, '2'); diff --git a/packages/components/src/internal/components/forms/model.ts b/packages/components/src/internal/components/forms/model.ts index a895cf5073..22653bc518 100644 --- a/packages/components/src/internal/components/forms/model.ts +++ b/packages/components/src/internal/components/forms/model.ts @@ -11,7 +11,7 @@ import { SchemaQuery } from '../../../public/SchemaQuery'; import { getQueryDetails, ISelectRowsResult, searchRows, selectRowsDeprecated } from '../../query/api'; import { similaritySortFactory } from '../../util/similaritySortFactory'; -import { caseInsensitive, splitMultiValueForImport } from '../../util/utils'; +import { caseInsensitive, joinMultiValueForExport, splitMultiValueForImport } from '../../util/utils'; import { naturalSort } from '../../../public/sort'; @@ -130,10 +130,7 @@ export function saveSearchResults(model: QuerySelectModel, result: ISelectRowsRe }) as QuerySelectModel; } -/** - * Normalizes a raw selection value into an array of values. Handles arrays, Immutable Lists, - * delimited strings (when "multiple" is enabled), and scalar values. - */ +/** Normalizes a raw selection value into an array of values. */ export function parseRawValue(value: any, multiple: boolean, delimiter: string): any[] { if (!validValue(value)) return []; if (Array.isArray(value)) return value; @@ -142,6 +139,36 @@ export function parseRawValue(value: any, multiple: boolean, delimiter: string): return [value]; } +/** + * Appends added value(s) to a raw multi-value selection, skipping values already present and de-duping repeats. + * Returns the joined delimited value. + */ +export function appendMultiValues(rawSelectedValue: any, addedValues: any[], delimiter: string): string { + const existing = parseRawValue(rawSelectedValue, true, delimiter); + const seen = new Set(existing.map(v => v?.toString())); + const additions = (addedValues ?? []).filter(v => { + const key = v?.toString(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return joinMultiValueForExport(existing.concat(additions), delimiter); +} + +/** + * Resolves the selection value to apply after entities are added: for multi-select, the added rows' values + * appended to the current selection; for single-select, the first added row's value. + */ +export function getAddedSelectionValue(model: QuerySelectModel, rows: Row[]): string | string[] { + const addedValues = rows.map(row => resolveDetailFieldValue(caseInsensitive(row, model.valueColumn))); + + if (model.multiple) { + return appendMultiValues(model.rawSelectedValue, addedValues, model.delimiter); + } + + return addedValues[0]; +} + function getSelectedOptions(model: QuerySelectModel, value: any): Map { // if no "value", just return currently selectedItems if (value === undefined || value === null || value === '') { From 4f8d8e05716597407aefcaf763bac59818d76a48 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 21 Jul 2026 14:39:04 -0700 Subject: [PATCH 23/37] useIsInModal, FORM_INSERT_MODAL --- packages/components/src/index.ts | 2 ++ .../components/forms/AddEntitiesModal.tsx | 21 ++++++++++++++----- packages/components/src/internal/constants.ts | 1 + 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 9fc77616fa..f131044585 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -877,6 +877,7 @@ import { PRIVATE_PICKLIST_CATEGORY, PUBLIC_PICKLIST_CATEGORY } from './internal/ import { getDefaultAPIWrapper, getTestAPIWrapper } from './internal/APIWrapper'; import { FormButtons } from './internal/FormButtons'; import { registerModalRenderer } from './internal/ModalRenderFactory'; +import { useIsInModal } from './internal/components/forms/AddEntitiesModal'; import { ModalButtons } from './internal/ModalButtons'; import { getSecurityTestAPIWrapper } from './internal/components/security/APIWrapper'; import { getFolderTestAPIWrapper } from './internal/components/container/FolderAPIWrapper'; @@ -1765,6 +1766,7 @@ export { useDataChangeCommentsRequired, useEnterEscape, useFolderMenuContext, + useIsInModal, useLabelPrintingContext, useLoadableState, useModalState, diff --git a/packages/components/src/internal/components/forms/AddEntitiesModal.tsx b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx index 70615b2238..5676ea9d16 100644 --- a/packages/components/src/internal/components/forms/AddEntitiesModal.tsx +++ b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx @@ -7,20 +7,30 @@ import { SchemaQuery } from '../../../public/SchemaQuery'; import { Modal } from '../../Modal'; import { ModalRendererProps, resolveModalRenderer } from '../../ModalRenderFactory'; -const AddEntitiesModalContext = createContext(true); +export type AddEntitiesModalContext = { + addEntitiesEnabled: boolean; + inModal: boolean; +}; + +const AddEntitiesModalContext = createContext({ addEntitiesEnabled: true, inModal: false }); export function useIsAddEntitiesEnabled(schemaQuery: SchemaQuery): boolean { - const addEntitiesEnabled = useContext(AddEntitiesModalContext); + const ctx = useContext(AddEntitiesModalContext); return useMemo( // If the context it not available/rendered, then default to true () => schemaQuery !== undefined && - (addEntitiesEnabled ?? true) && + (ctx?.addEntitiesEnabled ?? true) && resolveModalRenderer(schemaQuery) !== undefined, - [addEntitiesEnabled, schemaQuery] + [ctx, schemaQuery] ); } +export function useIsInModal(): boolean { + const ctx = useContext(AddEntitiesModalContext); + return !!ctx?.inModal; +} + interface AddEntitiesMenuFooterProps { onClick: () => void; } @@ -36,6 +46,7 @@ AddEntitiesFooter.displayName = 'AddEntitiesFooter'; export const AddEntitiesModal: FC = props => { const { containerFilter, containerPath, onCancel, onComplete, schemaQuery } = props; const ModalRenderer = useMemo(() => resolveModalRenderer(schemaQuery), [schemaQuery]); + const value = useMemo(() => ({ addEntitiesEnabled: false, inModal: true }), []); if (!ModalRenderer) { return ( @@ -46,7 +57,7 @@ export const AddEntitiesModal: FC = props => { } return ( - + {/* eslint-disable-next-line react-hooks/static-components */} Date: Wed, 22 Jul 2026 09:19:44 -0700 Subject: [PATCH 24/37] 7.48.1-fb-add-entity-modal.0 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 0f7997c61d..0ee9becb06 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.0", + "version": "7.48.1-fb-add-entity-modal.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.0", + "version": "7.48.1-fb-add-entity-modal.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 49fcfc1c63..c2bb24d530 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.0", + "version": "7.48.1-fb-add-entity-modal.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 565fb5f850c6f7dead5be10cd8a20a39fe11687b Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 22 Jul 2026 14:42:56 -0700 Subject: [PATCH 25/37] Use sticky --- packages/components/src/index.ts | 1 - .../src/internal/FormButtons.test.tsx | 26 ------ .../components/src/internal/FormButtons.tsx | 19 +--- .../components/src/internal/Modal.test.tsx | 81 +--------------- packages/components/src/internal/Modal.tsx | 73 ++++++--------- .../src/internal/ModalFooterSlot.test.tsx | 92 ------------------- .../src/internal/ModalFooterSlot.tsx | 25 ----- .../buttons/WizardNavButtons.test.tsx | 74 +++++++++++---- .../components/buttons/WizardNavButtons.tsx | 64 +++++++------ .../components/forms/FormStep.test.tsx | 61 +----------- .../internal/components/forms/FormStep.tsx | 17 +--- packages/components/src/theme/modal.scss | 4 + 12 files changed, 133 insertions(+), 404 deletions(-) delete mode 100644 packages/components/src/internal/ModalFooterSlot.test.tsx delete mode 100644 packages/components/src/internal/ModalFooterSlot.tsx diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index f131044585..8801a78b84 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1914,7 +1914,6 @@ export type { BSStyle } from './internal/dropdowns'; export type { MenuSectionItem } from './internal/DropdownSection'; export type { UseTimeout } from './internal/hooks'; export type { ModalProps } from './internal/Modal'; -export type { ModalFooterSlotElement } from './internal/ModalFooterSlot'; export type { AddEntitiesComplete, ModalRendererProps } from './internal/ModalRenderFactory'; export type { TriggerType } from './internal/OverlayTrigger'; export type { ISelectRowsResult } from './internal/query/api'; diff --git a/packages/components/src/internal/FormButtons.test.tsx b/packages/components/src/internal/FormButtons.test.tsx index 0a233544b8..6813026c92 100644 --- a/packages/components/src/internal/FormButtons.test.tsx +++ b/packages/components/src/internal/FormButtons.test.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { render } from '@testing-library/react'; import { FormButtons } from './FormButtons'; -import { ModalFooterSlotContext } from './ModalFooterSlot'; describe('FormButtons', () => { function renderButtons(): React.ReactElement { @@ -41,29 +40,4 @@ describe('FormButtons', () => { expect(buttons).not.toBeNull(); expect(buttons).not.toHaveClass('form-buttons--sticky'); }); - - test('portals into the footer slot element and is never sticky there', () => { - const target = document.createElement('div'); - document.body.appendChild(target); - - const { container } = render( - {renderButtons()} - ); - - expect(container.querySelector('.form-buttons')).toBeNull(); - const buttons = target.querySelector('.form-buttons'); - expect(buttons).not.toBeNull(); - expect(buttons).not.toHaveClass('form-buttons--sticky'); - expect(buttons.querySelector('.test-submit')).not.toBeNull(); - - target.remove(); - }); - - test('renders nothing while the slot element has not mounted (null)', () => { - const { container } = render( - {renderButtons()} - ); - expect(container.querySelector('.form-buttons')).toBeNull(); - expect(document.querySelector('.form-buttons')).toBeNull(); - }); }); diff --git a/packages/components/src/internal/FormButtons.tsx b/packages/components/src/internal/FormButtons.tsx index 225f28863a..a25d8b9946 100644 --- a/packages/components/src/internal/FormButtons.tsx +++ b/packages/components/src/internal/FormButtons.tsx @@ -4,31 +4,18 @@ */ import classNames from 'classnames'; import React, { Children, FC, memo, PropsWithChildren } from 'react'; -import { createPortal } from 'react-dom'; - -import { useModalFooterSlot } from './ModalFooterSlot'; -import { useFormStepActive } from './components/forms/FormStep'; interface Props extends PropsWithChildren { sticky?: boolean; } export const FormButtons: FC = memo(({ children, sticky = true }) => { - // When rendered inside a Modal with "footerSlot" enabled, the buttons portal into the modal footer instead - // of rendering inline. - const slot = useModalFooterSlot(); - const stepActive = useFormStepActive() ?? true; - const inSlotMode = slot !== undefined; - const className = classNames('form-buttons', { 'form-buttons--sticky': sticky && !inSlotMode }); + const className = classNames('form-buttons', { 'form-buttons--sticky': sticky }); let cancel; let secondary; let tertiary; let submit; - if (inSlotMode && (!stepActive || slot === null)) { - return null; - } - // Note: we have to filter children via forEach because doing something like {canSubmit && } // counts as a child, even when canSubmit is false, which results in a null child. const actualChildren = []; @@ -60,7 +47,7 @@ export const FormButtons: FC = memo(({ children, sticky = true }) => { return null; } - const content = ( + return (
{cancel}
@@ -70,7 +57,5 @@ export const FormButtons: FC = memo(({ children, sticky = true }) => {
); - - return inSlotMode ? createPortal(content, slot) : content; }); FormButtons.displayName = 'FormButtons'; diff --git a/packages/components/src/internal/Modal.test.tsx b/packages/components/src/internal/Modal.test.tsx index 1aab49a0d2..fa323a2a6b 100644 --- a/packages/components/src/internal/Modal.test.tsx +++ b/packages/components/src/internal/Modal.test.tsx @@ -2,12 +2,11 @@ * 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 React, { FC } from 'react'; +import React from 'react'; import { render } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { BaseModal, Modal, ModalHeader } from './Modal'; -import { useModalFooterSlot } from './ModalFooterSlot'; describe('Modal components', () => { describe('BaseModal', () => { @@ -190,82 +189,4 @@ describe('Modal components', () => { expect(dialog).toHaveClass('modal-lg', 'my-modal'); }); }); - - describe('Modal footerSlot', () => { - interface SlotProbeProps { - className?: string; - } - const SlotProbe: FC = ({ className = 'slot-probe' }) => { - const slot = useModalFooterSlot(); - return ( -
- ); - }; - SlotProbe.displayName = 'SlotProbe'; - - test('renders an empty footer slot and no default footer buttons', () => { - render( - - body - - ); - const slot = document.querySelector('.modal-footer.modal-footer--slot'); - expect(slot).not.toBeNull(); - expect(slot.childNodes).toHaveLength(0); - expect(document.querySelector('.modal-buttons')).toBeNull(); - }); - - test('explicit footer takes precedence over footerSlot', () => { - render( - f} footerSlot onCancel={jest.fn()}> - body - - ); - expect(document.querySelector('.modal-footer--slot')).toBeNull(); - expect(document.querySelector('.modal-footer .custom-footer')).toHaveTextContent('f'); - }); - - test('no footerSlot renders no slot element', () => { - render( - - body - - ); - expect(document.querySelector('.modal-footer--slot')).toBeNull(); - }); - - test('provides the slot element to descendants', () => { - render( - - - - ); - expect(document.querySelector('.slot-probe')).toHaveAttribute('data-slot', 'element'); - }); - - test('provides undefined when footerSlot is not enabled', () => { - render( - - - - ); - expect(document.querySelector('.slot-probe')).toHaveAttribute('data-slot', 'undefined'); - }); - - test('nested Modal resets the slot context for its own children', () => { - render( - - - - - - - ); - expect(document.querySelector('.outer-probe')).toHaveAttribute('data-slot', 'element'); - expect(document.querySelector('.inner-probe')).toHaveAttribute('data-slot', 'undefined'); - }); - }); }); diff --git a/packages/components/src/internal/Modal.tsx b/packages/components/src/internal/Modal.tsx index 8df18cb7de..d6a9e73955 100644 --- a/packages/components/src/internal/Modal.tsx +++ b/packages/components/src/internal/Modal.tsx @@ -2,14 +2,13 @@ * Copyright (c) 2024-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, memo, PropsWithChildren, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import React, { FC, memo, PropsWithChildren, ReactNode, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { usePortalRef } from './hooks'; import { ModalButtons, ModalButtonsProps } from './ModalButtons'; -import { ModalFooterSlotContext } from './ModalFooterSlot'; import { Key } from '../public/useEnterEscape'; const FOCUSABLE_SELECTORS = @@ -122,20 +121,15 @@ export interface ModalProps extends BaseModalProps, ModalButtonsProps { * Partial content of the default footer rendered by the Modal. It is ignored if a "footer" is supplied. */ footerContent?: ReactNode; - /** - * When true, the Modal renders an empty footer element and provides it to descendants via - * ModalFooterSlotContext. Use this when the modal body hosts a form or wizard whose steps render their own - * buttons that belong in the modal footer. Note this applies to every FormButtons in the body, so do not - * combine it with body content that renders unrelated inline FormButtons. An explicit "footer" takes precedence - * over the slot; "footerContent" and the default footer buttons are not rendered when the slot is enabled. - * Defaults to false. - */ - footerSlot?: boolean; /** * Custom header component. When this is supplied, the default header interactions will not be rendered. * Note: You probably should not use header, instead use the other props to render the appropriate header. */ header?: ReactNode; + /** + * Declare whether to render a footer. Overrides both "footer" and "footerContent". Defaults to true. + */ + showFooter?: boolean; /** * Title passed to the default header (see ModalHeader). If a custom header is supplied, then this is ignored. */ @@ -155,50 +149,43 @@ export const Modal: FC = memo(props => { confirmingText, footer, footerContent, - footerSlot = false, header, isConfirming, onCancel, onCommentChange, onConfirm, requiresUserComment, + showFooter = true, title, } = props; - // State (not useRef) so consumers re-render and portal in once the slot element mounts; a ref mutation wouldn't. - const [footerEl, setFooterEl] = useState(null); - const footerRef = useCallback((el: HTMLDivElement) => setFooterEl(el), []); - const slotEnabled = footerSlot && !footer; const showHeader = !!(onCancel || title); return ( - - {showHeader && !header && } - {header} - -
{children}
- - {!slotEnabled && !footer && ( - - {footerContent} - - )} - - {slotEnabled &&
} - {!slotEnabled && footer &&
{footer}
} - + {showHeader && !header && } + {header} + +
{children}
+ + {showFooter && !footer && ( + + {footerContent} + + )} + + {showFooter && footer &&
{footer}
} ); }); diff --git a/packages/components/src/internal/ModalFooterSlot.test.tsx b/packages/components/src/internal/ModalFooterSlot.test.tsx deleted file mode 100644 index aaace8fa38..0000000000 --- a/packages/components/src/internal/ModalFooterSlot.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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 React, { FC, useCallback } from 'react'; -import { render } from '@testing-library/react'; -import { userEvent } from '@testing-library/user-event'; - -import { Modal } from './Modal'; -import { WizardNavButtons } from './components/buttons/WizardNavButtons'; -import { FormStep, withFormSteps, WithFormStepsProps } from './components/forms/FormStep'; -import { Formsy } from './components/forms/formsy'; - -const FORM_ID = 'mini-wizard-form'; - -interface MiniWizardOwnProps { - onCancel: () => void; - onFinish: () => void; - onValidSubmit: jest.Mock; -} - -type MiniWizardProps = MiniWizardOwnProps & WithFormStepsProps; - -const MiniWizardImpl: FC = ({ nextStep, onCancel, onFinish, onValidSubmit, previousStep }) => { - const handleValidSubmit = useCallback( - (model: unknown): void => { - onValidSubmit(model); - nextStep(); - }, - [nextStep, onValidSubmit] - ); - - return ( - <> - - - - - - - - - - ); -}; -MiniWizardImpl.displayName = 'MiniWizard'; - -const MiniWizard = withFormSteps(MiniWizardImpl); - -describe('ModalFooterSlot', () => { - function footerButtons(): HTMLButtonElement[] { - return Array.from(document.querySelectorAll('.modal-footer--slot .form-buttons button')); - } - - test('active wizard step buttons render in the modal footer and navigate steps', async () => { - const onCancel = jest.fn(); - const onFinish = jest.fn(); - const onValidSubmit = jest.fn(); - - render( - - - - ); - - // Step 1: buttons are in the footer slot, not in the modal body, and not sticky - expect(document.querySelectorAll('.modal-footer--slot .form-buttons')).toHaveLength(1); - expect(document.querySelector('.modal-body .form-buttons')).toBeNull(); - expect(document.querySelector('.form-buttons--sticky')).toBeNull(); - let buttons = footerButtons(); - expect(buttons.map(b => b.textContent)).toEqual(['Cancel', 'Next']); - expect(buttons[1]).toHaveAttribute('form', FORM_ID); - - // The submit button lives outside the form's DOM, but the form attribute associates it; clicking it - // must submit the Formsy form (validating the form-attribute path through Formsy's submit guard) - await userEvent.click(buttons[1]); - expect(onValidSubmit).toHaveBeenCalledTimes(1); - - // Step 2: the footer now shows only the confirmation step's buttons - expect(document.querySelectorAll('.modal-footer--slot .form-buttons')).toHaveLength(1); - buttons = footerButtons(); - expect(buttons.map(b => b.textContent)).toEqual(['Cancel', 'Back', 'Finish']); - - await userEvent.click(buttons[2]); - expect(onFinish).toHaveBeenCalledTimes(1); - - // Back returns to step 1's buttons - await userEvent.click(buttons[1]); - buttons = footerButtons(); - expect(buttons.map(b => b.textContent)).toEqual(['Cancel', 'Next']); - }); -}); diff --git a/packages/components/src/internal/ModalFooterSlot.tsx b/packages/components/src/internal/ModalFooterSlot.tsx deleted file mode 100644 index ac582623dd..0000000000 --- a/packages/components/src/internal/ModalFooterSlot.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 { createContext, useContext } from 'react'; - -/** - * The footer slot element provided by a Modal rendered with "footerSlot": - * - undefined: no slot-enabled Modal is an ancestor. Consumers (e.g., FormButtons) should render inline. - * - null: a slot-enabled Modal is an ancestor, but the slot element has not mounted yet. Consumers should - * render nothing for this transient state (prevents a flash of inline buttons before the slot mounts). - * - HTMLDivElement: portal content into this element. - */ -export type ModalFooterSlotElement = HTMLDivElement | null | undefined; - -export const ModalFooterSlotContext = createContext(undefined); - -/** - * Returns the footer slot element of the nearest enclosing Modal rendered with "footerSlot", null if that - * Modal's slot has not mounted yet, or undefined when no slot-enabled Modal is an ancestor. See - * ModalFooterSlotElement for how consumers are expected to react to each state. - */ -export function useModalFooterSlot(): ModalFooterSlotElement { - return useContext(ModalFooterSlotContext); -} diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx index 3e4ecb06ca..69e315682b 100644 --- a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx +++ b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx @@ -7,31 +7,43 @@ import React from 'react'; import { render } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { useIsInModal } from '../forms/AddEntitiesModal'; + import { WizardNavButtons } from './WizardNavButtons'; +jest.mock('../forms/AddEntitiesModal', () => ({ + ...jest.requireActual('../forms/AddEntitiesModal'), + useIsInModal: jest.fn(), +})); + +const mockUseIsInModal = useIsInModal as jest.MockedFunction; + describe('WizardNavButtons', () => { + beforeEach(() => { + mockUseIsInModal.mockReturnValue(false); + }); test('default props', () => { render(); - expect(document.querySelectorAll('button').length === 2); - expect(document.querySelectorAll('button')[0].textContent).toEqual('Cancel'); - expect(document.querySelectorAll('button')[1].textContent).toEqual('Next'); - expect(document.querySelectorAll('button')[1].hasAttribute('disabled')).toEqual(false); + expect(document.querySelectorAll('button')).toHaveLength(2); + expect(document.querySelectorAll('button')[0]).toHaveTextContent('Cancel'); + expect(document.querySelectorAll('button')[1]).toHaveTextContent('Next'); + expect(document.querySelectorAll('button')[1]).not.toBeDisabled(); }); test('finish props', () => { render( ); - expect(document.querySelectorAll('button').length).toEqual(2); - expect(document.querySelectorAll('button')[0].textContent).toEqual('Cancel'); - expect(document.querySelectorAll('button')[1].textContent).toEqual('Custom Finish'); - expect(document.querySelectorAll('button')[1].hasAttribute('disabled')).toEqual(true); + expect(document.querySelectorAll('button')).toHaveLength(2); + expect(document.querySelectorAll('button')[0]).toHaveTextContent('Cancel'); + expect(document.querySelectorAll('button')[1]).toHaveTextContent('Custom Finish'); + expect(document.querySelectorAll('button')[1]).toBeDisabled(); }); test('with children', () => { @@ -42,37 +54,37 @@ describe('WizardNavButtons', () => { ); - expect(document.querySelectorAll('button').length).toEqual(3); - expect(document.querySelectorAll('button')[0].textContent).toEqual('Cancel'); - expect(document.querySelectorAll('button')[1].textContent).toEqual('My Additional Button'); + expect(document.querySelectorAll('button')).toHaveLength(3); + expect(document.querySelectorAll('button')[0]).toHaveTextContent('Cancel'); + expect(document.querySelectorAll('button')[1]).toHaveTextContent('My Additional Button'); }); test('formId applies the form attribute to the next button', () => { render(); const buttons = document.querySelectorAll('button'); - expect(buttons[1].textContent).toEqual('Next'); - expect(buttons[1].getAttribute('form')).toEqual('my-form'); - expect(buttons[0].hasAttribute('form')).toBe(false); + expect(buttons[1]).toHaveTextContent('Next'); + expect(buttons[1]).toHaveAttribute('form', 'my-form'); + expect(buttons[0]).not.toHaveAttribute('form'); }); test('formId applies the form attribute to the finish button', () => { render(); const buttons = document.querySelectorAll('button'); - expect(buttons[1].textContent).toEqual('Finish'); - expect(buttons[1].getAttribute('form')).toEqual('my-form'); + expect(buttons[1]).toHaveTextContent('Finish'); + expect(buttons[1]).toHaveAttribute('form', 'my-form'); }); test('no form attribute when formId is omitted', () => { render(); const buttons = document.querySelectorAll('button'); - expect(buttons[1].hasAttribute('form')).toBe(false); + expect(buttons[1]).not.toHaveAttribute('form'); }); test('onClick handlers', async () => { const cancelFn = jest.fn(); const prevFn = jest.fn(); const nextFn = jest.fn(); - render(); + render(); expect(cancelFn).toHaveBeenCalledTimes(0); expect(prevFn).toHaveBeenCalledTimes(0); expect(nextFn).toHaveBeenCalledTimes(0); @@ -92,4 +104,26 @@ describe('WizardNavButtons', () => { expect(prevFn).toHaveBeenCalledTimes(1); expect(nextFn).toHaveBeenCalledTimes(1); }); + + test('respects useIsInModal', () => { + // When not in a modal, the buttons render in sticky mode without the modal footer wrapper. + const { rerender } = render(); + expect(document.querySelector('.modal-footer')).toBeNull(); + expect(document.querySelector('.form-buttons--sticky')).not.toBeNull(); + + // When in a modal, the buttons are wrapped in a modal footer element and are no longer sticky. + mockUseIsInModal.mockReturnValue(true); + rerender(); + const footer = document.querySelector('.modal-footer'); + expect(footer).not.toBeNull(); + expect(footer).toHaveClass('modal-buttons', 'modal-footer-in-body'); + expect(document.querySelector('.form-buttons--sticky')).toBeNull(); + expect(footer.querySelector('.form-buttons')).not.toBeNull(); + + // The nav buttons are still rendered inside the modal footer. + const buttons = footer.querySelectorAll('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent('Cancel'); + expect(buttons[1]).toHaveTextContent('Next'); + }); }); diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx index 242ac142cb..3507f12d09 100644 --- a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx +++ b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx @@ -2,9 +2,10 @@ * 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, memo, PropsWithChildren, ReactNode } from 'react'; +import React, { FC, memo, PropsWithChildren } from 'react'; import { FormButtons } from '../../FormButtons'; +import { useIsInModal } from '../forms/AddEntitiesModal'; interface Props extends PropsWithChildren { canCancel?: boolean; @@ -45,32 +46,10 @@ export const WizardNavButtons: FC = memo(props => { previousStep, singularNoun, } = props; + const inModal = useIsInModal(); - let submitButton: ReactNode; - - if (finish) { - submitButton = ( - - ); - } else { - submitButton = ( - - ); - } - - return ( - + const formButtons = ( + @@ -80,8 +59,39 @@ export const WizardNavButtons: FC = memo(props => { )} {children} - {submitButton} + {finish && ( + + )} + {!finish && ( + + )} ); + + if (inModal) { + // This is not ideal as this can result in a "modal-footer" inside a "modal-body", however, it is much + // less complicated than rendering to a React.createPortal(). Apply the "modal-footer-in-body" class to + // adjust the layout to align with the body. + return
{formButtons}
; + } + + return formButtons; }); WizardNavButtons.displayName = 'WizardNavButtons'; diff --git a/packages/components/src/internal/components/forms/FormStep.test.tsx b/packages/components/src/internal/components/forms/FormStep.test.tsx index 72ca504123..328aef10d7 100644 --- a/packages/components/src/internal/components/forms/FormStep.test.tsx +++ b/packages/components/src/internal/components/forms/FormStep.test.tsx @@ -2,11 +2,10 @@ * 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 React, { FC } from 'react'; +import React from 'react'; import { render } from '@testing-library/react'; -import { userEvent } from '@testing-library/user-event'; -import { FormStep, FormTabs, useFormStepActive, withFormSteps, WithFormStepsProps } from './FormStep'; +import { FormStep, FormTabs, withFormSteps, WithFormStepsProps } from './FormStep'; interface OwnProps { initialStep?: number; @@ -81,60 +80,4 @@ describe('FormStep', () => { expect(tabs2[0].classList.contains('active')).toBe(false); expect(tabs2[1].classList.contains('active')).toBe(true); }); - - describe('useFormStepActive', () => { - const ActiveProbe: FC<{ id: string }> = ({ id }) => { - const active = useFormStepActive(); - return
; - }; - ActiveProbe.displayName = 'ActiveProbe'; - - class ActiveProbeImpl extends React.Component { - render() { - return ( - <> - - - - - - - - - ); - } - } - - const ActiveProbeTest = withFormSteps(ActiveProbeImpl, { - currentStep: 1, - furthestStep: 2, - hasDependentSteps: false, - }); - - const getActive = (id: string): string => document.querySelector(`.probe-${id}`).getAttribute('data-active'); - - test('returns undefined outside of a FormStep', () => { - render(); - expect(getActive('none')).toEqual('undefined'); - }); - - test('flips as steps change, despite blocked re-renders of inactive steps', async () => { - // FormStep keeps visited steps mounted (hidden via CSS) and ActiveStep blocks re-renders of inactive - // step content via shouldComponentUpdate. FormButtons' modal footer slot behavior depends on context - // updates reaching consumers anyway, so this test pins that semantic. - const { container } = render(); - expect(getActive('1')).toEqual('true'); - expect(getActive('2')).toEqual('false'); - - const tabs = container.querySelectorAll('.form-step-tab'); - await userEvent.click(tabs[1]); - expect(getActive('1')).toEqual('false'); - expect(getActive('2')).toEqual('true'); - - await userEvent.click(tabs[0]); - expect(getActive('1')).toEqual('true'); - expect(getActive('2')).toEqual('false'); - }); - }); - }); diff --git a/packages/components/src/internal/components/forms/FormStep.tsx b/packages/components/src/internal/components/forms/FormStep.tsx index 7ab9ba9280..d597f6a6e1 100644 --- a/packages/components/src/internal/components/forms/FormStep.tsx +++ b/packages/components/src/internal/components/forms/FormStep.tsx @@ -18,15 +18,6 @@ const FormStepContext = React.createContext(undefined); const FormStepContextProvider = FormStepContext.Provider; const FormStepContextConsumer = FormStepContext.Consumer; -const FormStepActiveContext = React.createContext(undefined); - -/** - * Returns whether the nearest enclosing FormStep is the active step or undefined when not rendered inside a FormStep. - */ -export function useFormStepActive(): boolean { - return useContext(FormStepActiveContext); -} - interface ActiveStepProps extends PropsWithChildren { active?: boolean; } @@ -68,11 +59,9 @@ export class FormStep extends React.Component { if (furthestStep >= stepIndex) { return ( - -
- {trackActive ? {children} : children} -
-
+
+ {trackActive ? {children} : children} +
); } diff --git a/packages/components/src/theme/modal.scss b/packages/components/src/theme/modal.scss index 52b36e120c..09855da73c 100644 --- a/packages/components/src/theme/modal.scss +++ b/packages/components/src/theme/modal.scss @@ -38,3 +38,7 @@ font-size: 18px; font-weight: 700; } + +.modal-footer-in-body { + margin: 5px -15px -15px -15px; +} From 12849492fc4950d5ed786e74c5e668b4bdc9ddca Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 22 Jul 2026 14:43:08 -0700 Subject: [PATCH 26/37] 7.48.1-fb-add-entity-modal.1 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 0ee9becb06..1a8c827479 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-add-entity-modal.0", + "version": "7.48.1-fb-add-entity-modal.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.1-fb-add-entity-modal.0", + "version": "7.48.1-fb-add-entity-modal.1", "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 c2bb24d530..19190ca7c4 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-add-entity-modal.0", + "version": "7.48.1-fb-add-entity-modal.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From b52230a797471776d2f938b7061d79cb58c4b879 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 22 Jul 2026 15:15:36 -0700 Subject: [PATCH 27/37] ModalFooterContext --- packages/components/src/index.ts | 3 ++ .../src/internal/ModalFooterContext.tsx | 17 +++++++++ .../buttons/WizardNavButtons.test.tsx | 35 +++++++++---------- .../components/buttons/WizardNavButtons.tsx | 16 ++++----- packages/components/src/theme/modal.scss | 4 --- 5 files changed, 44 insertions(+), 31 deletions(-) create mode 100644 packages/components/src/internal/ModalFooterContext.tsx diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 8801a78b84..09f021cea6 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -894,6 +894,7 @@ import { LineageGridModel, LineageResult } from './internal/components/lineage/m import { ActiveUserLimit, ActiveUserLimitMessage } from './internal/components/settings/ActiveUserLimit'; import { NameIdSettings } from './internal/components/settings/NameIdSettings'; import { BaseModal, Modal, ModalHeader } from './internal/Modal'; +import { ModalFooterContext, useModalFooter } from './internal/ModalFooterContext'; import { Tab, Tabs } from './internal/Tabs'; import { CheckboxLK } from './internal/Checkbox'; import { ArchivedFolderTag } from './internal/components/folder/ArchivedFolderTag'; @@ -1561,6 +1562,7 @@ export { MessageLevel, Modal, ModalButtons, + ModalFooterContext, ModalHeader, MultiValueRenderer, NameIdSettings, @@ -1767,6 +1769,7 @@ export { useEnterEscape, useFolderMenuContext, useIsInModal, + useModalFooter, useLabelPrintingContext, useLoadableState, useModalState, diff --git a/packages/components/src/internal/ModalFooterContext.tsx b/packages/components/src/internal/ModalFooterContext.tsx new file mode 100644 index 0000000000..924673b22a --- /dev/null +++ b/packages/components/src/internal/ModalFooterContext.tsx @@ -0,0 +1,17 @@ +/* + * 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 { createContext, useContext } from 'react'; + +/** + * Context that exposes the DOM node of a modal footer so that buttons rendered deep within the modal body (e.g. + * WizardNavButtons rendered by an individual wizard step) can be portaled into the actual footer element. This keeps + * the footer a true sibling of the body per Bootstrap layout, rather than rendering a "modal-footer" inside the + * "modal-body". A null value means there is no footer to portal into and buttons should render inline. + */ +export const ModalFooterContext = createContext(null); + +export function useModalFooter(): HTMLElement | null { + return useContext(ModalFooterContext); +} diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx index 69e315682b..76f07d8a89 100644 --- a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx +++ b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx @@ -7,20 +7,20 @@ import React from 'react'; import { render } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import { useIsInModal } from '../forms/AddEntitiesModal'; +import { useModalFooter } from '../../ModalFooterContext'; import { WizardNavButtons } from './WizardNavButtons'; -jest.mock('../forms/AddEntitiesModal', () => ({ - ...jest.requireActual('../forms/AddEntitiesModal'), - useIsInModal: jest.fn(), +jest.mock('../../ModalFooterContext', () => ({ + ...jest.requireActual('../../ModalFooterContext'), + useModalFooter: jest.fn(), })); -const mockUseIsInModal = useIsInModal as jest.MockedFunction; +const mockUseModalFooter = useModalFooter as jest.MockedFunction; describe('WizardNavButtons', () => { beforeEach(() => { - mockUseIsInModal.mockReturnValue(false); + mockUseModalFooter.mockReturnValue(null); }); test('default props', () => { render(); @@ -105,25 +105,22 @@ describe('WizardNavButtons', () => { expect(nextFn).toHaveBeenCalledTimes(1); }); - test('respects useIsInModal', () => { - // When not in a modal, the buttons render in sticky mode without the modal footer wrapper. - const { rerender } = render(); - expect(document.querySelector('.modal-footer')).toBeNull(); - expect(document.querySelector('.form-buttons--sticky')).not.toBeNull(); - - // When in a modal, the buttons are wrapped in a modal footer element and are no longer sticky. - mockUseIsInModal.mockReturnValue(true); - rerender(); - const footer = document.querySelector('.modal-footer'); - expect(footer).not.toBeNull(); - expect(footer).toHaveClass('modal-buttons', 'modal-footer-in-body'); + test('portals into the modal footer when one is provided', () => { + const footer = document.createElement('div'); + footer.className = 'modal-footer modal-buttons'; + document.body.appendChild(footer); + mockUseModalFooter.mockReturnValue(footer); + + render(); + expect(document.querySelector('.form-buttons--sticky')).toBeNull(); expect(footer.querySelector('.form-buttons')).not.toBeNull(); - // The nav buttons are still rendered inside the modal footer. const buttons = footer.querySelectorAll('button'); expect(buttons).toHaveLength(2); expect(buttons[0]).toHaveTextContent('Cancel'); expect(buttons[1]).toHaveTextContent('Next'); + + document.body.removeChild(footer); }); }); diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx index 3507f12d09..3f3d54bf8b 100644 --- a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx +++ b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx @@ -3,9 +3,10 @@ * any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ import React, { FC, memo, PropsWithChildren } from 'react'; +import { createPortal } from 'react-dom'; import { FormButtons } from '../../FormButtons'; -import { useIsInModal } from '../forms/AddEntitiesModal'; +import { useModalFooter } from '../../ModalFooterContext'; interface Props extends PropsWithChildren { canCancel?: boolean; @@ -46,10 +47,10 @@ export const WizardNavButtons: FC = memo(props => { previousStep, singularNoun, } = props; - const inModal = useIsInModal(); + const footerEl = useModalFooter(); const formButtons = ( - + @@ -85,11 +86,10 @@ export const WizardNavButtons: FC = memo(props => { ); - if (inModal) { - // This is not ideal as this can result in a "modal-footer" inside a "modal-body", however, it is much - // less complicated than rendering to a React.createPortal(). Apply the "modal-footer-in-body" class to - // adjust the layout to align with the body. - return
{formButtons}
; + // When rendered inside a modal that provides a footer element (see ModalFooterContext), portal the buttons into + // the actual footer so the footer stays a true sibling of the modal body rather than nested within it. + if (footerEl) { + return createPortal(formButtons, footerEl); } return formButtons; diff --git a/packages/components/src/theme/modal.scss b/packages/components/src/theme/modal.scss index 09855da73c..52b36e120c 100644 --- a/packages/components/src/theme/modal.scss +++ b/packages/components/src/theme/modal.scss @@ -38,7 +38,3 @@ font-size: 18px; font-weight: 700; } - -.modal-footer-in-body { - margin: 5px -15px -15px -15px; -} From 098697c9869e0a1e12bb2d88cd0524795318aeaa Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 22 Jul 2026 15:15:57 -0700 Subject: [PATCH 28/37] 7.48.1-fb-add-entity-modal.2 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 1a8c827479..659565170a 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-add-entity-modal.1", + "version": "7.48.1-fb-add-entity-modal.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.1-fb-add-entity-modal.1", + "version": "7.48.1-fb-add-entity-modal.2", "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 19190ca7c4..4247707751 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-add-entity-modal.1", + "version": "7.48.1-fb-add-entity-modal.2", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 24a85809cba3f3bf06b20c0383c4543fb87633b8 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 22 Jul 2026 15:46:36 -0700 Subject: [PATCH 29/37] useFormStepActive --- packages/components/src/index.ts | 3 ++- .../buttons/WizardNavButtons.test.tsx | 25 +++++++++++++++++++ .../components/buttons/WizardNavButtons.tsx | 5 ++++ .../internal/components/forms/FormStep.tsx | 10 +++++++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 09f021cea6..556d6441b8 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -325,7 +325,7 @@ import { updateRowFieldValue, useUsersWithPermissions, } from './internal/components/forms/actions'; -import { FormStep, FormTabs, withFormSteps } from './internal/components/forms/FormStep'; +import { FormStep, FormTabs, useFormStepActive, withFormSteps } from './internal/components/forms/FormStep'; import { EntityIdCreationModel, EntityParentType, @@ -1768,6 +1768,7 @@ export { useDataChangeCommentsRequired, useEnterEscape, useFolderMenuContext, + useFormStepActive, useIsInModal, useModalFooter, useLabelPrintingContext, diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx index 76f07d8a89..2496fd1c23 100644 --- a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx +++ b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx @@ -8,6 +8,7 @@ import { render } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { useModalFooter } from '../../ModalFooterContext'; +import { useFormStepActive } from '../forms/FormStep'; import { WizardNavButtons } from './WizardNavButtons'; @@ -16,11 +17,18 @@ jest.mock('../../ModalFooterContext', () => ({ useModalFooter: jest.fn(), })); +jest.mock('../forms/FormStep', () => ({ + ...jest.requireActual('../forms/FormStep'), + useFormStepActive: jest.fn(), +})); + const mockUseModalFooter = useModalFooter as jest.MockedFunction; +const mockUseFormStepActive = useFormStepActive as jest.MockedFunction; describe('WizardNavButtons', () => { beforeEach(() => { mockUseModalFooter.mockReturnValue(null); + mockUseFormStepActive.mockReturnValue(true); }); test('default props', () => { render(); @@ -123,4 +131,21 @@ describe('WizardNavButtons', () => { document.body.removeChild(footer); }); + + test('does not portal into the footer when its form step is inactive', () => { + // A wizard mounts every visited step at once, so an inactive step must contribute nothing to the shared + // modal footer. + const footer = document.createElement('div'); + footer.className = 'modal-footer modal-buttons'; + document.body.appendChild(footer); + mockUseModalFooter.mockReturnValue(footer); + mockUseFormStepActive.mockReturnValue(false); + + render(); + + expect(footer.querySelectorAll('button')).toHaveLength(0); + expect(document.querySelectorAll('button')).toHaveLength(0); + + document.body.removeChild(footer); + }); }); diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx index 3f3d54bf8b..88034f2f5f 100644 --- a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx +++ b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx @@ -7,6 +7,7 @@ import { createPortal } from 'react-dom'; import { FormButtons } from '../../FormButtons'; import { useModalFooter } from '../../ModalFooterContext'; +import { useFormStepActive } from '../forms/FormStep'; interface Props extends PropsWithChildren { canCancel?: boolean; @@ -48,6 +49,7 @@ export const WizardNavButtons: FC = memo(props => { singularNoun, } = props; const footerEl = useModalFooter(); + const stepActive = useFormStepActive(); const formButtons = ( @@ -88,7 +90,10 @@ export const WizardNavButtons: FC = memo(props => { // When rendered inside a modal that provides a footer element (see ModalFooterContext), portal the buttons into // the actual footer so the footer stays a true sibling of the modal body rather than nested within it. + // If used in combination with FormStep, only render the buttons for the active step. If not in a FormStep, then + // stepActive will be true. if (footerEl) { + if (!stepActive) return null; return createPortal(formButtons, footerEl); } diff --git a/packages/components/src/internal/components/forms/FormStep.tsx b/packages/components/src/internal/components/forms/FormStep.tsx index d597f6a6e1..a9fc583073 100644 --- a/packages/components/src/internal/components/forms/FormStep.tsx +++ b/packages/components/src/internal/components/forms/FormStep.tsx @@ -18,6 +18,12 @@ const FormStepContext = React.createContext(undefined); const FormStepContextProvider = FormStepContext.Provider; const FormStepContextConsumer = FormStepContext.Consumer; +export const FormStepActiveContext = React.createContext(true); + +export function useFormStepActive(): boolean { + return useContext(FormStepActiveContext); +} + interface ActiveStepProps extends PropsWithChildren { active?: boolean; } @@ -60,7 +66,9 @@ export class FormStep extends React.Component { if (furthestStep >= stepIndex) { return (
- {trackActive ? {children} : children} + + {trackActive ? {children} : children} +
); } From 43ce51cf9057270eeb044859df3d282062cc4fe5 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 22 Jul 2026 15:46:46 -0700 Subject: [PATCH 30/37] 7.48.1-fb-add-entity-modal.3 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 659565170a..7a411d1b00 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-add-entity-modal.2", + "version": "7.48.1-fb-add-entity-modal.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.1-fb-add-entity-modal.2", + "version": "7.48.1-fb-add-entity-modal.3", "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 4247707751..7ae3030f09 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-add-entity-modal.2", + "version": "7.48.1-fb-add-entity-modal.3", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 369c281fbd9887c955b1159080c277fd3543c382 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 23 Jul 2026 06:50:27 -0700 Subject: [PATCH 31/37] Remove unused class --- packages/components/src/theme/form.scss | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/components/src/theme/form.scss b/packages/components/src/theme/form.scss index 63c3b3cc43..a0afd598e6 100644 --- a/packages/components/src/theme/form.scss +++ b/packages/components/src/theme/form.scss @@ -382,12 +382,6 @@ textarea.form-control { text-align: left; } -.modal-footer--slot:empty { - display: none; - padding: 0; - border-top: none; -} - .query-info-form .toggle-group-icon { padding-top: 7px; } From c878fe72a198b12a2da78859cf63e561363e09b8 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 23 Jul 2026 09:42:50 -0700 Subject: [PATCH 32/37] FileInput: unique ID per field instance --- .../components/forms/input/FileInput.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/components/src/internal/components/forms/input/FileInput.tsx b/packages/components/src/internal/components/forms/input/FileInput.tsx index 039310a12c..721ea587ff 100644 --- a/packages/components/src/internal/components/forms/input/FileInput.tsx +++ b/packages/components/src/internal/components/forms/input/FileInput.tsx @@ -21,6 +21,7 @@ import { fileMatchesAcceptedFormat } from '../../files/actions'; import { getTransferItemDirectoryEntry } from '../../files/FileAttachmentContainer'; import { DisableableInput, DisableableInputProps, DisableableInputState } from './DisableableInput'; +import { generateId } from '../../../util/utils'; type FileInputData = Map | string | undefined; @@ -70,6 +71,7 @@ interface State extends DisableableInputState { class FileInputImpl extends DisableableInput { fileInput: RefObject; + inputId: string; static defaultProps = { ...DisableableInput.defaultProps, @@ -84,6 +86,8 @@ class FileInputImpl extends DisableableInput { 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); @@ -101,8 +105,6 @@ class FileInputImpl extends DisableableInput { } getInputName(): string { - // FIXME if there's more than one of these on the page with the same inputName - // files will go to the wrong place when uploaded unless the names are unique return this.props.name ?? this.props.queryColumn.fieldKey; } @@ -199,14 +201,14 @@ class FileInputImpl extends DisableableInput { } = this.props; const { data, error, file, isDisabled, isHover } = this.state; - const name = this.getInputName(); - const inputId = `${name}-fileUpload`; // Issue 53394: needs to be a distinct input id so it doesn't collide with other elements on the page for this fieldKey - let body; + let body: ReactNode; if (file || typeof data === 'string') { body = (
{ - {/* We render a label here so click and drag events propagate to the input above */} + {/* We render a label here, so click and drag events propagate to the input above */}