diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 0f7997c61d..5856d0a773 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.0", + "version": "7.48.1-fb-add-entity-modal.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.0", + "version": "7.48.1-fb-add-entity-modal.6", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 49fcfc1c63..49948121a6 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.0", + "version": "7.48.1-fb-add-entity-modal.6", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index bd738fbf34..556d6441b8 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -325,7 +325,7 @@ import { updateRowFieldValue, useUsersWithPermissions, } from './internal/components/forms/actions'; -import { FormStep, FormTabs, withFormSteps } from './internal/components/forms/FormStep'; +import { FormStep, FormTabs, useFormStepActive, withFormSteps } from './internal/components/forms/FormStep'; import { EntityIdCreationModel, EntityParentType, @@ -876,6 +876,8 @@ 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 { 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'; @@ -892,6 +894,7 @@ import { LineageGridModel, LineageResult } from './internal/components/lineage/m import { ActiveUserLimit, ActiveUserLimitMessage } from './internal/components/settings/ActiveUserLimit'; import { NameIdSettings } from './internal/components/settings/NameIdSettings'; import { BaseModal, Modal, ModalHeader } from './internal/Modal'; +import { ModalFooterContext, useModalFooter } from './internal/ModalFooterContext'; import { Tab, Tabs } from './internal/Tabs'; import { CheckboxLK } from './internal/Checkbox'; import { ArchivedFolderTag } from './internal/components/folder/ArchivedFolderTag'; @@ -1559,6 +1562,7 @@ export { MessageLevel, Modal, ModalButtons, + ModalFooterContext, ModalHeader, MultiValueRenderer, NameIdSettings, @@ -1625,6 +1629,7 @@ export { registerDefaultURLMappers, registerFilterType, registerInputRenderer, + registerModalRenderer, ReleaseNote, removeColumn, removeColumns, @@ -1763,6 +1768,9 @@ export { useDataChangeCommentsRequired, useEnterEscape, useFolderMenuContext, + useFormStepActive, + useIsInModal, + useModalFooter, useLabelPrintingContext, useLoadableState, useModalState, @@ -1910,6 +1918,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 { AddEntitiesComplete, ModalRendererProps } from './internal/ModalRenderFactory'; export type { TriggerType } from './internal/OverlayTrigger'; export type { ISelectRowsResult } from './internal/query/api'; export type { @@ -1952,3 +1961,4 @@ export type { QueryModelMap, RequiresModelAndActions, } from './public/QueryModel/withQueryModels'; +export type { SchemaQueryKey } from './public/SchemaQuery'; diff --git a/packages/components/src/internal/FormButtons.test.tsx b/packages/components/src/internal/FormButtons.test.tsx new file mode 100644 index 0000000000..6813026c92 --- /dev/null +++ b/packages/components/src/internal/FormButtons.test.tsx @@ -0,0 +1,43 @@ +/* + * 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'; + +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).toHaveClass('form-buttons--sticky'); + 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).not.toHaveClass('form-buttons--sticky'); + }); +}); diff --git a/packages/components/src/internal/Modal.test.tsx b/packages/components/src/internal/Modal.test.tsx index ffd53ac144..fa323a2a6b 100644 --- a/packages/components/src/internal/Modal.test.tsx +++ b/packages/components/src/internal/Modal.test.tsx @@ -23,38 +23,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'); }); }); @@ -63,7 +62,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(); }); @@ -72,7 +71,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); }); @@ -91,7 +90,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'); }); }); @@ -104,14 +103,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(); }); @@ -128,7 +127,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', () => { @@ -139,7 +138,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(); }); @@ -177,7 +176,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', () => { @@ -187,8 +186,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'); }); }); }); diff --git a/packages/components/src/internal/Modal.tsx b/packages/components/src/internal/Modal.tsx index 6b9c108cd4..d6a9e73955 100644 --- a/packages/components/src/internal/Modal.tsx +++ b/packages/components/src/internal/Modal.tsx @@ -94,6 +94,7 @@ interface ModalHeaderProps extends PropsWithChildren { onCancel?: () => void; title: ReactNode; } + export const ModalHeader: FC = ({ children, onCancel, title }) => { return (
@@ -125,6 +126,10 @@ 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; + /** + * 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. */ @@ -150,9 +155,11 @@ export const Modal: FC = memo(props => { onCommentChange, onConfirm, requiresUserComment, + showFooter = true, title, } = props; const showHeader = !!(onCancel || title); + return ( {showHeader && !header && } @@ -160,7 +167,7 @@ export const Modal: FC = memo(props => {
{children}
- {!footer && ( + {showFooter && !footer && ( = memo(props => { )} - {footer &&
{footer}
} + {showFooter && footer &&
{footer}
}
); }); 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/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 new file mode 100644 index 0000000000..e8b6434687 --- /dev/null +++ b/packages/components/src/internal/ModalRenderFactory.ts @@ -0,0 +1,64 @@ +/* + * 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 { 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; +} + +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(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 | null, + modalRenderContext = ModalRenderContext.AddEntities +): void { + modalRenderers[getKey(identifier, modalRenderContext)] = renderer; +} + +export function resolveModalRenderer( + identifier: SchemaQuery, + modalRenderContext = ModalRenderContext.AddEntities +): ModalRendererComponent | undefined { + 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; +} 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'; diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx index bb8103f1d8..2496fd1c23 100644 --- a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx +++ b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx @@ -7,31 +7,51 @@ import React from 'react'; 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'; -describe('', () => { +jest.mock('../../ModalFooterContext', () => ({ + ...jest.requireActual('../../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(); - 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,16 +62,37 @@ describe('', () => { ); - 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]).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]).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]).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); @@ -71,4 +112,40 @@ describe('', () => { expect(prevFn).toHaveBeenCalledTimes(1); expect(nextFn).toHaveBeenCalledTimes(1); }); + + 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(); + + const buttons = footer.querySelectorAll('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent('Cancel'); + expect(buttons[1]).toHaveTextContent('Next'); + + 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 c5c63077f9..88034f2f5f 100644 --- a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx +++ b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx @@ -3,18 +3,22 @@ * 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 { useModalFooter } from '../../ModalFooterContext'; +import { useFormStepActive } from '../forms/FormStep'; 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 +39,7 @@ export const WizardNavButtons: FC = memo(props => { children, finish = false, finishText = 'Finish', + formId, isFinished, isFinishedText = 'Finished', isFinishing, @@ -43,37 +48,55 @@ export const WizardNavButtons: FC = memo(props => { previousStep, singularNoun, } = props; + const footerEl = useModalFooter(); + const stepActive = useFormStepActive(); - let submitButton; - - if (finish) { - submitButton = ( - - ); - } else { - submitButton = ( - - ); - } - - return ( - + const formButtons = ( + {previousStep !== undefined && ( - )} {children} - {submitButton} + {finish && ( + + )} + {!finish && ( + + )} ); + + // 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); + } + + return formButtons; }); WizardNavButtons.displayName = 'WizardNavButtons'; 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, }; 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..75e6ebf06e --- /dev/null +++ b/packages/components/src/internal/components/forms/AddEntitiesModal.test.tsx @@ -0,0 +1,150 @@ +/* + * 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); + }); + + test('reflects keyboard focus via the "focused" prop', () => { + const { rerender } = render(); + expect(document.querySelector('.add-entities-footer')).not.toHaveClass('is-focused'); + + rerender(); + expect(document.querySelector('.add-entities-footer')).toHaveClass('is-focused'); + }); + }); + + 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(); + }); + }); +}); 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..3e1beca450 --- /dev/null +++ b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx @@ -0,0 +1,80 @@ +/* + * 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 classNames from 'classnames'; + +import { SchemaQuery } from '../../../public/SchemaQuery'; +import { Modal } from '../../Modal'; +import { ModalRendererProps, resolveModalRenderer } from '../../ModalRenderFactory'; + +export type AddEntitiesModalContext = { + addEntitiesEnabled: boolean; + inModal: boolean; +}; + +const AddEntitiesModalContext = createContext({ addEntitiesEnabled: true, inModal: false }); + +export function useIsAddEntitiesEnabled(schemaQuery: SchemaQuery): boolean { + const ctx = useContext(AddEntitiesModalContext); + return useMemo( + // If the context it not available/rendered, then default to true + () => + schemaQuery !== undefined && + (ctx?.addEntitiesEnabled ?? true) && + resolveModalRenderer(schemaQuery) !== undefined, + [ctx, schemaQuery] + ); +} + +export function useIsInModal(): boolean { + const ctx = useContext(AddEntitiesModalContext); + return !!ctx?.inModal; +} + +interface AddEntitiesMenuFooterProps { + focused?: boolean; + onClick: () => void; +} + +export const AddEntitiesFooter: FC = ({ focused, onClick }) => ( +
+ + Add New +
+); +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 ( + + Add entities modal not registered for {schemaQuery.schemaName}.{schemaQuery.queryName} + + ); + } + + return ( + + {/* eslint-disable-next-line react-hooks/static-components */} + + + ); +}; +AddEntitiesModal.displayName = 'AddEntitiesModal'; diff --git a/packages/components/src/internal/components/forms/FormStep.test.tsx b/packages/components/src/internal/components/forms/FormStep.test.tsx index 0ce4b8c31f..328aef10d7 100644 --- a/packages/components/src/internal/components/forms/FormStep.test.tsx +++ b/packages/components/src/internal/components/forms/FormStep.test.tsx @@ -8,8 +8,8 @@ import { render } from '@testing-library/react'; import { FormStep, FormTabs, withFormSteps, WithFormStepsProps } from './FormStep'; interface OwnProps { - step?: number; initialStep?: number; + step?: number; } type Props = OwnProps & WithFormStepsProps; @@ -41,7 +41,7 @@ const FormStepTest = withFormSteps(FormStepTestImpl, { hasDependentSteps: true, }); -describe('', () => { +describe('FormStep', () => { test('default props', () => { const { container } = render(); const tabs = container.querySelectorAll('.form-step-tab'); 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} +
); } diff --git a/packages/components/src/internal/components/forms/QuerySelect.tsx b/packages/components/src/internal/components/forms/QuerySelect.tsx index 7077547e5a..4607a989e3 100644 --- a/packages/components/src/internal/components/forms/QuerySelect.tsx +++ b/packages/components/src/internal/components/forms/QuerySelect.tsx @@ -16,21 +16,35 @@ import { QueryInfo } from '../../../public/QueryInfo'; import { isTestEnv } from '../../util/utils'; -import { useTimeout } from '../../hooks'; +import { useModalState, useTimeout } from '../../hooks'; -import { SelectInput, SelectInputChange, SelectInputOption, SelectInputProps } from './input/SelectInput'; +import { + SelectInput, + SelectInputChange, + SelectInputOnFocus, + SelectInputOnKeyDown, + SelectInputOption, + SelectInputProps, +} from './input/SelectInput'; import { resolveDetailFieldLabel } from './utils'; import { fetchSearchResults, + fetchSelectedValues, formatResults, formatSavedResults, + getAddedSelectionValue, initSelect, parseSelectedQuery, QuerySelectModel, saveSearchResults, setSelection, + setSelectionWithResults, + valuesAreLoaded, } from './model'; import { DELIMITER } from './constants'; +import { AddEntitiesFooter, AddEntitiesModal, useIsAddEntitiesEnabled } from './AddEntitiesModal'; +import { AddEntitiesComplete } from '../../ModalRenderFactory'; +import { Key } from '../../../public/useEnterEscape'; function getValue(model: QuerySelectModel, multiple: boolean): any { const { rawSelectedValue } = model; @@ -83,47 +97,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'; @@ -147,9 +159,10 @@ type InheritedSelectInputProps = Omit< SelectInputProps, | 'allowCreate' | 'autoValue' + | 'cacheKey' // used by QuerySelect to invalidate cached options when new entities are added. | '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 +173,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 +185,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 +209,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, @@ -234,6 +250,7 @@ export const QuerySelect: FC = memo(props => { menuPosition, multiple, name, + onKeyDown, onToggleDisable, openMenuOnFocus, required, @@ -244,7 +261,9 @@ 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 [footerFocused, setFooterFocused] = useState(false); const [loadOnFocusLock, setLoadOnFocusLock] = useState(false); const [isLoading, setIsLoading] = useState(undefined); const [model, setModel] = useState( @@ -258,7 +277,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 +299,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 +366,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 +377,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 +408,45 @@ export const QuerySelect: FC = memo(props => { [model, onQSChange] ); - const onFocus = useCallback(async () => { - // NK: To support loading the select upon focus (a.k.a. "loadOnFocus") we have to explicitly utilize + const onAddEntitiesComplete = useCallback( + async resultsMap => { + closeModal(); + const result = resultsMap.get(schemaQuery.getKey()); + if (!model.isInit || !result?.rows?.length) return; + + const nextValue = getAddedSelectionValue(model, result.rows); + + try { + let model_: QuerySelectModel; + + 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_); + + // 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, notFoundValuesEnabled, onQSChange, props, schemaQuery] + ); + + const onFocus = useCallback(async () => { + // 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: @@ -409,6 +467,61 @@ export const QuerySelect: FC = memo(props => { } }, [loadOptions, shouldLoadOnFocus]); + // Support keyboard interaction with footer + const handleKeyDown = useCallback( + (event, select) => { + onKeyDown?.(event, select); + if (event.defaultPrevented || !isAddEntitiesEnabled || !select.props.menuIsOpen) return; + const { key } = event; + + if (footerFocused) { + switch (key) { + case Key.ARROW_DOWN: + // Wrap back around to the first option. + event.preventDefault(); + setFooterFocused(false); + select.focusOption('first'); + break; + case Key.ARROW_UP: + // Move back up to the last option. + event.preventDefault(); + setFooterFocused(false); + select.focusOption('last'); + break; + case Key.ENTER: + event.preventDefault(); + setFooterFocused(false); + openModal(); + break; + default: + // Drop footer focus and let react-select handle the key. + setFooterFocused(false); + break; + } + return; + } + + // Enter the footer by wrapping around the option list: arrowing down from the last option or up from + // the first option (or either direction when the list has no options). + if (key === Key.ARROW_DOWN || key === Key.ARROW_UP) { + const options = select.getFocusableOptions(); + const { focusedOption } = select.state; + + const shouldFocusFooter = + options.length === 0 || + (key === Key.ARROW_DOWN && focusedOption === options.at(-1)) || + (key === Key.ARROW_UP && focusedOption === options[0]); + + if (shouldFocusFooter) { + event.preventDefault(); + setFooterFocused(true); + select.setState({ focusedOption: null }); + } + } + }, + [footerFocused, isAddEntitiesEnabled, onKeyDown, openModal] + ); + const optionRenderer = useCallback( option => ( @@ -416,7 +529,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 +565,42 @@ 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} + onKeyDown={handleKeyDown} + 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'; 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 => { 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'; 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 */}