From d8a33a09766251c70d3621ad9e8f5184cb9a020a Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 14 Jul 2026 14:17:59 -0700 Subject: [PATCH 01/32] 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 59ae7e9d89..eeff8dd5e4 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -882,6 +882,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'; @@ -1634,6 +1635,7 @@ export { registerDefaultURLMappers, registerFilterType, registerInputRenderer, + registerModalRenderer, ReleaseNote, removeColumn, removeColumns, @@ -1922,6 +1924,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 b1cf381882f3cc6177d966e237d84b7263ad400c Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 14 Jul 2026 15:42:00 -0700 Subject: [PATCH 02/32] 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 6a947a36eed3ecc1c11eab5e7bb15c95c7a0d8e7 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 10:15:38 -0700 Subject: [PATCH 03/32] 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 72ce99d7b73c810e748b18d53d25a0cd6857366c Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 10:21:03 -0700 Subject: [PATCH 04/32] 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 9010a2a76fa67d3a171f93e76368f53ef60aeea4 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:52:59 -0700 Subject: [PATCH 05/32] 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 b7490682ea290fe61ab1da5a27c2accaf4dacb89 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:53:19 -0700 Subject: [PATCH 06/32] 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 d4f404513714c59ffc0839bfcbb8036f9dbe9d13 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:53:50 -0700 Subject: [PATCH 07/32] 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 634f98c882b96a8c0e620f17d69c06f3e813c0bf Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:55:57 -0700 Subject: [PATCH 08/32] 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 d9dbd255650d83eb88d5e2b142f201d63378aea3 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:56:02 -0700 Subject: [PATCH 09/32] 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 eeff8dd5e4..fcc06d8d51 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1924,7 +1924,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 { @@ -1967,3 +1967,4 @@ export type { QueryModelMap, RequiresModelAndActions, } from './public/QueryModel/withQueryModels'; +export type { SchemaQueryKey } from './public/SchemaQuery'; From bc798b22fe04e801cc414f03d1b28d06d1959a08 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 14:57:12 -0700 Subject: [PATCH 10/32] 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 7404a094e4a4351e32ce86ba9fd96a307a43d080 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 15 Jul 2026 16:14:48 -0700 Subject: [PATCH 11/32] 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 f6199924d8d8a1e76ed30ddaa836f3d3e02da1ff Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 16 Jul 2026 12:58:50 -0700 Subject: [PATCH 12/32] 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 fb1bb253f57616f0e81a91824513cf4c3c1284b4 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 16 Jul 2026 13:12:38 -0700 Subject: [PATCH 13/32] 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 a134af030fc757eb70cc1682b28899f7b8b7218c Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 16 Jul 2026 13:17:51 -0700 Subject: [PATCH 14/32] 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 c034abf2892d2b084c157caff5c698a35e1c2006 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 06:59:10 -0700 Subject: [PATCH 15/32] 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 0a9bc3939dd3357f30155c0f3e4ab12db99d98bf Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 08:22:21 -0700 Subject: [PATCH 16/32] 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 711b02a2b84e565f9d7c9b90a28bd2684446d53b Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 09:00:17 -0700 Subject: [PATCH 17/32] 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 f1c3385dd7b3e423dbba83709466d91f51e94c66 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 09:00:23 -0700 Subject: [PATCH 18/32] 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 8e8ce5c67d7d880318c2f701f72ed44fb0b867c8 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 09:00:31 -0700 Subject: [PATCH 19/32] 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 5641802e9408a772df6ba60ef9509a0a26cd5b6d Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 17 Jul 2026 14:49:48 -0700 Subject: [PATCH 20/32] 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 fcc06d8d51..c1fbfd739c 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1924,6 +1924,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 0ca4b69358..fa6a34f129 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 c7e9627100218bf868422638829d17b68568a73a Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Mon, 20 Jul 2026 09:40:25 -0700 Subject: [PATCH 21/32] 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 69d9d066f7dcbafd59df0a163c043cad5c62929b Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 21 Jul 2026 09:01:11 -0700 Subject: [PATCH 22/32] 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 5eec4075853855451de23e2d9e425e32e382d6d9 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 21 Jul 2026 14:39:04 -0700 Subject: [PATCH 23/32] 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 c1fbfd739c..451c94950c 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -883,6 +883,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'; @@ -1777,6 +1778,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 14:42:56 -0700 Subject: [PATCH 24/32] 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 451c94950c..93e51bdc1d 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1926,7 +1926,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 871b38e91ba10d6a5a1f2a6d27ad096dae56a57c Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 22 Jul 2026 15:15:36 -0700 Subject: [PATCH 25/32] 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 93e51bdc1d..3674d99b81 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -900,6 +900,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'; @@ -1570,6 +1571,7 @@ export { MessageLevel, Modal, ModalButtons, + ModalFooterContext, ModalHeader, MultiValueRenderer, NameIdSettings, @@ -1779,6 +1781,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 f4d218dc93e002963780509c10d463267f64b73f Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 22 Jul 2026 15:46:36 -0700 Subject: [PATCH 26/32] 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 3674d99b81..48f601f372 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -326,7 +326,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, @@ -1780,6 +1780,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 ffe72fe9ccdd1cc7837c62f2b362a5eae7d86b67 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 23 Jul 2026 06:50:27 -0700 Subject: [PATCH 27/32] 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 fa6a34f129..0ca4b69358 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 41747c7c3c8676b059f9b9d097a86b33af7aa3cb Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 23 Jul 2026 09:42:50 -0700 Subject: [PATCH 28/32] 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 */}