Skip to content

Commit d593fd6

Browse files
authored
Add a “Mixed” state in Bulk Edit when values differ across selected samples (#1917)
1 parent c773bea commit d593fd6

24 files changed

+405
-181
lines changed

packages/components/package-lock.json

Lines changed: 2 additions & 2 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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@labkey/components",
3-
"version": "7.7.3",
3+
"version": "7.8.0",
44
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
55
"sideEffects": false,
66
"files": [

packages/components/releaseNotes/components.md

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

4+
### version 7.8.0
5+
*Released*: 5 January 2026
6+
- Add a “Mixed” state in Bulk Edit when values differ across selected samples
7+
- Modified getCommonDataValues utility to return both common field values and a list of fields with conflicting values
8+
- Added hasMixedValue prop support across all input components (TextInput, SelectInput, CheckboxInput, DatePickerInput, FileInput, TextAreaInput, AmountUnitInput)
9+
- Updated BulkUpdateForm and BulkAddUpdateForm to pass conflicting fields information to form inputs
10+
411
### version 7.7.3
512
*Released*: 31 December 2025
613
- [GitHub Issue #495](https://github.com/LabKey/internal-issues/issues/495)

packages/components/src/internal/components/domainproperties/models.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ const gridDataAppPropsOnlyConst = [
131131
format: '',
132132
fieldIndex: 0,
133133
importAliases: '',
134-
selected: '',
134+
selected: false,
135135
description: '',
136136
required: 'false',
137137
scannable: 'false',

packages/components/src/internal/components/forms/BulkAddUpdateForm.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const BulkAddUpdateForm: FC<BulkAddUpdateFormProps> = props => {
4141
const title =
4242
'Update ' + selectedRowIndexes.size + ' ' + (selectedRowIndexes.size === 1 ? singularNoun : pluralNoun);
4343

44-
const fieldValues = useMemo(() => {
44+
const { fieldValues, fieldsInConflict } = useMemo(() => {
4545
const editorData = editorModel
4646
.getDataForServerUpload(false)
4747
.filter((val, index) => selectedRowIndexes.contains(index))
@@ -58,6 +58,7 @@ export const BulkAddUpdateForm: FC<BulkAddUpdateFormProps> = props => {
5858
asModal={asModal}
5959
checkRequiredFields={false}
6060
fieldValues={fieldValues}
61+
fieldWithMixedValues={fieldsInConflict}
6162
hideButtons={!queryInfoFormProps.asModal}
6263
includeCountField={false}
6364
initiallyDisableFields={true}

packages/components/src/internal/components/forms/BulkUpdateForm.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -312,10 +312,8 @@ export class BulkUpdateForm extends PureComponent<BulkUpdateFormProps, State> {
312312
selectedIds,
313313
} = this.props;
314314
const fileFields = queryInfo.columns.valueArray.filter(col => col.isFileInput).map(col => col.name);
315-
const fieldValues =
316-
isLoadingDataForSelection || !dataForSelection
317-
? undefined
318-
: getCommonDataValues(dataForSelection, fileFields);
315+
const { fieldValues, fieldsInConflict } =
316+
isLoadingDataForSelection || !dataForSelection ? { fieldValues: undefined, fieldsInConflict: [] } : getCommonDataValues(dataForSelection, fileFields);
319317

320318
// if all selectedIds are from the same containerPath, use that for the lookups via QueryFormInputs > QuerySelect,
321319
// if selections are from multiple containerPaths, disable the lookup and file field inputs
@@ -344,6 +342,7 @@ export class BulkUpdateForm extends PureComponent<BulkUpdateFormProps, State> {
344342
containerPath={containerPath}
345343
disabled={disabled}
346344
fieldValues={values}
345+
fieldWithMixedValues={fieldsInConflict}
347346
header={this.renderBulkUpdateHeader()}
348347
includeCommentField={includeCommentField}
349348
includeCountField={false}

packages/components/src/internal/components/forms/QueryFormInputs.spec.tsx

Lines changed: 0 additions & 120 deletions
This file was deleted.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright (c) 2019 LabKey Corporation
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import React from 'react';
17+
import { List } from 'immutable';
18+
import { render, screen } from '@testing-library/react';
19+
20+
import { makeQueryInfo } from '../../test/testHelpers';
21+
import assayGpatDataQueryInfo from '../../../test/data/assayGpatData-getQueryDetails.json';
22+
import { QueryColumn } from '../../../public/QueryColumn';
23+
24+
import { Formsy } from './formsy';
25+
import { QueryFormInputs } from './QueryFormInputs';
26+
27+
const QUERY_INFO = makeQueryInfo(assayGpatDataQueryInfo);
28+
29+
describe('QueryFormInputs', () => {
30+
test('default properties with queryInfo', () => {
31+
const { container } = render(
32+
<Formsy>
33+
<QueryFormInputs queryInfo={QUERY_INFO} />
34+
</Formsy>
35+
);
36+
37+
expect(document.querySelectorAll('input')).toHaveLength(9);
38+
expect(container.querySelectorAll('input:disabled')).toHaveLength(0);
39+
40+
// Verify presence of expected fields
41+
expect(screen.getByLabelText('Participant ID')).toBeInTheDocument();
42+
expect(screen.getByLabelText('Visit ID')).toBeInTheDocument();
43+
44+
// Check types where possible
45+
expect(screen.getByLabelText('Healthy')).toHaveAttribute('type', 'checkbox');
46+
47+
// default properties don't render file inputs
48+
expect(container.querySelectorAll('input[type="file"]')).toHaveLength(0);
49+
});
50+
51+
test('renderFieldLabel', () => {
52+
const { container } = render(
53+
<Formsy>
54+
<QueryFormInputs
55+
queryInfo={QUERY_INFO}
56+
renderFieldLabel={(queryColumn: QueryColumn, label: string) => {
57+
return <div className="jest-field-label-test">{queryColumn?.name || label}</div>;
58+
}}
59+
/>
60+
</Formsy>
61+
);
62+
63+
expect(container.querySelectorAll('.jest-field-label-test')).toHaveLength(9);
64+
});
65+
66+
test('render file inputs', () => {
67+
const { container } = render(
68+
<Formsy>
69+
<QueryFormInputs queryInfo={QUERY_INFO} renderFileInputs={true} />
70+
</Formsy>
71+
);
72+
73+
expect(container.querySelectorAll('input[type="file"]')).toHaveLength(1);
74+
});
75+
76+
test('custom columnFilter', () => {
77+
const filter = (col: QueryColumn) => {
78+
return col.name === 'Healthy';
79+
};
80+
81+
render(
82+
<Formsy>
83+
<QueryFormInputs columnFilter={filter} queryInfo={QUERY_INFO} />
84+
</Formsy>
85+
);
86+
87+
expect(screen.getByLabelText('Healthy')).toBeInTheDocument();
88+
expect(screen.queryByLabelText('Participant ID')).not.toBeInTheDocument();
89+
});
90+
91+
test('disabledFields', () => {
92+
render(
93+
<Formsy>
94+
<QueryFormInputs
95+
disabledFields={List<string>(['date', 'ParticipantID', 'textarea'])}
96+
queryInfo={QUERY_INFO}
97+
/>
98+
</Formsy>
99+
);
100+
101+
const inputs = document.querySelectorAll('input');
102+
expect(inputs).toHaveLength(9);
103+
expect(inputs[4].getAttribute('type')).toBe('text');
104+
expect(inputs[4].getAttribute('name')).toBe('Date');
105+
expect(inputs[4].getAttribute('value')).toBe('');
106+
expect(inputs[4].getAttribute('disabled')).toBe('');
107+
});
108+
109+
test('disabledFields, with fieldWithMixedValues', () => {
110+
render(
111+
<Formsy>
112+
<QueryFormInputs
113+
disabledFields={List<string>(['date', 'healthy'])}
114+
fieldWithMixedValues={['date', 'healthy', 'ParticipantID']}
115+
queryInfo={QUERY_INFO}
116+
/>
117+
</Formsy>
118+
);
119+
120+
const inputs = document.querySelectorAll('input');
121+
expect(inputs).toHaveLength(9);
122+
expect(inputs[2].getAttribute('name')).toBe('ParticipantID');
123+
expect(inputs[2].getAttribute('disabled')).toBeNull();
124+
expect(inputs[2].getAttribute('placeholder')).toBe('Enter participant id'); // not disabled, show don't show Mixed placeholder
125+
expect(inputs[4].getAttribute('name')).toBe('Date');
126+
expect(inputs[4].getAttribute('disabled')).toBe(''); // disabled
127+
expect(inputs[4].getAttribute('placeholder')).toBe('[Mixed]'); // disabled and has mix value
128+
expect(inputs[5].getAttribute('name')).toBe('DateOnly');
129+
expect(inputs[5].getAttribute('placeholder')).toBe('Select dateonly');
130+
expect(inputs[6].getAttribute('name')).toBe('TimeOnly');
131+
expect(inputs[6].getAttribute('placeholder')).toBe('Select timeonly');
132+
expect(inputs[7].getAttribute('placeholder')).toBeNull();
133+
expect(inputs[7].getAttribute('title')).toBe('[Mixed]'); // disabled and has mix value, boolean
134+
});
135+
});

0 commit comments

Comments
 (0)