diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json
index 22936add2a..cdba2cc695 100644
--- a/packages/components/package-lock.json
+++ b/packages/components/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@labkey/components",
- "version": "6.45.0",
+ "version": "6.45.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@labkey/components",
- "version": "6.45.0",
+ "version": "6.45.1",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"@hello-pangea/dnd": "18.0.1",
diff --git a/packages/components/package.json b/packages/components/package.json
index a81c7e1613..5ee6c48aa5 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -1,6 +1,6 @@
{
"name": "@labkey/components",
- "version": "6.45.0",
+ "version": "6.45.1",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md
index 3d4752e97a..5656131efd 100644
--- a/packages/components/releaseNotes/components.md
+++ b/packages/components/releaseNotes/components.md
@@ -1,6 +1,12 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages
+### version 6.45.1
+*Released*: 3 June 2025
+- Issue 52959: LKSM/LKB: Existing file not shown in Bulk Edit
+ - Update `getCommonDataValues` to retain full data map for file fields
+ - Update `FileInput` to set init value so diff can be generated correctly
+
### version 6.45.0
*Released*: 2 June 2025
- Export Loader type
diff --git a/packages/components/src/internal/components/forms/BulkUpdateForm.spec.tsx b/packages/components/src/internal/components/forms/BulkUpdateForm.spec.tsx
deleted file mode 100644
index 05cbe70769..0000000000
--- a/packages/components/src/internal/components/forms/BulkUpdateForm.spec.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-
-import { QueryColumn } from '../../../public/QueryColumn';
-import { QueryInfo } from '../../../public/QueryInfo';
-import { SchemaQuery } from '../../../public/SchemaQuery';
-
-import { QueryInfoForm } from './QueryInfoForm';
-import { BulkUpdateForm } from './BulkUpdateForm';
-
-const COLUMN_CAN_UPDATE = new QueryColumn({
- fieldKey: 'update',
- name: 'update',
- fieldKeyArray: ['update'],
- shownInUpdateView: true,
- userEditable: true,
-});
-const COLUMN_CANNOT_UPDATE = new QueryColumn({
- fieldKey: 'neither',
- name: 'neither',
- fieldKeyArray: ['neither'],
- shownInUpdateView: false,
- userEditable: true,
-});
-const COLUMN_FILE_INPUT = new QueryColumn({
- fieldKey: 'fileInput',
- name: 'fileInput',
- fieldKeyArray: ['fileInput'],
- shownInUpdateView: true,
- userEditable: true,
- inputType: 'file',
-});
-const QUERY_INFO = QueryInfo.fromJsonForTests({
- name: 'test',
- schemaName: 'schema',
- columns: {
- update: COLUMN_CAN_UPDATE,
- neither: COLUMN_CANNOT_UPDATE,
- fileInput: COLUMN_FILE_INPUT,
- },
-});
-
-const DEFAULT_PROPS = {
- onComplete: jest.fn,
- onCancel: jest.fn,
- onSubmitForEdit: jest.fn,
- queryInfo: QUERY_INFO,
- viewName: undefined,
- selectedIds: [],
- updateRows: (schemaQuery: SchemaQuery, rows: any[]) => Promise.resolve(),
-};
-
-describe('BulkUpdateForm', () => {
- // TODO missing test cases for main functionality of component
- describe('columnFilter', () => {
- test('filters without uniqueKeyField', () => {
- // Arrange
- const wrapper = shallow();
- const columnFilter = wrapper.find(QueryInfoForm).prop('columnFilter');
-
- // Act
- const filteredColumns = QUERY_INFO.columns.filter(c => columnFilter(c));
-
- // Assert
- expect(filteredColumns.size).toEqual(2);
- expect(filteredColumns.get('update')).toEqual(COLUMN_CAN_UPDATE);
- expect(filteredColumns.get('fileinput')).toEqual(COLUMN_FILE_INPUT);
- });
-
- test('filters with uniqueFieldKey', () => {
- // Arrange
- const wrapper = shallow();
- const columnFilter = wrapper.find(QueryInfoForm).prop('columnFilter');
-
- // Act
- const filteredColumns = QUERY_INFO.columns.filter(c => columnFilter(c));
-
- // Assert
- expect(filteredColumns.size).toEqual(1);
- expect(filteredColumns.get('fileinput')).toEqual(COLUMN_FILE_INPUT);
- });
- });
-});
diff --git a/packages/components/src/internal/components/forms/BulkUpdateForm.test.tsx b/packages/components/src/internal/components/forms/BulkUpdateForm.test.tsx
new file mode 100644
index 0000000000..55c0236ceb
--- /dev/null
+++ b/packages/components/src/internal/components/forms/BulkUpdateForm.test.tsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { render, waitFor } from '@testing-library/react';
+import { fromJS, List } from 'immutable';
+
+import { QueryColumn } from '../../../public/QueryColumn';
+import { QueryInfo } from '../../../public/QueryInfo';
+
+import { GridResponse } from '../editable/models';
+
+import { getTestAPIWrapper } from '../../APIWrapper';
+
+import { BulkUpdateForm, BulkUpdateFormProps } from './BulkUpdateForm';
+
+const COLUMN_CAN_UPDATE = new QueryColumn({
+ fieldKey: 'update',
+ name: 'update',
+ caption: 'update',
+ fieldKeyArray: ['update'],
+ shownInUpdateView: true,
+ userEditable: true,
+});
+const COLUMN_CANNOT_UPDATE = new QueryColumn({
+ fieldKey: 'neither',
+ name: 'neither',
+ caption: 'neither',
+ fieldKeyArray: ['neither'],
+ shownInUpdateView: false,
+ userEditable: true,
+});
+const COLUMN_FILE_INPUT = new QueryColumn({
+ fieldKey: 'fileInput',
+ name: 'fileInput',
+ caption: 'fileInput',
+ fieldKeyArray: ['fileInput'],
+ shownInUpdateView: true,
+ userEditable: true,
+ inputType: 'file',
+});
+const SCHEMA = 'samples';
+const QUERY = 'testST';
+const QUERY_INFO = QueryInfo.fromJsonForTests({
+ name: QUERY,
+ schemaName: SCHEMA,
+ columns: {
+ update: COLUMN_CAN_UPDATE,
+ neither: COLUMN_CANNOT_UPDATE,
+ fileInput: COLUMN_FILE_INPUT,
+ },
+});
+
+const DEFAULT_PROPS: BulkUpdateFormProps = {
+ api: getTestAPIWrapper(jest.fn),
+ onComplete: jest.fn(),
+ onCancel: jest.fn(),
+ onSubmitForEdit: jest.fn(),
+ queryInfo: QUERY_INFO,
+ viewName: undefined,
+ selectedIds: [],
+ updateRows: jest.fn(),
+};
+
+const mockGridResponse: GridResponse = {
+ data: fromJS({
+ '127796': {
+ update: {
+ value: 'abc',
+ },
+ fileInput: {
+ value: '/trunk/build/deploy/files/LKSM/@files/sampletype/test.txt',
+ url: '/LKSM-dan/core-downloadFileLink.view?propertyId=82852',
+ displayValue: 'sampletype/test.txt',
+ },
+ },
+ '127797': {
+ update: {
+ value: 'abc',
+ },
+ fileInput: {
+ value: '/trunk/build/deploy/files/LKSM/@files/sampletype/test.txt',
+ url: '/LKSM-dan/core-downloadFileLink.view?propertyId=82852',
+ displayValue: 'sampletype/test.txt',
+ },
+ },
+ }),
+ dataIds: List(['127796', '127797']),
+};
+
+jest.mock('../../actions', () => ({
+ ...jest.requireActual('../../actions'),
+ getSelectedDataDeprecated: jest.fn().mockImplementation(() => mockGridResponse),
+}));
+
+describe('BulkUpdateForm', () => {
+ // TODO missing test cases for main functionality of component
+ describe('columnFilter', () => {
+ test('filters without uniqueKeyField', async () => {
+ render();
+
+ await waitFor(() => {
+ 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('.attachment-card__name')).toHaveLength(1);
+ expect(document.querySelector('.attachment-card__name')).toHaveTextContent('test.txt');
+ });
+
+ test('filters with uniqueFieldKey', async () => {
+ render();
+
+ await waitFor(() => {
+ 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('.attachment-card__name')).toHaveLength(1);
+ });
+ });
+});
diff --git a/packages/components/src/internal/components/forms/BulkUpdateForm.tsx b/packages/components/src/internal/components/forms/BulkUpdateForm.tsx
index 69ec3ad564..af4024d113 100644
--- a/packages/components/src/internal/components/forms/BulkUpdateForm.tsx
+++ b/packages/components/src/internal/components/forms/BulkUpdateForm.tsx
@@ -11,6 +11,8 @@ import { getSelectedDataDeprecated } from '../../actions';
import { capitalizeFirstChar, caseInsensitive, getCommonDataValues, getUpdatedData } from '../../util/utils';
+import { ComponentsAPIWrapper } from '../../APIWrapper';
+
import { QueryInfoForm } from './QueryInfoForm';
type UpdateRows = (schemaQuery: SchemaQuery, rows: any[], comment?: string) => Promise;
@@ -20,7 +22,8 @@ function isUpdateModel(fn: UpdateRows | UpdateModel): fn is UpdateModel {
return fn.length === 1; // UpdateModel has only one parameter
}
-interface Props {
+export interface BulkUpdateFormProps {
+ api?: ComponentsAPIWrapper;
containerFilter?: Query.ContainerFilter;
disabled?: boolean;
header?: ReactNode;
@@ -59,7 +62,7 @@ interface State {
originalDataForSelection: Map;
}
-export class BulkUpdateForm extends PureComponent {
+export class BulkUpdateForm extends PureComponent {
static defaultProps = {
pluralNoun: 'rows',
singularNoun: 'row',
@@ -196,6 +199,7 @@ export class BulkUpdateForm extends PureComponent {
render() {
const { formData, isLoadingDataForSelection, dataForSelection, containerPaths } = this.state;
const {
+ api,
containerFilter,
onCancel,
onComplete,
@@ -207,8 +211,11 @@ export class BulkUpdateForm extends PureComponent {
includeCommentField,
onSubmitForEdit,
} = this.props;
+ const fileFields = queryInfo.columns.valueArray.filter(col => col.isFileInput).map(col => col.name);
const fieldValues =
- isLoadingDataForSelection || !dataForSelection ? undefined : getCommonDataValues(dataForSelection);
+ isLoadingDataForSelection || !dataForSelection
+ ? undefined
+ : getCommonDataValues(dataForSelection, fileFields);
// if all selectedIds are from the same containerPath, use that for the lookups via QueryFormInputs > QuerySelect,
// if selections are from multiple containerPaths, disable the lookup and file field inputs
@@ -221,6 +228,7 @@ export class BulkUpdateForm extends PureComponent {
return (
{
error: '',
isDisabled: props.initiallyDisabled,
};
+
+ if (Map.isMap(props.initialValue)) {
+ // call setValue so to populate form data (for diff compare)
+ props.setValue?.(props.initialValue.get('value'));
+ }
}
getInputName(): string {
diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts
index e935dbb2b6..db585d7be7 100644
--- a/packages/components/src/internal/util/utils.test.ts
+++ b/packages/components/src/internal/util/utils.test.ts
@@ -253,6 +253,11 @@ describe('getCommonDataForSelection', () => {
Other: {
value: 'other1',
},
+ Pdf: {
+ value: '/root/lk/Sample%20Management/blood.pdf',
+ displayValue: 'sampletype/blood.pdf',
+ url: '/labkey/Sample%20Management/core-downloadFileLink.view?propertyId=552',
+ },
},
'447': {
RowId: {
@@ -275,6 +280,11 @@ describe('getCommonDataForSelection', () => {
Other: {
value: 'other2',
},
+ Pdf: {
+ value: '/root/lk/Sample%20Management/blood.pdf',
+ displayValue: 'sampletype/blood.pdf',
+ url: '/labkey/Sample%20Management/core-downloadFileLink.view?propertyId=552',
+ },
},
'446': {
RowId: {
@@ -297,6 +307,11 @@ describe('getCommonDataForSelection', () => {
Other: {
value: 'other3',
},
+ Pdf: {
+ value: '/root/lk/Sample%20Management/blood.pdf',
+ displayValue: 'sampletype/blood.pdf',
+ url: '/labkey/Sample%20Management/core-downloadFileLink.view?propertyId=552',
+ },
},
'445': {
RowId: {
@@ -319,6 +334,11 @@ describe('getCommonDataForSelection', () => {
Other: {
value: null,
},
+ Pdf: {
+ value: '/root/lk/Sample%20Management/blood.pdf',
+ displayValue: 'sampletype/blood.pdf',
+ url: '/labkey/Sample%20Management/core-downloadFileLink.view?propertyId=552',
+ },
},
'367': {
RowId: {
@@ -341,11 +361,26 @@ describe('getCommonDataForSelection', () => {
Other: {
value: null,
},
+ Pdf: {
+ value: '/root/lk/Sample%20Management/blood.pdf',
+ displayValue: 'sampletype/blood.pdf',
+ url: '/labkey/Sample%20Management/core-downloadFileLink.view?propertyId=552',
+ },
},
});
expect(getCommonDataValues(data)).toEqual({
AndAgain: 'again',
Data: 'data1',
+ Pdf: '/root/lk/Sample%20Management/blood.pdf',
+ });
+ expect(getCommonDataValues(data, ['Pdf'])).toEqual({
+ AndAgain: 'again',
+ Data: 'data1',
+ Pdf: fromJS({
+ value: '/root/lk/Sample%20Management/blood.pdf',
+ displayValue: 'sampletype/blood.pdf',
+ url: '/labkey/Sample%20Management/core-downloadFileLink.view?propertyId=552',
+ }),
});
});
});
diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts
index 8ae7433f17..b870b6f9ab 100644
--- a/packages/components/src/internal/util/utils.ts
+++ b/packages/components/src/internal/util/utils.ts
@@ -200,16 +200,18 @@ export function valueIsEmpty(value: any): boolean {
*
* @param data Map between ids and a map of data for the ids (i.e, a row of data for that id)
*/
-export function getCommonDataValues(data: Map): any {
+export function getCommonDataValues(data: Map, fileFields?: string[]): any {
let valueMap = Map(); // map from fields to the value shared by all rows
let fieldsInConflict = ImmutableSet();
let emptyFields = ImmutableSet(); // those fields that are empty
+ const fileMap = {};
data.map((rowData, id) => {
if (rowData) {
rowData.forEach((data, key) => {
if (!fieldsInConflict.has(key)) {
// skip fields that are already in conflict
let value = data;
+ const rawValue = data;
// Convert from immutable to regular JS
if (Iterable.isIterable(data)) {
@@ -233,6 +235,9 @@ export function getCommonDataValues(data: Map): any {
fieldsInConflict = fieldsInConflict.add(key);
} else if (!havePreviousValue) {
valueMap = valueMap.set(key, value);
+ if (fileFields?.indexOf(key) > -1) {
+ fileMap[key] = rawValue;
+ }
}
if (arrayNotEqual) {
fieldsInConflict = fieldsInConflict.add(key);
@@ -254,6 +259,12 @@ export function getCommonDataValues(data: Map): any {
console.error('Unable to find data for selection id ' + id);
}
});
+
+ // return full file data map (url, displayValue, value) for file fields
+ fileFields?.forEach(fileField => {
+ if (valueMap.has(fileField)) valueMap = valueMap.set(fileField, fileMap[fileField]);
+ });
+
return valueMap.toObject();
}
diff --git a/packages/components/src/test/MockUtils.ts b/packages/components/src/test/MockUtils.ts
index d13f10f8f4..e518d54b81 100644
--- a/packages/components/src/test/MockUtils.ts
+++ b/packages/components/src/test/MockUtils.ts
@@ -7,14 +7,16 @@ import { QueryInfo } from '../public/QueryInfo';
* occurring in your tests. See DatasetPropertiesAdvancedSettings.test.tsx for an example.
*/
-export function createMockSelectRowsDeprecatedResponse() {
- return Promise.resolve({
- key: 'test',
- models: { test: {} },
- orderedModels: { test: List() },
- queries: { test: QueryInfo.fromJsonForTests({}) },
- rowCount: 0,
- });
+export function createMockSelectRowsDeprecatedResponse(result?: Record) {
+ return Promise.resolve(
+ result ?? {
+ key: 'test',
+ models: { test: {} },
+ orderedModels: { test: List() },
+ queries: { test: QueryInfo.fromJsonForTests({}) },
+ rowCount: 0,
+ }
+ );
}
export function createMockSelectRowsResponse() {