Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
3561ccb
Initial ChatModal
labkey-nicka May 8, 2026
7667ec8
Wire up expression assistant modal
labkey-nicka May 8, 2026
2e84efb
Introduce query-expressionAssistantAgent.api
labkey-nicka May 11, 2026
a4b338e
Client-side metrics
labkey-nicka May 11, 2026
e6f5129
Interrupt on cancel
labkey-nicka May 11, 2026
4a285cd
Interrupt message
labkey-nicka May 11, 2026
7dc8ca5
Improvements
labkey-nicka May 11, 2026
1d45c88
Bump @labkey/api
labkey-nicka May 12, 2026
46050e6
conversationId
labkey-nicka May 12, 2026
0873d41
Use segments
labkey-nicka May 13, 2026
8120164
Update jest.setup.ts
labkey-nicka May 13, 2026
0ef487b
Jest tests
labkey-nicka May 13, 2026
7e891e8
nits
labkey-nicka May 13, 2026
7a7d270
styling
labkey-nicka May 13, 2026
3ee3755
nits
labkey-nicka May 13, 2026
8b8054c
fence/lines
labkey-nicka May 13, 2026
c9ab670
7.36.1-fb-mcp-calc-cols.0
labkey-nicka May 13, 2026
8e22853
Styling updates
labkey-nicka May 13, 2026
f2afb81
Fix circular dependency
labkey-nicka May 13, 2026
0238f2e
AppContexts.includeGlobalState
labkey-nicka May 13, 2026
ba97473
7.36.1-fb-mcp-calc-cols.1
labkey-nicka May 13, 2026
f3818d7
7.36.1-fb-mcp-calc-cols.2
labkey-nicka May 13, 2026
f5ed360
docs
labkey-nicka May 13, 2026
b8394bc
4000 character prompt limit (client only)
labkey-nicka May 13, 2026
31933da
Compose prompt on server
labkey-nicka May 13, 2026
3e6b189
7.36.1-fb-mcp-calc-cols.3
labkey-nicka May 13, 2026
98bff5d
Remove variable
labkey-nicka May 14, 2026
24865a8
Styling variables
labkey-nicka May 14, 2026
d8fe2e6
7.36.1-fb-mcp-calc-cols.4
labkey-nicka May 14, 2026
a57ad12
Inline CSS.supports call
labkey-nicka May 14, 2026
1480e0e
7.36.1-fb-mcp-calc-cols.5
labkey-nicka May 14, 2026
03b94d3
Use Modal, expand tests
labkey-nicka May 15, 2026
c8dccc5
lint
labkey-nicka May 15, 2026
d7f3999
7.36.1-fb-mcp-calc-cols.6
labkey-nicka May 15, 2026
7fda387
Prepare release notes
labkey-nicka May 15, 2026
2852cb6
resolveErrorMessage
labkey-nicka May 16, 2026
59ccf68
7.37.0
labkey-nicka May 16, 2026
527ae7e
Bump @labkey/api
labkey-nicka May 16, 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
12 changes: 6 additions & 6 deletions packages/components/package-lock.json

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

4 changes: 2 additions & 2 deletions packages/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@labkey/components",
"version": "7.36.0",
"version": "7.37.0",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down Expand Up @@ -53,7 +53,7 @@
"homepage": "https://github.com/LabKey/labkey-ui-components#readme",
"dependencies": {
"@hello-pangea/dnd": "18.0.1",
"@labkey/api": "1.51.2",
"@labkey/api": "1.51.3",
"@testing-library/dom": "~10.4.1",
"@testing-library/jest-dom": "~6.9.1",
"@testing-library/react": "~16.3.2",
Expand Down
8 changes: 8 additions & 0 deletions packages/components/releaseNotes/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages

### version 7.37.0
*Released*: 15 May 2026
- Calculated Column Assistant
- Add `ChatModal` component for prompt/conversation interaction
- Add `ExpressionAssistantModal` for specific implementation of expression assistant modal
- Add `query-expressionAssistantAgent.api` endpoint to `APIWrapper`
- Move `query-parseCalculatedColumn.api` endpoint wrapper to `APIWrapper`

### version 7.36.0
*Released*: 13 May 2026
- Update heading tags in various page elements for better accessibility
Expand Down
9 changes: 5 additions & 4 deletions packages/components/src/internal/APIWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { AssayAPIWrapper, AssayServerAPIWrapper, getAssayTestAPIWrapper } from './components/assay/APIWrapper';
import { SamplesAPIWrapper, SamplesServerAPIWrapper, getSamplesTestAPIWrapper } from './components/samples/APIWrapper';
import { getSamplesTestAPIWrapper, SamplesAPIWrapper, SamplesServerAPIWrapper } from './components/samples/APIWrapper';
import {
getPicklistTestAPIWrapper,
PicklistAPIWrapper,
PicklistServerAPIWrapper,
getPicklistTestAPIWrapper,
} from './components/picklist/APIWrapper';
import {
getLabelPrintingTestAPIWrapper,
LabelPrintingAPIWrapper,
LabelPrintingServerAPIWrapper,
getLabelPrintingTestAPIWrapper,
} from './components/labelPrinting/APIWrapper';
import {
getSecurityTestAPIWrapper,
Expand All @@ -17,6 +17,7 @@ import {
} from './components/security/APIWrapper';
import {
DomainPropertiesAPIWrapper,
DomainPropertiesServerAPIWrapper,
getDomainPropertiesTestAPIWrapper,
} from './components/domainproperties/APIWrapper';
import { getQueryTestAPIWrapper, QueryAPIWrapper, QueryServerAPIWrapper } from './query/APIWrapper';
Expand Down Expand Up @@ -55,7 +56,7 @@ export function getDefaultAPIWrapper(): ComponentsAPIWrapper {
if (!DEFAULT_WRAPPER) {
DEFAULT_WRAPPER = {
assay: new AssayServerAPIWrapper(),
domain: new DomainPropertiesAPIWrapper(),
domain: new DomainPropertiesServerAPIWrapper(),
entity: new EntityServerAPIWrapper(),
folder: new ServerFolderAPIWrapper(),
query: new QueryServerAPIWrapper(),
Expand Down
3 changes: 1 addition & 2 deletions packages/components/src/internal/AppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
NotebookNotificationSettings,
WorkflowNotificationSettings,
} from './app/models';
import { DomainDetails } from './components/domainproperties/models';
import { EntityDataType } from './components/entities/models';
import { DetailRenderer } from './components/forms/detail/DetailDisplay';
import { SchemaQuery } from '../public/SchemaQuery';
Expand Down Expand Up @@ -80,7 +79,7 @@ export interface AppContext {
export type ExtendableAppContext<T> = AppContext & T;

// The "any" used here should be fine, it gets re-typed in useAppContext, so as long as you're using that and providing
// a type (e.g. useAppContext<MyAppContextType>()) you'll be fine.
// a type (e.g., useAppContext<MyAppContextType>()) you'll be fine.
const Context = createContext<ExtendableAppContext<any>>(undefined);

export interface AppContextProviderProps<T> {
Expand Down
40 changes: 29 additions & 11 deletions packages/components/src/internal/AppContexts.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,51 @@
import React, { FC, PropsWithChildren, useMemo } from 'react';
import { getServerContext } from '@labkey/api';
import React, { FC, ReactNode, useMemo } from 'react';

import { AppContextProvider, ExtendableAppContext } from './AppContext';
import { GlobalStateContextProvider } from './GlobalStateContext';
import { ServerContextProvider, withAppUser } from './components/base/ServerContext';
import { NotificationsContextProvider } from './components/notifications/NotificationsContext';
import { LabelPrintingContextProvider } from './components/labelPrinting/LabelPrintingContextProvider';

interface Props<T = {}> {
interface Props<T = {}> extends PropsWithChildren {
/**
* When true (the default), wraps children in GlobalStateContextProvider (and its dependent
* NotificationsContextProvider / LabelPrintingContextProvider), making app-wide state like
* the navigation context available via useGlobalStateContext.
*
* Set this to false when mounting AppContexts outside of one of our full applications (LKB,
* LKSM, etc.) — for example, a standalone designer page rendered directly into a
* LabKey Server view (see DataClassDesigner, ListDesigner, SampleTypeDesigner, etc.). Those
* entry points do not have app-level routing/navigation and do not consume global state, so
* initializing GlobalStateContext is unnecessary overhead and pulls in providers (like
* navigation) that assume an app context that isn't there.
*
* Rule of thumb: leave this true inside any app that uses our shared navigation/routing;
* set it to false for one-off React entry points embedded in a server-rendered page.
*/
includeGlobalState?: boolean;
initialAppContext?: ExtendableAppContext<T>;
children?: ReactNode | undefined
}

/**
* AppContexts is where you should add any additional contexts needed by our applications. At the moment all of our
* apps share the same basic context configurations, and this component makes it easy for us to update all of our Apps
* at once, and reduce the level of nesting needed in our Route configurations.
* apps share the same basic context configurations. This component makes it easy for us to update all of our Apps
* at once and reduce the level of nesting needed in our Route configurations.
*/
export const AppContexts: FC<Props> = props => {
const { children, initialAppContext } = props;
const { children, includeGlobalState = true, initialAppContext } = props;
const initialServerContext = useMemo(() => withAppUser(getServerContext()), []);
return (
<ServerContextProvider initialContext={initialServerContext}>
<AppContextProvider initialContext={initialAppContext}>
<GlobalStateContextProvider>
<NotificationsContextProvider>
<LabelPrintingContextProvider>{children}</LabelPrintingContextProvider>
</NotificationsContextProvider>
</GlobalStateContextProvider>
{includeGlobalState && (
<GlobalStateContextProvider>
<NotificationsContextProvider>
<LabelPrintingContextProvider>{children}</LabelPrintingContextProvider>
</NotificationsContextProvider>
</GlobalStateContextProvider>
)}
{!includeGlobalState && <>{children}</>}
</AppContextProvider>
</ServerContextProvider>
);
Expand Down
190 changes: 190 additions & 0 deletions packages/components/src/internal/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import React from 'react';
import { render } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';

import { BaseModal, Modal, ModalHeader } from './Modal';

describe('Modal components', () => {
describe('BaseModal', () => {
test('renders children into a portal with default classes', () => {
render(
<BaseModal>
<div className="inner-content">hello</div>
</BaseModal>
);

const wrapper = document.querySelector('.modal-wrapper');
expect(wrapper).not.toBeNull();
expect(document.querySelector('.modal-backdrop')).not.toBeNull();

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(document.querySelector('.modal-content .inner-content').textContent).toEqual('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);
});

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);
});

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);
});

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);
unmount();
expect(document.body.classList.contains('no-scroll')).toBe(false);
});
});

describe('ModalHeader', () => {
test('renders title and no close button by default', () => {
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('button.close')).toBeNull();
});

test('renders close button when onCancel is provided and invokes it on click', async () => {
const onCancel = jest.fn();
render(<ModalHeader onCancel={onCancel} title="t" />);
const closeBtn = document.querySelector('button.close');
expect(closeBtn).not.toBeNull();
expect(closeBtn.querySelector('.sr-only').textContent).toEqual('Close');
await userEvent.click(closeBtn);
expect(onCancel).toHaveBeenCalledTimes(1);
});

test('does not render the title element when title is falsy', () => {
render(<ModalHeader onCancel={jest.fn()} title={null} />);
expect(document.querySelector('.modal-title')).toBeNull();
// Close button should still be present
expect(document.querySelector('button.close')).not.toBeNull();
});

test('renders children inside the header', () => {
render(
<ModalHeader title="t">
<span className="extra-child">extra</span>
</ModalHeader>
);
const header = document.querySelector('.modal-header');
expect(header.querySelector('.extra-child').textContent).toEqual('extra');
});
});

describe('Modal', () => {
test('renders children inside modal-body', () => {
render(
<Modal onCancel={jest.fn()}>
<div className="body-child">body content</div>
</Modal>
);
const body = document.querySelector('.modal-body');
expect(body).not.toBeNull();
expect(body.querySelector('.body-child').textContent).toEqual('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('button.close')).not.toBeNull();
});

test('does not render header when no title, onCancel, or custom header is given', () => {
render(<Modal onConfirm={jest.fn()}>body</Modal>);
expect(document.querySelector('.modal-header')).toBeNull();
});

test('renders custom header instead of default ModalHeader', () => {
render(
<Modal header={<div className="custom-header">custom</div>} onCancel={jest.fn()} title="ignored">
body
</Modal>
);
// 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');
});

test('renders custom footer when provided and skips ModalButtons', () => {
render(
<Modal footer={<span className="custom-footer">f</span>} onCancel={jest.fn()} onConfirm={jest.fn()}>
body
</Modal>
);
const footer = document.querySelector('.modal-footer');
expect(footer).not.toBeNull();
expect(footer.querySelector('.custom-footer').textContent).toEqual('f');
// ModalButtons applies the 'modal-buttons' class — should not be present
expect(document.querySelector('.modal-buttons')).toBeNull();
});

test('renders ModalButtons when no footer is provided', async () => {
const onCancel = jest.fn();
const onConfirm = jest.fn();
render(
<Modal cancelText="Nope" confirmText="Go" onCancel={onCancel} onConfirm={onConfirm}>
body
</Modal>
);

const buttons = document.querySelector('.modal-footer.modal-buttons');
expect(buttons).not.toBeNull();

const buttonEls = buttons.querySelectorAll('button');
// First button is cancel, last is confirm
const cancelBtn = Array.from(buttonEls).find(b => b.textContent === 'Nope');
const confirmBtn = Array.from(buttonEls).find(b => b.textContent === 'Go');
expect(cancelBtn).toBeDefined();
expect(confirmBtn).toBeDefined();

await userEvent.click(cancelBtn);
await userEvent.click(confirmBtn);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledTimes(1);
});

test('renders footerContent inside default ModalButtons', () => {
render(
<Modal footerContent={<span className="fc">fc</span>} onCancel={jest.fn()} onConfirm={jest.fn()}>
body
</Modal>
);
const buttons = document.querySelector('.modal-footer.modal-buttons');
expect(buttons).not.toBeNull();
expect(buttons.querySelector('.fc').textContent).toEqual('fc');
});

test('passes bsSize and className down to BaseModal', () => {
render(
<Modal bsSize="lg" className="my-modal" onCancel={jest.fn()}>
body
</Modal>
);
const dialog = document.querySelector('.modal-dialog');
expect(dialog.classList.contains('modal-lg')).toBe(true);
expect(dialog.classList.contains('my-modal')).toBe(true);
});
});
});
Loading