Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3c99147
registerModalRenderer
labkey-nicka Jul 14, 2026
f2841fc
SelectInput: menuFooter prop
labkey-nicka Jul 14, 2026
89b4981
AmountUnitInput test updates
labkey-nicka Jul 15, 2026
f33eb47
Formsy: do not propagate nested form events
labkey-nicka Jul 15, 2026
5d8e984
ExtendedMap: improve types
labkey-nicka Jul 15, 2026
8acac01
SchemaQueryKey
labkey-nicka Jul 15, 2026
57b17b7
EditableGrid: disable allowAddEntities
labkey-nicka Jul 15, 2026
011a99b
AddEntitiesModal
labkey-nicka Jul 15, 2026
38e692e
Export types
labkey-nicka Jul 15, 2026
7ba0753
QuerySelect: introduce AddEntitiesModal
labkey-nicka Jul 15, 2026
63a2728
ActionButton FC
labkey-nicka Jul 15, 2026
da9dc95
SelectInput.cacheKey
labkey-nicka Jul 16, 2026
ebc868f
QuerySelect implement onAddEntitiesComplete
labkey-nicka Jul 16, 2026
96f1cf5
AddEntitiesModalContext.Provider
labkey-nicka Jul 16, 2026
8fdd911
ModalRenderFactory register null
labkey-nicka Jul 17, 2026
2753d21
AddEntitiesFooter
labkey-nicka Jul 17, 2026
436ac0f
Modal.showFooter
labkey-nicka Jul 17, 2026
4d0a20e
Add test
labkey-nicka Jul 17, 2026
1c1c9ff
OptionRenderer
labkey-nicka Jul 17, 2026
3f8f9cd
ModalFooterSlot
labkey-nicka Jul 17, 2026
6ac48e2
Updates
labkey-nicka Jul 20, 2026
75ea320
nits
labkey-nicka Jul 21, 2026
4f8d8e0
useIsInModal, FORM_INSERT_MODAL
labkey-nicka Jul 21, 2026
65e6f5e
7.48.1-fb-add-entity-modal.0
labkey-nicka Jul 22, 2026
565fb5f
Use sticky
labkey-nicka Jul 22, 2026
1284949
7.48.1-fb-add-entity-modal.1
labkey-nicka Jul 22, 2026
b52230a
ModalFooterContext
labkey-nicka Jul 22, 2026
098697c
7.48.1-fb-add-entity-modal.2
labkey-nicka Jul 22, 2026
24a8580
useFormStepActive
labkey-nicka Jul 22, 2026
43ce51c
7.48.1-fb-add-entity-modal.3
labkey-nicka Jul 22, 2026
369c281
Remove unused class
labkey-nicka Jul 23, 2026
c878fe7
FileInput: unique ID per field instance
labkey-nicka Jul 23, 2026
6b52efb
7.48.1-fb-add-entity-modal.4
labkey-nicka Jul 23, 2026
81a7500
Footer keyboard selection
labkey-nicka Jul 23, 2026
5f8ec9e
7.48.1-fb-add-entity-modal.5
labkey-nicka Jul 23, 2026
dcaae72
SelectInput.MenuListWithFooter
labkey-nicka Jul 27, 2026
a9c547e
7.48.1-fb-add-entity-modal.6
labkey-nicka Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/components/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
12 changes: 11 additions & 1 deletion packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -1559,6 +1562,7 @@ export {
MessageLevel,
Modal,
ModalButtons,
ModalFooterContext,
ModalHeader,
MultiValueRenderer,
NameIdSettings,
Expand Down Expand Up @@ -1625,6 +1629,7 @@ export {
registerDefaultURLMappers,
registerFilterType,
registerInputRenderer,
registerModalRenderer,
ReleaseNote,
removeColumn,
removeColumns,
Expand Down Expand Up @@ -1763,6 +1768,9 @@ export {
useDataChangeCommentsRequired,
useEnterEscape,
useFolderMenuContext,
useFormStepActive,
useIsInModal,
useModalFooter,
useLabelPrintingContext,
useLoadableState,
useModalState,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1952,3 +1961,4 @@ export type {
QueryModelMap,
RequiresModelAndActions,
} from './public/QueryModel/withQueryModels';
export type { SchemaQueryKey } from './public/SchemaQuery';
43 changes: 43 additions & 0 deletions packages/components/src/internal/FormButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<FormButtons>
<button className="test-cancel" type="button">
Cancel
</button>
<button className="test-submit" type="submit">
Submit
</button>
</FormButtons>
);
}

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(
<FormButtons sticky={false}>
<button type="submit">Submit</button>
</FormButtons>
);
const buttons = container.querySelector('.form-buttons');
expect(buttons).not.toBeNull();
expect(buttons).not.toHaveClass('form-buttons--sticky');
});
});
38 changes: 18 additions & 20 deletions packages/components/src/internal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<BaseModal bsSize="sm">child</BaseModal>);
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(<BaseModal bsSize="lg">child</BaseModal>);
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(<BaseModal className="custom-class">child</BaseModal>);
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(<BaseModal>child</BaseModal>);
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');
});
});

Expand All @@ -63,7 +62,7 @@ describe('Modal components', () => {
render(<ModalHeader title="My Title" />);
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();
});

Expand All @@ -72,7 +71,7 @@ describe('Modal components', () => {
render(<ModalHeader onCancel={onCancel} title="t" />);
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);
});
Expand All @@ -91,7 +90,7 @@ describe('Modal components', () => {
</ModalHeader>
);
const header = document.querySelector('.modal-header');
expect(header.querySelector('.extra-child').textContent).toEqual('extra');
expect(header.querySelector('.extra-child')).toHaveTextContent('extra');
});
});

Expand All @@ -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(<Modal onCancel={jest.fn()} title="Hello" />);
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();
});

Expand All @@ -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', () => {
Expand All @@ -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();
});
Expand Down Expand Up @@ -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', () => {
Expand All @@ -187,8 +186,7 @@ describe('Modal components', () => {
</Modal>
);
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');
});
});
});
11 changes: 9 additions & 2 deletions packages/components/src/internal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ interface ModalHeaderProps extends PropsWithChildren {
onCancel?: () => void;
title: ReactNode;
}

export const ModalHeader: FC<ModalHeaderProps> = ({ children, onCancel, title }) => {
return (
<div className="modal-header">
Expand Down Expand Up @@ -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.
*/
Expand All @@ -150,17 +155,19 @@ export const Modal: FC<ModalProps> = memo(props => {
onCommentChange,
onConfirm,
requiresUserComment,
showFooter = true,
title,
} = props;
const showHeader = !!(onCancel || title);

return (
<BaseModal bsSize={bsSize} className={className} onCancel={onCancel}>
{showHeader && !header && <ModalHeader onCancel={onCancel} title={title} />}
{header}

<div className="modal-body">{children}</div>

{!footer && (
{showFooter && !footer && (
<ModalButtons
actionName={actionName}
cancelText={cancelText}
Expand All @@ -178,7 +185,7 @@ export const Modal: FC<ModalProps> = memo(props => {
</ModalButtons>
)}

{footer && <div className="modal-footer">{footer}</div>}
{showFooter && footer && <div className="modal-footer">{footer}</div>}
</BaseModal>
);
});
Expand Down
17 changes: 17 additions & 0 deletions packages/components/src/internal/ModalFooterContext.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>(null);

export function useModalFooter(): HTMLElement | null {
return useContext(ModalFooterContext);
}
63 changes: 63 additions & 0 deletions packages/components/src/internal/ModalRenderFactory.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading