Skip to content

Commit c1e3346

Browse files
authored
Calculated Column Expression Assistant (#2002)
1 parent 590c6c4 commit c1e3346

35 files changed

Lines changed: 2752 additions & 1056 deletions

packages/components/package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/components/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@labkey/components",
3-
"version": "7.36.0",
3+
"version": "7.37.0",
44
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
55
"sideEffects": false,
66
"files": [
@@ -53,7 +53,7 @@
5353
"homepage": "https://github.com/LabKey/labkey-ui-components#readme",
5454
"dependencies": {
5555
"@hello-pangea/dnd": "18.0.1",
56-
"@labkey/api": "1.51.2",
56+
"@labkey/api": "1.51.3",
5757
"@testing-library/dom": "~10.4.1",
5858
"@testing-library/jest-dom": "~6.9.1",
5959
"@testing-library/react": "~16.3.2",

packages/components/releaseNotes/components.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
# @labkey/components
22
Components, models, actions, and utility functions for LabKey applications and pages
33

4+
### version 7.37.0
5+
*Released*: 15 May 2026
6+
- Calculated Column Assistant
7+
- Add `ChatModal` component for prompt/conversation interaction
8+
- Add `ExpressionAssistantModal` for specific implementation of expression assistant modal
9+
- Add `query-expressionAssistantAgent.api` endpoint to `APIWrapper`
10+
- Move `query-parseCalculatedColumn.api` endpoint wrapper to `APIWrapper`
11+
412
### version 7.36.0
513
*Released*: 13 May 2026
614
- Update heading tags in various page elements for better accessibility

packages/components/src/internal/APIWrapper.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { AssayAPIWrapper, AssayServerAPIWrapper, getAssayTestAPIWrapper } from './components/assay/APIWrapper';
2-
import { SamplesAPIWrapper, SamplesServerAPIWrapper, getSamplesTestAPIWrapper } from './components/samples/APIWrapper';
2+
import { getSamplesTestAPIWrapper, SamplesAPIWrapper, SamplesServerAPIWrapper } from './components/samples/APIWrapper';
33
import {
4+
getPicklistTestAPIWrapper,
45
PicklistAPIWrapper,
56
PicklistServerAPIWrapper,
6-
getPicklistTestAPIWrapper,
77
} from './components/picklist/APIWrapper';
88
import {
9+
getLabelPrintingTestAPIWrapper,
910
LabelPrintingAPIWrapper,
1011
LabelPrintingServerAPIWrapper,
11-
getLabelPrintingTestAPIWrapper,
1212
} from './components/labelPrinting/APIWrapper';
1313
import {
1414
getSecurityTestAPIWrapper,
@@ -17,6 +17,7 @@ import {
1717
} from './components/security/APIWrapper';
1818
import {
1919
DomainPropertiesAPIWrapper,
20+
DomainPropertiesServerAPIWrapper,
2021
getDomainPropertiesTestAPIWrapper,
2122
} from './components/domainproperties/APIWrapper';
2223
import { getQueryTestAPIWrapper, QueryAPIWrapper, QueryServerAPIWrapper } from './query/APIWrapper';
@@ -55,7 +56,7 @@ export function getDefaultAPIWrapper(): ComponentsAPIWrapper {
5556
if (!DEFAULT_WRAPPER) {
5657
DEFAULT_WRAPPER = {
5758
assay: new AssayServerAPIWrapper(),
58-
domain: new DomainPropertiesAPIWrapper(),
59+
domain: new DomainPropertiesServerAPIWrapper(),
5960
entity: new EntityServerAPIWrapper(),
6061
folder: new ServerFolderAPIWrapper(),
6162
query: new QueryServerAPIWrapper(),

packages/components/src/internal/AppContext.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {
2222
NotebookNotificationSettings,
2323
WorkflowNotificationSettings,
2424
} from './app/models';
25-
import { DomainDetails } from './components/domainproperties/models';
2625
import { EntityDataType } from './components/entities/models';
2726
import { DetailRenderer } from './components/forms/detail/DetailDisplay';
2827
import { SchemaQuery } from '../public/SchemaQuery';
@@ -80,7 +79,7 @@ export interface AppContext {
8079
export type ExtendableAppContext<T> = AppContext & T;
8180

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

8685
export interface AppContextProviderProps<T> {

packages/components/src/internal/AppContexts.tsx

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,51 @@
1+
import React, { FC, PropsWithChildren, useMemo } from 'react';
12
import { getServerContext } from '@labkey/api';
2-
import React, { FC, ReactNode, useMemo } from 'react';
33

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

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

1530
/**
1631
* AppContexts is where you should add any additional contexts needed by our applications. At the moment all of our
17-
* apps share the same basic context configurations, and this component makes it easy for us to update all of our Apps
18-
* at once, and reduce the level of nesting needed in our Route configurations.
32+
* apps share the same basic context configurations. This component makes it easy for us to update all of our Apps
33+
* at once and reduce the level of nesting needed in our Route configurations.
1934
*/
2035
export const AppContexts: FC<Props> = props => {
21-
const { children, initialAppContext } = props;
36+
const { children, includeGlobalState = true, initialAppContext } = props;
2237
const initialServerContext = useMemo(() => withAppUser(getServerContext()), []);
2338
return (
2439
<ServerContextProvider initialContext={initialServerContext}>
2540
<AppContextProvider initialContext={initialAppContext}>
26-
<GlobalStateContextProvider>
27-
<NotificationsContextProvider>
28-
<LabelPrintingContextProvider>{children}</LabelPrintingContextProvider>
29-
</NotificationsContextProvider>
30-
</GlobalStateContextProvider>
41+
{includeGlobalState && (
42+
<GlobalStateContextProvider>
43+
<NotificationsContextProvider>
44+
<LabelPrintingContextProvider>{children}</LabelPrintingContextProvider>
45+
</NotificationsContextProvider>
46+
</GlobalStateContextProvider>
47+
)}
48+
{!includeGlobalState && <>{children}</>}
3149
</AppContextProvider>
3250
</ServerContextProvider>
3351
);
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react';
3+
import { userEvent } from '@testing-library/user-event';
4+
5+
import { BaseModal, Modal, ModalHeader } from './Modal';
6+
7+
describe('Modal components', () => {
8+
describe('BaseModal', () => {
9+
test('renders children into a portal with default classes', () => {
10+
render(
11+
<BaseModal>
12+
<div className="inner-content">hello</div>
13+
</BaseModal>
14+
);
15+
16+
const wrapper = document.querySelector('.modal-wrapper');
17+
expect(wrapper).not.toBeNull();
18+
expect(document.querySelector('.modal-backdrop')).not.toBeNull();
19+
20+
const dialog = document.querySelector('.modal-dialog');
21+
expect(dialog).not.toBeNull();
22+
expect(dialog.classList.contains('modal-sm')).toBe(false);
23+
expect(dialog.classList.contains('modal-lg')).toBe(false);
24+
25+
expect(document.querySelector('.modal-content .inner-content').textContent).toEqual('hello');
26+
});
27+
28+
test('applies bsSize="sm" class', () => {
29+
render(<BaseModal bsSize="sm">child</BaseModal>);
30+
const dialog = document.querySelector('.modal-dialog');
31+
expect(dialog.classList.contains('modal-sm')).toBe(true);
32+
expect(dialog.classList.contains('modal-lg')).toBe(false);
33+
});
34+
35+
test('applies bsSize="lg" class', () => {
36+
render(<BaseModal bsSize="lg">child</BaseModal>);
37+
const dialog = document.querySelector('.modal-dialog');
38+
expect(dialog.classList.contains('modal-lg')).toBe(true);
39+
expect(dialog.classList.contains('modal-sm')).toBe(false);
40+
});
41+
42+
test('applies custom className', () => {
43+
render(<BaseModal className="custom-class">child</BaseModal>);
44+
const dialog = document.querySelector('.modal-dialog');
45+
expect(dialog.classList.contains('custom-class')).toBe(true);
46+
});
47+
48+
test('toggles "no-scroll" on document.body while mounted', () => {
49+
expect(document.body.classList.contains('no-scroll')).toBe(false);
50+
const { unmount } = render(<BaseModal>child</BaseModal>);
51+
expect(document.body.classList.contains('no-scroll')).toBe(true);
52+
unmount();
53+
expect(document.body.classList.contains('no-scroll')).toBe(false);
54+
});
55+
});
56+
57+
describe('ModalHeader', () => {
58+
test('renders title and no close button by default', () => {
59+
render(<ModalHeader title="My Title" />);
60+
const header = document.querySelector('.modal-header');
61+
expect(header).not.toBeNull();
62+
expect(header.querySelector('.modal-title').textContent).toEqual('My Title');
63+
expect(header.querySelector('button.close')).toBeNull();
64+
});
65+
66+
test('renders close button when onCancel is provided and invokes it on click', async () => {
67+
const onCancel = jest.fn();
68+
render(<ModalHeader onCancel={onCancel} title="t" />);
69+
const closeBtn = document.querySelector('button.close');
70+
expect(closeBtn).not.toBeNull();
71+
expect(closeBtn.querySelector('.sr-only').textContent).toEqual('Close');
72+
await userEvent.click(closeBtn);
73+
expect(onCancel).toHaveBeenCalledTimes(1);
74+
});
75+
76+
test('does not render the title element when title is falsy', () => {
77+
render(<ModalHeader onCancel={jest.fn()} title={null} />);
78+
expect(document.querySelector('.modal-title')).toBeNull();
79+
// Close button should still be present
80+
expect(document.querySelector('button.close')).not.toBeNull();
81+
});
82+
83+
test('renders children inside the header', () => {
84+
render(
85+
<ModalHeader title="t">
86+
<span className="extra-child">extra</span>
87+
</ModalHeader>
88+
);
89+
const header = document.querySelector('.modal-header');
90+
expect(header.querySelector('.extra-child').textContent).toEqual('extra');
91+
});
92+
});
93+
94+
describe('Modal', () => {
95+
test('renders children inside modal-body', () => {
96+
render(
97+
<Modal onCancel={jest.fn()}>
98+
<div className="body-child">body content</div>
99+
</Modal>
100+
);
101+
const body = document.querySelector('.modal-body');
102+
expect(body).not.toBeNull();
103+
expect(body.querySelector('.body-child').textContent).toEqual('body content');
104+
});
105+
106+
test('renders default ModalHeader when title or onCancel is provided and no custom header', () => {
107+
render(<Modal onCancel={jest.fn()} title="Hello" />);
108+
const header = document.querySelector('.modal-header');
109+
expect(header).not.toBeNull();
110+
expect(header.querySelector('.modal-title').textContent).toEqual('Hello');
111+
expect(header.querySelector('button.close')).not.toBeNull();
112+
});
113+
114+
test('does not render header when no title, onCancel, or custom header is given', () => {
115+
render(<Modal onConfirm={jest.fn()}>body</Modal>);
116+
expect(document.querySelector('.modal-header')).toBeNull();
117+
});
118+
119+
test('renders custom header instead of default ModalHeader', () => {
120+
render(
121+
<Modal header={<div className="custom-header">custom</div>} onCancel={jest.fn()} title="ignored">
122+
body
123+
</Modal>
124+
);
125+
// Default ModalHeader should not render when a custom header is supplied
126+
expect(document.querySelector('.modal-header')).toBeNull();
127+
expect(document.querySelector('.custom-header').textContent).toEqual('custom');
128+
});
129+
130+
test('renders custom footer when provided and skips ModalButtons', () => {
131+
render(
132+
<Modal footer={<span className="custom-footer">f</span>} onCancel={jest.fn()} onConfirm={jest.fn()}>
133+
body
134+
</Modal>
135+
);
136+
const footer = document.querySelector('.modal-footer');
137+
expect(footer).not.toBeNull();
138+
expect(footer.querySelector('.custom-footer').textContent).toEqual('f');
139+
// ModalButtons applies the 'modal-buttons' class — should not be present
140+
expect(document.querySelector('.modal-buttons')).toBeNull();
141+
});
142+
143+
test('renders ModalButtons when no footer is provided', async () => {
144+
const onCancel = jest.fn();
145+
const onConfirm = jest.fn();
146+
render(
147+
<Modal cancelText="Nope" confirmText="Go" onCancel={onCancel} onConfirm={onConfirm}>
148+
body
149+
</Modal>
150+
);
151+
152+
const buttons = document.querySelector('.modal-footer.modal-buttons');
153+
expect(buttons).not.toBeNull();
154+
155+
const buttonEls = buttons.querySelectorAll('button');
156+
// First button is cancel, last is confirm
157+
const cancelBtn = Array.from(buttonEls).find(b => b.textContent === 'Nope');
158+
const confirmBtn = Array.from(buttonEls).find(b => b.textContent === 'Go');
159+
expect(cancelBtn).toBeDefined();
160+
expect(confirmBtn).toBeDefined();
161+
162+
await userEvent.click(cancelBtn);
163+
await userEvent.click(confirmBtn);
164+
expect(onCancel).toHaveBeenCalledTimes(1);
165+
expect(onConfirm).toHaveBeenCalledTimes(1);
166+
});
167+
168+
test('renders footerContent inside default ModalButtons', () => {
169+
render(
170+
<Modal footerContent={<span className="fc">fc</span>} onCancel={jest.fn()} onConfirm={jest.fn()}>
171+
body
172+
</Modal>
173+
);
174+
const buttons = document.querySelector('.modal-footer.modal-buttons');
175+
expect(buttons).not.toBeNull();
176+
expect(buttons.querySelector('.fc').textContent).toEqual('fc');
177+
});
178+
179+
test('passes bsSize and className down to BaseModal', () => {
180+
render(
181+
<Modal bsSize="lg" className="my-modal" onCancel={jest.fn()}>
182+
body
183+
</Modal>
184+
);
185+
const dialog = document.querySelector('.modal-dialog');
186+
expect(dialog.classList.contains('modal-lg')).toBe(true);
187+
expect(dialog.classList.contains('my-modal')).toBe(true);
188+
});
189+
});
190+
});

0 commit comments

Comments
 (0)