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() {