Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d8a33a0
registerModalRenderer
labkey-nicka Jul 14, 2026
b1cf381
SelectInput: menuFooter prop
labkey-nicka Jul 14, 2026
6a947a3
AmountUnitInput test updates
labkey-nicka Jul 15, 2026
72ce99d
Formsy: do not propagate nested form events
labkey-nicka Jul 15, 2026
9010a2a
ExtendedMap: improve types
labkey-nicka Jul 15, 2026
b749068
SchemaQueryKey
labkey-nicka Jul 15, 2026
d4f4045
EditableGrid: disable allowAddEntities
labkey-nicka Jul 15, 2026
634f98c
AddEntitiesModal
labkey-nicka Jul 15, 2026
d9dbd25
Export types
labkey-nicka Jul 15, 2026
bc798b2
QuerySelect: introduce AddEntitiesModal
labkey-nicka Jul 15, 2026
7404a09
ActionButton FC
labkey-nicka Jul 15, 2026
f619992
SelectInput.cacheKey
labkey-nicka Jul 16, 2026
fb1bb25
QuerySelect implement onAddEntitiesComplete
labkey-nicka Jul 16, 2026
a134af0
AddEntitiesModalContext.Provider
labkey-nicka Jul 16, 2026
c034abf
ModalRenderFactory register null
labkey-nicka Jul 17, 2026
0a9bc39
AddEntitiesFooter
labkey-nicka Jul 17, 2026
711b02a
Modal.showFooter
labkey-nicka Jul 17, 2026
f1c3385
Add test
labkey-nicka Jul 17, 2026
8e8ce5c
OptionRenderer
labkey-nicka Jul 17, 2026
5641802
ModalFooterSlot
labkey-nicka Jul 17, 2026
c7e9627
Updates
labkey-nicka Jul 20, 2026
69d9d06
nits
labkey-nicka Jul 21, 2026
5eec407
useIsInModal, FORM_INSERT_MODAL
labkey-nicka Jul 21, 2026
775fa45
Use sticky
labkey-nicka Jul 22, 2026
871b38e
ModalFooterContext
labkey-nicka Jul 22, 2026
f4d218d
useFormStepActive
labkey-nicka Jul 22, 2026
ffe72fe
Remove unused class
labkey-nicka Jul 23, 2026
41747c7
FileInput: unique ID per field instance
labkey-nicka Jul 23, 2026
b343bef
Footer keyboard selection
labkey-nicka Jul 23, 2026
8b2968c
SelectInput.MenuListWithFooter
labkey-nicka Jul 27, 2026
c346588
lint
labkey-nicka Jul 28, 2026
d134ea5
7.49.1-fb-add-entity-modal.0
labkey-nicka Jul 28, 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.49.0",
"version": "7.49.1-fb-add-entity-modal.0",
"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 @@ -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,
Expand Down Expand Up @@ -882,6 +882,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 @@ -898,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';
Expand Down Expand Up @@ -1568,6 +1571,7 @@ export {
MessageLevel,
Modal,
ModalButtons,
ModalFooterContext,
ModalHeader,
MultiValueRenderer,
NameIdSettings,
Expand Down Expand Up @@ -1634,6 +1638,7 @@ export {
registerDefaultURLMappers,
registerFilterType,
registerInputRenderer,
registerModalRenderer,
ReleaseNote,
removeColumn,
removeColumns,
Expand Down Expand Up @@ -1775,8 +1780,11 @@ export {
useDataChangeCommentsRequired,
useEnterEscape,
useFolderMenuContext,
useFormStepActive,
useIsInModal,
useLabelPrintingContext,
useLoadableState,
useModalFooter,
useModalState,
useNotAuthorized,
useNotFound,
Expand Down Expand Up @@ -1922,6 +1930,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 @@ -1964,3 +1973,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