Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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.39.1",
"version": "7.40.0",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
12 changes: 10 additions & 2 deletions packages/components/releaseNotes/components.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages

### version TBD
*Released*: TBD
### version 7.40.0
*Released*: 28 May 2026
- Calculated Column Assistant
- Keep the modal open after clicking apply expression
- Send the current field to server to distinguish from a field set
- Display column type for validated expressions
- Refill prompt when request interrupted

### version 7.39.0
*Released*: 27 May 2026
- Misc. accessibility improvements
- Auto-link to study input field labels
- Remove tabIndex value from `DomainRow`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,9 @@ export const CalculatedFieldOptions: FC<CalculatedFieldOptionsProps> = memo(prop
setError(undefined);
setParsedType(undefined);
validateExpression(analysis, true);
close();
incrementClientSideMetricCount(EXPR_ASST_METRIC_FEATURE_AREA, 'applyExpression');
},
[close, inputId, onChange, validateExpression]
[inputId, onChange, validateExpression]
);

const onOpenAssistant = useCallback(() => {
Expand Down Expand Up @@ -293,12 +292,12 @@ export const CalculatedFieldOptions: FC<CalculatedFieldOptionsProps> = memo(prop
</div>
{show && (
<ExpressionAssistantModal
field={field}
// Only inform the modal of the error if there is an invalid expression
fieldError={field.valueExpression ? error : undefined}
fieldExpression={field.valueExpression}
getDomainFields={getDomainFields}
onApplyExpression={handleApplyExpression}
onCancel={close}
onComplete={handleApplyExpression}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ function makeField(name: string, rangeURI = 'http://www.w3.org/2001/XMLSchema#st
return DomainField.create({ name, rangeURI, PHI: phi });
}

const DEFAULT_FIELDS = [makeField('A'), makeField('B', 'http://www.w3.org/2001/XMLSchema#int')];
function makeCalculatedField(valueExpression?: string, name = 'CALC_FIELD'): DomainField {
return DomainField.create({ name, rangeURI: 'http://www.w3.org/2001/XMLSchema#calculated', valueExpression });
}

const CALC_FIELD = makeCalculatedField('SELECT 1');

const DEFAULT_FIELDS = [makeField('A'), makeField('B', 'http://www.w3.org/2001/XMLSchema#int'), CALC_FIELD];
function getDomainFields(fields = DEFAULT_FIELDS) {
return () => ({
domainFields: List<DomainField>(fields),
Expand All @@ -56,6 +62,7 @@ function getDomainFields(fields = DEFAULT_FIELDS) {

function defaultProps(overrides?: Partial<ExpressionAssistantModalProps>): ExpressionAssistantModalProps {
return {
field: CALC_FIELD,
getDomainFields: getDomainFields(),
onCancel: jest.fn(),
...overrides,
Expand Down Expand Up @@ -86,7 +93,10 @@ describe('ExpressionAssistantModal', () => {
const expressionAssistant = jest.fn();

// Act
renderWithAppContext(<ExpressionAssistantModal {...defaultProps()} />, makeApiContext(expressionAssistant));
renderWithAppContext(
<ExpressionAssistantModal {...defaultProps()} field={makeCalculatedField(undefined)} />,
makeApiContext(expressionAssistant)
);

// Assert - one assistant intro message with the NEW prompt text and no SQL segment
const messages = chatModalProps.messages as ChatMessage[];
Expand All @@ -99,10 +109,7 @@ describe('ExpressionAssistantModal', () => {

test('shows the CHANGE intro with a SQL segment when fieldExpression is provided', () => {
// Arrange / Act
renderWithAppContext(
<ExpressionAssistantModal {...defaultProps()} fieldExpression="SELECT 1" />,
makeApiContext()
);
renderWithAppContext(<ExpressionAssistantModal {...defaultProps()} />, makeApiContext());

// Assert - intro begins with the CHANGE prompt and includes a sql segment containing the existing expression
const intro = (chatModalProps.messages as ChatMessage[])[0];
Expand All @@ -120,7 +127,11 @@ describe('ExpressionAssistantModal', () => {

// Act
renderWithAppContext(
<ExpressionAssistantModal {...defaultProps()} fieldError="boom" fieldExpression="SELECT bad" />,
<ExpressionAssistantModal
{...defaultProps()}
field={makeCalculatedField('SELECT bad')}
fieldError="boom"
/>,
makeApiContext(expressionAssistant)
);

Expand Down Expand Up @@ -188,12 +199,17 @@ describe('ExpressionAssistantModal', () => {
test('passes columnMap and PHI columns derived from the provided domain fields', async () => {
// Arrange
const fields = [
CALC_FIELD,
makeField('plain', 'http://www.w3.org/2001/XMLSchema#string'),
makeField('secret', 'http://www.w3.org/2001/XMLSchema#string', 'Restricted'),
];
const expressionAssistant = jest.fn().mockResolvedValue({ conversationId: 'c', success: true, text: 'ok' });
renderWithAppContext(
<ExpressionAssistantModal getDomainFields={getDomainFields(fields)} onCancel={jest.fn()} />,
<ExpressionAssistantModal
field={CALC_FIELD}
getDomainFields={getDomainFields(fields)}
onCancel={jest.fn()}
/>,
makeApiContext(expressionAssistant)
);

Expand Down Expand Up @@ -308,11 +324,11 @@ describe('ExpressionAssistantModal', () => {
});

describe('renderSegment / SqlExpression', () => {
test('expression segments render an Apply Expression action that calls onComplete with the SQL', () => {
test('expression segments render an Apply Expression action that calls onApplyExpression with the SQL', () => {
// Arrange
const onComplete = jest.fn();
const onApplyExpression = jest.fn();
renderWithAppContext(
<ExpressionAssistantModal {...defaultProps()} onComplete={onComplete} />,
<ExpressionAssistantModal {...defaultProps()} onApplyExpression={onApplyExpression} />,
makeApiContext()
);
// Render the segment ourselves into a container so we can interact with it
Expand All @@ -322,17 +338,17 @@ describe('ExpressionAssistantModal', () => {
const { unmount } = render(<div>{node}</div>);
fireEvent.click(screen.getByRole('button', { name: /apply expression/i }));

// Assert - the SQL is shown and clicking Apply forwards the expression to onComplete
// Assert - the SQL is shown, and clicking Apply forwards the expression to onApplyExpression
expect(screen.getByText('SELECT 1')).toBeInTheDocument();
expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledWith('SELECT 1');
expect(onApplyExpression).toHaveBeenCalledTimes(1);
expect(onApplyExpression).toHaveBeenCalledWith('SELECT 1');
unmount();
});

test('sql segments render read-only without an Apply action', () => {
// Arrange
renderWithAppContext(
<ExpressionAssistantModal {...defaultProps()} onComplete={jest.fn()} />,
<ExpressionAssistantModal {...defaultProps()} onApplyExpression={jest.fn()} />,
makeApiContext()
);
const node = chatModalProps.renderSegment({ type: 'sql', sql: 'SELECT 2' }, 0);
Expand All @@ -345,15 +361,15 @@ describe('ExpressionAssistantModal', () => {
expect(screen.queryByRole('button', { name: /apply expression/i })).not.toBeInTheDocument();
});

test('expression segment without onComplete still renders read-only', () => {
// Arrange - omit onComplete
test('expression segment without onApplyExpression still renders read-only', () => {
// Arrange - omit onApplyExpression
renderWithAppContext(<ExpressionAssistantModal {...defaultProps()} />, makeApiContext());
const node = chatModalProps.renderSegment({ type: 'expression', sql: 'SELECT 3' }, 0);

// Act
render(<div>{node}</div>);

// Assert - SQL still renders, but there is no Apply action when no onComplete is supplied
// Assert - SQL still renders, but there is no Apply action when no onApplyExpression is supplied
expect(screen.getByText('SELECT 3')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /apply expression/i })).not.toBeInTheDocument();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { useAppContext } from '../../AppContext';
import { generateId } from '../../util/utils';
import { ChatModal, RenderSegment } from '../mcp/ChatModal';
import { ChatMessage, ChatRole, ChatSegment } from '../mcp/models';
import { DomainField, GetDomainFields, SystemField } from './models';
import { useRequestHandler } from '../../util/RequestHandler';
import { incrementClientSideMetricCount } from '../../actions';
import { getColumnTypeMap, getPHIColumnNames } from './CalculatedFieldOptions';
import { getColumnTypeMap, getPHIColumnNames, typeToDisplay } from './CalculatedFieldOptions';
import { ExpressionAssistOptions } from './actions';
import { resolveErrorMessage } from '../../util/messaging';

Expand All @@ -26,40 +27,61 @@ function createChatMessage(message: Partial<ChatMessage>): ChatMessage {
}

interface SqlSnippetProps {
onApply?: (sql: string) => void;
jdbcType?: string;
onApplyExpression?: (sql: string) => void;
readOnly?: boolean;
sql: string;
}

const SqlExpression: FC<SqlSnippetProps> = memo(({ onApply, readOnly, sql }) => {
const handleApply = useCallback(() => onApply?.(sql), [onApply, sql]);
const SqlExpression: FC<SqlSnippetProps> = memo(({ onApplyExpression, jdbcType, readOnly, sql }) => {
const [animating, setAnimating] = useState<boolean>(false);
const handleApply = useCallback(() => {
setAnimating(true);
onApplyExpression(sql);
}, [onApplyExpression, sql]);

const onAnimationEnd = useCallback(() => {
setAnimating(false);
}, []);

return (
<div className="assistant-expression">
<pre>
<code className="language-sql">{sql}</code>
</pre>
{!readOnly && onApply && (
<button className="clickable-text" onClick={handleApply} type="button">
<i className="fa fa-check" /> Apply Expression
</button>
{!readOnly && onApplyExpression && (
<>
<button className="clickable-text" onClick={handleApply} type="button">
<i
className={classNames('fa fa-check', { 'bounce-effect': animating })}
onAnimationEnd={onAnimationEnd}
/>{' '}
Apply Expression
</button>
{jdbcType && (
<span className="assistant-expression__type">
The calculated data type is {typeToDisplay(jdbcType).toLowerCase()}
</span>
)}
</>
)}
</div>
);
});
SqlExpression.displayName = 'SqlExpression';

export interface ExpressionAssistantModalProps {
field: DomainField;
fieldError?: string;
fieldExpression?: string;
getDomainFields: GetDomainFields;
onApplyExpression?: (analysis: string) => void;
onCancel: () => void;
onComplete?: (analysis: string) => void;
}

function useExpressionAssistance(
domainFields: DomainField[],
systemFields: SystemField[],
fieldExpression?: string,
field: DomainField,
fieldError?: string
) {
const [conversationId, setConversationId] = useState<string>();
Expand All @@ -68,9 +90,9 @@ function useExpressionAssistance(
let segments: ChatSegment[] | undefined;
if (fieldError) {
text = VALIDATE_INTRO;
} else if (fieldExpression) {
} else if (field.valueExpression) {
text = CHANGE_INTRO;
segments = [{ type: 'sql', sql: fieldExpression }];
segments = [{ type: 'sql', sql: field.valueExpression }];
} else {
text = NEW_INTRO;
}
Expand Down Expand Up @@ -122,8 +144,9 @@ function useExpressionAssistance(

if (conversationId === undefined) {
options.domainFields = combinedFields;
options.field = field;
options.fieldError = fieldError;
options.fieldExpression = fieldExpression;
options.fieldExpression = field.valueExpression;
}

const response = await api.domain.expressionAssistant(options);
Expand Down Expand Up @@ -160,8 +183,8 @@ function useExpressionAssistance(
columnMap,
combinedFields,
conversationId,
field,
fieldError,
fieldExpression,
phiColumns,
pushMessage,
requestHandler,
Expand All @@ -187,29 +210,36 @@ function useExpressionAssistance(
}

export const ExpressionAssistantModal: FC<ExpressionAssistantModalProps> = memo(props => {
const { fieldError, fieldExpression, getDomainFields, onCancel, onComplete } = props;
const { field, fieldError, getDomainFields, onApplyExpression, onCancel } = props;
const { domainFields, systemFields } = useMemo(() => {
const { domainFields, systemFields } = getDomainFields();
return { domainFields: domainFields.toArray(), systemFields };
}, [getDomainFields]);
const { isPending, messages, onInterrupt, sendPrompt } = useExpressionAssistance(
domainFields,
systemFields,
fieldExpression,
field,
fieldError
);

const renderSegment = useCallback<RenderSegment>(
(segment, index) => {
if (segment.type === 'expression' && segment.sql) {
return <SqlExpression key={index} onApply={onComplete} sql={segment.sql} />;
return (
<SqlExpression
jdbcType={segment.jdbcType}
key={index}
onApplyExpression={onApplyExpression}
sql={segment.sql}
/>
);
}
if (segment.type === 'sql' && segment.sql) {
return <SqlExpression key={index} readOnly sql={segment.sql} />;
}
return undefined;
},
[onComplete]
[onApplyExpression]
);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1532,6 +1532,7 @@ export interface ExpressionAssistOptions {
containerPath?: string;
conversationId?: string;
domainFields?: (DomainField | SystemField)[];
field?: DomainField;
fieldError?: string;
fieldExpression?: string;
phiColumns?: string[];
Expand All @@ -1554,11 +1555,22 @@ export interface ExpressionAssistResponse {
}

export function expressionAssistant(options: ExpressionAssistOptions): Promise<ExpressionAssistResponse> {
const { containerPath, requestHandler, ...jsonData } = options;
const { containerPath, domainFields, field, requestHandler, ...jsonData } = options;

const serializedField = field ? DomainField.serialize(field) : undefined;
if (serializedField) {
// Do not pass the value expression for the current field as that is supplied separately
delete serializedField.valueExpression;
}

return request<ExpressionAssistResponse>({
url: ActionURL.buildURL('query', 'expressionAssistantAgent.api', containerPath),
method: 'POST',
jsonData,
jsonData: {
...jsonData,
domainFields: domainFields?.map(f => (f instanceof DomainField ? DomainField.serialize(f) : f)),
field: serializedField,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the field here is the current calculated field that you are working on, right? I didn't look at the server side changes yet, but how is it using this field info? I see that it was previously using the field valueExpression, which makes sense, but what else about the field would be helpful (something like the name/label/descprition)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, other metadata that it can refer to. I didn't want to caveat it too much right now.

},
errorLogMsg: 'Failed to assist with expression',
requestHandler,
});
Expand Down
Loading