Skip to content
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.62.0",
"version": "7.62.1",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
5 changes: 0 additions & 5 deletions packages/components/src/internal/app/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,8 @@ import { ModuleContext, resolveModuleContext } from '../components/base/ServerCo
// These ids should match what is used by the MenuProviders in the Java code, so we can avoid toLowerCase comparisons.
export const LKS_PRODUCT_ID = 'LabKeyServer';
export const BIOLOGICS_PRODUCT_ID = 'Biologics';
export const BIOLOGICS_ENTERPRISE_PRODUCT_KEY = 'limsEnterprise';
export const BIOLOGICS_STARTER_PRODUCT_KEY = 'limsStarter'
export const LIMS_PRODUCT_ID = 'LIMS';
export const LIMS_PRODUCT_KEY = 'labkeyLims';
export const SAMPLE_MANAGER_PRODUCT_ID = 'SampleManager';
export const SAMPLE_MANAGER_STARTER_PRODUCT_KEY = 'sampleManagerStarter';
export const SAMPLE_MANAGER_PROFESSIONAL_PRODUCT_KEY = 'sampleManagerProfessional'
export const FREEZER_MANAGER_PRODUCT_ID = 'FreezerManager';

export function isFreezerManagementEnabled(moduleContext?: ModuleContext): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,10 @@ export const LookupCell: FC<LookupCellProps> = memo(props => {
autoFocus
defaultInputValue={defaultInputValue}
disabled={disabled}
multiple={col.isMultiChoice}
onBlur={onBlur}
onChange={onSelectChange}
onKeyDown={onKeyDown}
queryColumn={col}
skipJoinValues={true}
value={col.isMultiChoice ? rawValues : rawValues[0]}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const QUERY_INFO = QueryInfo.fromJsonForTests({

const DEFAULT_PROPS: BulkUpdateFormProps = {
api: getTestAPIWrapper(jest.fn),
nounPlural: QUERY,
nounSingular: QUERY,
onComplete: jest.fn(),
onCancel: jest.fn(),
queryInfo: QUERY_INFO,
Expand Down Expand Up @@ -105,8 +107,8 @@ describe('BulkUpdateForm', () => {
expect(document.querySelectorAll('.query-info-form')).toHaveLength(1);
});
expect(document.querySelectorAll('.toggle-group-icon')).toHaveLength(2);
expect(document.querySelectorAll('input#update')).toHaveLength(1);
expect(document.querySelector('input#update').getAttribute('value')).toBe('abc');
expect(document.querySelectorAll('input[name="update"]')).toHaveLength(1);
expect(document.querySelector('input[name="update"]')).toHaveValue('abc');
expect(document.querySelectorAll('.attachment-card__name')).toHaveLength(1);
expect(document.querySelector('.attachment-card__name')).toHaveTextContent('test.txt');
});
Expand All @@ -118,7 +120,7 @@ describe('BulkUpdateForm', () => {
expect(document.querySelectorAll('.query-info-form')).toHaveLength(1);
});
expect(document.querySelectorAll('.toggle-group-icon')).toHaveLength(1);
expect(document.querySelectorAll('input#update')).toHaveLength(0);
expect(document.querySelectorAll('input[name="update"]')).toHaveLength(0);
expect(document.querySelectorAll('.attachment-card__name')).toHaveLength(1);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,13 @@ export const AmountUnitInput: FC<InputRendererProps> = memo(props => {
/>
<TextInput
aria-label="Amount"
disableInput={disabled}
disabled={disabled}
elementWrapperClassName=""
hasMixedValue={hasMixedAmountValue}
onChange={onAmountChange}
queryColumn={amountCol}
rowClassName="col-sm-5 col-xs-6"
showLabel={false}
type="number"
validations="sampleAmount"
value={amountValue ? String(amountValue) : amountValue}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('AppendUnitsInput', () => {
test('without formsy', () => {
// Without Formsy it should not crash the page
renderWithAppContext(<AppendUnitsInput col={column} data={undefined} value={undefined} />);
expect(document.querySelector('#appendUnitsColumn')).toBeNull();
expect(document.querySelector('input[name="appendUnitsColumn"]')).not.toBeInTheDocument();
});

test('with formsy', () => {
Expand All @@ -31,6 +31,6 @@ describe('AppendUnitsInput', () => {
<AppendUnitsInput col={column} data={undefined} formsy value={undefined} />
</Formsy>
);
expect(document.querySelector('#appendUnitsColumn')).toBeInTheDocument();
expect(document.querySelector('input[name="appendUnitsColumn"]')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,36 @@ describe('DisableableInput', () => {
expect(notDisableable.result.current.inputValue).toBe('fromProps');
});

test('localValue reports local edits whether or not allowDisable is set', () => {
const disableable = renderHook(() =>
useDisableableInput<string>({ allowDisable: true, value: 'fromProps' })
);
const notDisableable = renderHook(() => useDisableableInput<string>({ value: 'fromProps' }));

expect(disableable.result.current.localValue).toBe('fromProps');
expect(notDisableable.result.current.localValue).toBe('fromProps');

act(() => {
disableable.result.current.setInputValue('edited');
});
act(() => {
notDisableable.result.current.setInputValue('edited');
});

expect(disableable.result.current.localValue).toBe('edited');

// Where inputValue falls back to the value from props, localValue still reports the edit
expect(notDisableable.result.current.inputValue).toBe('fromProps');
expect(notDisableable.result.current.localValue).toBe('edited');

// Disabling discards the edit, so localValue tracks the value the input reverts to
act(() => {
disableable.result.current.toggleDisabled();
});

expect(disableable.result.current.localValue).toBe('fromProps');
});

test('discards local edits when the input is disabled', () => {
const { result } = renderHook(() =>
useDisableableInput<string>({ allowDisable: true, value: 'fromProps' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export interface UseDisableableInput<V> {
*/
inputValue: V;
isDisabled: boolean;
/**
* The locally tracked value. Unlike inputValue this never falls back to props, so it still reflects what the user
* has typed into an input whose parent does not echo edits back through value.
*/
localValue: V;
/**
* Records the value as the user edits it so it can be reverted when the input is subsequently disabled.
* Call this from the input's onChange handler.
Expand Down Expand Up @@ -117,6 +122,7 @@ export function useDisableableInput<V>(props: DisableableInputProps<V>): UseDisa
return {
inputValue: !allowDisable || inputValue === undefined ? value : inputValue,
isDisabled,
localValue: inputValue,
setInputValue,
toggleDisabled,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/*
* 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, RenderResult } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';

import { QueryColumn } from '../../../../public/QueryColumn';
import { Formsy } from '../formsy';
import { INPUT_LABEL_CLASS_NAME, INPUT_WRAPPER_CLASS_NAME, MIXED_VALUE_DISPLAY } from '../constants';

import { TextAreaInput, TextAreaInputProps } from './TextAreaInput';

const COLUMN = new QueryColumn({ caption: 'Description', fieldKey: 'description', name: 'description' });
const REQUIRED_COLUMN = COLUMN.mutate({ required: true }) as QueryColumn;

const ENABLED_FIELD_SELECTOR = `input[name="${COLUMN.fieldKey}::enabled"]`;

function renderInForm(props?: Partial<TextAreaInputProps>): RenderResult {
return render(
<Formsy>
<TextAreaInput queryColumn={COLUMN} {...props} />
</Formsy>
);
}

function textArea(): HTMLTextAreaElement {
return document.querySelector('textarea');
}

function clickToggle(): Promise<void> {
return userEvent.click(document.querySelector('.control-label-toggle-input button:last-child'));
}

describe('TextAreaInput', () => {
describe('label', () => {
test('renders the column caption by default', () => {
const { container } = renderInForm();

const label = container.querySelector('label');
expect(label).toHaveTextContent('Description');
expect(label).toHaveClass(...INPUT_LABEL_CLASS_NAME.split(' '), 'textarea-control-label');
// With a visible label the textarea is labeled by the <label>, so aria-label would be redundant
expect(textArea()).not.toHaveAttribute('aria-label');
});

// Control drops the <label> entirely when it is given a null label for an optional column, so the textarea
// carries its accessible name itself, and the wrapper takes the offset the label would have occupied
test('drops the label but keeps the textarea accessible when showLabel is false', () => {
const { container } = renderInForm({ showLabel: false });

expect(container.querySelector('label')).not.toBeInTheDocument();
expect(textArea()).toHaveAttribute('aria-label', 'Description');
expect(container.querySelector('.offset-sm-3')).toBeInTheDocument();
});

// A required column still needs somewhere to hang its asterisk, which is the only case where the
// hide-label class is actually used
test('keeps a hidden label carrying the asterisk when showLabel is false for a required column', () => {
const { container } = renderInForm({ queryColumn: REQUIRED_COLUMN, showLabel: false });

const label = container.querySelector('label');
expect(label).toHaveClass('hide-label');
expect(label).toHaveTextContent('*');
expect(textArea()).toHaveAttribute('aria-label', 'Description');
});

test('renders a caller-supplied label in place of the FieldLabel', () => {
const renderFieldLabel = jest.fn().mockReturnValue(<span className="custom-label">Custom</span>);
const { container } = renderInForm({ renderFieldLabel });

expect(renderFieldLabel).toHaveBeenCalledWith(COLUMN);
expect(container.querySelector('.custom-label')).toBeInTheDocument();
// FieldLabel's overlay is bypassed entirely
expect(container.querySelector('.overlay-trigger')).not.toBeInTheDocument();
});

test('renders the required asterisk for a required column', () => {
const { container } = renderInForm({ queryColumn: REQUIRED_COLUMN });

expect(container.querySelector('label')).toHaveTextContent('*');
expect(textArea()).toBeRequired();
});

// QueryFormInputs mutates the column to non-required when checkRequiredFields is false, so the asterisk
// has to come from the label overlay instead of the Formsy Control
test('renders the asterisk without requiring the textarea when addLabelAsterisk is set', () => {
const { container } = renderInForm({ addLabelAsterisk: true });

expect(container.querySelector('label')).toHaveTextContent('*');
expect(textArea()).not.toBeRequired();
});
});

describe('textarea attributes', () => {
test('leaves name unencoded', () => {
const column = COLUMN.mutate({ fieldKey: 'Lookup/Field', name: 'Lookup/Field' });
renderInForm({ queryColumn: column });
expect(textArea()).toHaveAttribute('name', 'Lookup/Field');
});

test('honors a caller-supplied id', () => {
renderInForm({ id: 'my-own-id' });

expect(textArea()).toHaveAttribute('id', 'my-own-id');
});

test('applies the default wrapper class name', () => {
const { container } = renderInForm();

expect(container.querySelector(`.${INPUT_WRAPPER_CLASS_NAME.split(' ').join('.')}`)).toBeInTheDocument();
});

test('sizes the textarea, letting the caller override', () => {
const { unmount } = renderInForm();
expect(textArea()).toHaveAttribute('cols', '50');
expect(textArea()).toHaveAttribute('rows', '5');
unmount();

// DetailDisplay renders a smaller textarea
renderInForm({ cols: 4, rows: 4 });
expect(textArea()).toHaveAttribute('cols', '4');
expect(textArea()).toHaveAttribute('rows', '4');
});

test('placeholder prompts for the caption', () => {
renderInForm();

expect(textArea()).toHaveAttribute('placeholder', 'Enter description');
});

test('placeholder reports a mixed value only while disabled', () => {
const { unmount } = renderInForm({ hasMixedValue: true });
expect(textArea()).toHaveAttribute('placeholder', 'Enter description');
unmount();

renderInForm({ disabled: true, hasMixedValue: true });
expect(textArea()).toHaveAttribute('placeholder', MIXED_VALUE_DISPLAY);
});
});

describe('onChange', () => {
test('reports the field key and the new value', async () => {
const onChange = jest.fn();
renderInForm({ onChange });

await userEvent.type(textArea(), 'ab');

expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith(COLUMN.fieldKey, 'ab');
});
});

describe('disabling', () => {
test('disables the textarea when disabled is set', () => {
renderInForm({ disabled: true });

expect(textArea()).toBeDisabled();
});

test('renders no toggle unless allowDisable is set', () => {
const { container } = renderInForm();

expect(container.querySelector('.control-label-toggle-input')).not.toBeInTheDocument();
expect(container.querySelector(ENABLED_FIELD_SELECTOR)).not.toBeInTheDocument();
});

test('starts disabled when initiallyDisabled is set', () => {
const { container } = renderInForm({ allowDisable: true, initiallyDisabled: true });

expect(textArea()).toBeDisabled();
expect(container.querySelector('.fa-toggle-off')).toBeInTheDocument();
expect(container.querySelector(ENABLED_FIELD_SELECTOR)).toHaveAttribute('value', 'false');
});

test('discards local edits when the field is toggled off', async () => {
const onToggleDisable = jest.fn();
const { container } = renderInForm({ allowDisable: true, onToggleDisable, value: 'fromProps' });

expect(textArea()).toBeEnabled();
expect(container.querySelector('.fa-toggle-on')).toBeInTheDocument();
expect(container.querySelector(ENABLED_FIELD_SELECTOR)).toHaveAttribute('value', 'true');

await userEvent.clear(textArea());
await userEvent.type(textArea(), 'edited');
expect(textArea()).toHaveValue('edited');

await clickToggle();

expect(onToggleDisable).toHaveBeenLastCalledWith(true);
expect(textArea()).toBeDisabled();
expect(textArea()).toHaveValue('fromProps');
expect(container.querySelector(ENABLED_FIELD_SELECTOR)).toHaveAttribute('value', 'false');
});
});
});
Loading