diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 859047b50c..d5d04429e4 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "3.39.6", + "version": "3.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "3.39.6", + "version": "3.40.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@labkey/api": "1.33.0", diff --git a/packages/components/package.json b/packages/components/package.json index e1018c014a..25f7d98b0d 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "3.39.6", + "version": "3.40.0", "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 a2a8c74306..8aceda3ef1 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,17 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages. +### version 3.40.0 +*Released*: 29 April 2024 +- Support cross-folder "Edit in Bulk" + - Update getOperationNotPermittedMessage to work for both Edit in Grid and Edit in Bulk scenarios + - BulkUpdateForm getUpdatedData() to include Folder in updated rows, if it exists in originalData + - Add getSelectedIds(filterIds) to QueryModel + - saveRowsByContainer prop for containerField to be optional since it has a default + - AppendUnitsInput fixes for grid cell rendering and enable/disable in bulk form + - Edit in Grid and Bulk lookup fields to use containerPath based on selected row(s) (for BulkUpdateForm, disable lookup fields and file files toggle when more than one containerPath in selection) + - Add getOperationConfirmationData and getParentTypeDataForLineage to ApiWrapper + ### version 3.39.6 *Released*: 25 April 2024 - Add support for exporting a storage map from terminal storage grids diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index d97a2a7328..a58b63a49d 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -76,6 +76,7 @@ import { uncapitalizeFirstChar, valueIsEmpty, withTransformedKeys, + getValueFromRow, } from './internal/util/utils'; import { AutoForm } from './internal/components/AutoForm'; import { HelpIcon } from './internal/components/HelpIcon'; @@ -1577,6 +1578,7 @@ export { generateId, debounce, valueIsEmpty, + getValueFromRow, getActionErrorMessage, getConfirmDeleteMessage, resolveErrorMessage, diff --git a/packages/components/src/internal/components/buttons/ToggleButtons.test.tsx b/packages/components/src/internal/components/buttons/ToggleButtons.test.tsx index 5f5a53a69f..b63c524c15 100644 --- a/packages/components/src/internal/components/buttons/ToggleButtons.test.tsx +++ b/packages/components/src/internal/components/buttons/ToggleButtons.test.tsx @@ -105,6 +105,7 @@ describe('ToggleIcon', () => { userEvent.click(document.getElementsByTagName('i')[0]); expect(onClickFn).toHaveBeenCalledTimes(1); + expect(onClickFn).toHaveBeenCalledWith('on'); }); test('active first item', () => { @@ -125,4 +126,23 @@ describe('ToggleIcon', () => { expect(document.getElementsByClassName('toggle').length).toBe(1); expect(document.getElementsByClassName('test-class').length).toBe(1); }); + + test('disabled', () => { + const onClickFn = jest.fn(); + render(); + + userEvent.click(document.getElementsByTagName('i')[0]); + expect(onClickFn).toHaveBeenCalledTimes(0); + }); + + test('tooltip', () => { + const onClickFn = jest.fn(); + render(); + + expect(document.getElementsByClassName('overlay-trigger').length).toBe(1); + + userEvent.click(document.getElementsByTagName('i')[0]); + expect(onClickFn).toHaveBeenCalledTimes(1); + expect(onClickFn).toHaveBeenCalledWith('on'); + }); }); diff --git a/packages/components/src/internal/components/buttons/ToggleButtons.tsx b/packages/components/src/internal/components/buttons/ToggleButtons.tsx index 48265540d9..11998db7d1 100644 --- a/packages/components/src/internal/components/buttons/ToggleButtons.tsx +++ b/packages/components/src/internal/components/buttons/ToggleButtons.tsx @@ -2,6 +2,7 @@ import React, { FC, memo, useCallback } from 'react'; import classNames from 'classnames'; import { FormsyInput } from '../forms/input/FormsyReactComponents'; +import { LabelHelpTip } from '../base/LabelHelpTip'; interface Props { active: string; @@ -16,6 +17,7 @@ interface Props { inputFieldName?: string; onClick: (selected: string) => void; second?: string; + toolTip?: string; } export const ToggleButtons: FC = memo(props => { @@ -71,17 +73,34 @@ export const ToggleButtons: FC = memo(props => { }); export const ToggleIcon: FC = memo(props => { - const { first = 'on', second = 'off', onClick, active = 'off', className, inputFieldName, id } = props; + const { + first = 'on', + second = 'off', + onClick, + active = 'off', + className, + inputFieldName, + id, + disabled = false, + toolTip, + } = props; const firstActive = active === first; const secondActive = active === second; const firstBtnClick = useCallback(() => { - if (secondActive) onClick(first); - }, [first, secondActive, onClick]); + if (secondActive && !disabled) onClick(first); + }, [first, secondActive, onClick, disabled]); const secondBtnClick = useCallback(() => { - if (firstActive) onClick(second); - }, [second, firstActive, onClick]); + if (firstActive && !disabled) onClick(second); + }, [second, firstActive, onClick, disabled]); + + const body = ( + <> + {firstActive && } + {secondActive && } + + ); return ( <> @@ -92,12 +111,13 @@ export const ToggleIcon: FC = memo(props => { className={classNames('toggle', 'toggle-group-icon', 'btn-group', { 'toggle-on': firstActive, 'toggle-off': secondActive, + disabled, [className]: !!className, })} id={id} > - {firstActive && } - {secondActive && } + {toolTip && {toolTip}} + {!toolTip && body} ); diff --git a/packages/components/src/internal/components/editable/Cell.tsx b/packages/components/src/internal/components/editable/Cell.tsx index e11fcf06a3..8828a469f6 100644 --- a/packages/components/src/internal/components/editable/Cell.tsx +++ b/packages/components/src/internal/components/editable/Cell.tsx @@ -385,6 +385,7 @@ export class Cell extends React.PureComponent { const { filteredLookupKeys } = this.state; const isDateTimeField = this.isDateTimeField; const showLookup = this.isLookup; + const showMenu = showLookup || (col.inputRenderer && col.inputRenderer !== 'AppendUnitsInput'); if (!focused) { let valueDisplay = values @@ -403,7 +404,7 @@ export class Cell extends React.PureComponent { 'cell-warning': message !== undefined, 'cell-read-only': this.isReadOnly, 'cell-locked': locked, - 'cell-menu': showLookup || col.inputRenderer, + 'cell-menu': showMenu, 'cell-placeholder': valueDisplay.length === 0 && placeholder !== undefined, }), onDoubleClick: this.handleDblClick, @@ -418,7 +419,7 @@ export class Cell extends React.PureComponent { if (valueDisplay.length === 0 && placeholder) valueDisplay = placeholder; let cell: ReactNode; - if (showLookup || col.inputRenderer) { + if (showMenu) { cell = (
{valueDisplay}
@@ -491,6 +492,7 @@ export class Cell extends React.PureComponent { forUpdate={forUpdate} modifyCell={cellActions.modifyCell} onKeyDown={this.handleFocusedDropdownKeys} + row={row} rowIdx={rowIdx} select={cellActions.selectCell} values={values} diff --git a/packages/components/src/internal/components/editable/EditableGridLoaderFromSelection.tsx b/packages/components/src/internal/components/editable/EditableGridLoaderFromSelection.tsx index 62ec4c02c3..4b2698b407 100644 --- a/packages/components/src/internal/components/editable/EditableGridLoaderFromSelection.tsx +++ b/packages/components/src/internal/components/editable/EditableGridLoaderFromSelection.tsx @@ -61,14 +61,13 @@ export class EditableGridLoaderFromSelection implements EditableGridLoader { fetch(gridModel: QueryModel): Promise { return new Promise((resolve, reject) => { - const { queryName, queryParameters, schemaName, selections, sortString, viewName } = gridModel; - - const selections_ = [...selections].filter(s => this.idsNotPermitted.indexOf(parseInt(s, 10)) === -1); + const { queryName, queryParameters, schemaName, sortString, viewName } = gridModel; + const selectedIds = gridModel.getSelectedIds(this.idsNotPermitted); return getSelectedData( schemaName, queryName, - selections_, + selectedIds, gridModel.getRequestColumnsString(this.requiredColumns, this.omittedColumns, true), sortString, queryParameters, diff --git a/packages/components/src/internal/components/editable/EditableGridPanelForUpdate.tsx b/packages/components/src/internal/components/editable/EditableGridPanelForUpdate.tsx index fc54e5af83..c31cb03d4f 100644 --- a/packages/components/src/internal/components/editable/EditableGridPanelForUpdate.tsx +++ b/packages/components/src/internal/components/editable/EditableGridPanelForUpdate.tsx @@ -88,8 +88,8 @@ export const EditableGridPanelForUpdate: FC = p const hasValidUserComment = comment?.trim()?.length > 0; const notPermittedText = useMemo( - () => getOperationNotPermittedMessage(editStatusData, pluralNoun), - [editStatusData, pluralNoun] + () => getOperationNotPermittedMessage(editStatusData, singularNoun, pluralNoun), + [editStatusData, singularNoun, pluralNoun] ); useEffect(() => { diff --git a/packages/components/src/internal/components/editable/LookupCell.tsx b/packages/components/src/internal/components/editable/LookupCell.tsx index f68bf5b0ea..8a8c66de4c 100644 --- a/packages/components/src/internal/components/editable/LookupCell.tsx +++ b/packages/components/src/internal/components/editable/LookupCell.tsx @@ -30,6 +30,8 @@ import { QuerySelect } from '../forms/QuerySelect'; import { getQueryColumnRenderers } from '../../global'; +import { getValueFromRow } from '../../util/utils'; + import { MODIFICATION_TYPES, SELECTION_TYPES } from './constants'; import { ValueDescriptor } from './models'; @@ -48,9 +50,10 @@ export interface LookupCellProps { lookupValueFilters?: Filter.IFilter[]; modifyCell: (colIdx: number, rowIdx: number, newValues: ValueDescriptor[], mod: MODIFICATION_TYPES) => void; onKeyDown?: (event: KeyboardEvent) => void; + row: any; rowIdx: number; - values: List; select: (colIdx: number, rowIdx: number, selection?: SELECTION_TYPES, resetValue?: boolean) => void; + values: List; } interface QueryLookupCellProps extends LookupCellProps { @@ -126,7 +129,7 @@ const QueryLookupCell: FC = memo(props => { QueryLookupCell.displayName = 'QueryLookupCell'; export const LookupCell: FC = memo(props => { - const { col, colIdx, disabled, modifyCell, onKeyDown, rowIdx, select, values } = props; + const { col, colIdx, disabled, modifyCell, onKeyDown, row, rowIdx, select, values, containerPath } = props; const onSelectChange = useCallback( (fieldName, formValue, options, props_) => { @@ -158,7 +161,20 @@ export const LookupCell: FC = memo(props => { ); } - return ; + // if the column is a lookup, we need to pass the containerPath to the QuerySelect + const containerPath_ = + containerPath ?? + getValueFromRow(row?.toJS(), 'Folder')?.toString() ?? + getValueFromRow(row?.toJS(), 'Container')?.toString(); + + return ( + + ); }); LookupCell.displayName = 'LookupCell'; diff --git a/packages/components/src/internal/components/entities/APIWrapper.ts b/packages/components/src/internal/components/entities/APIWrapper.ts index c602a3de0f..c0d0388210 100644 --- a/packages/components/src/internal/components/entities/APIWrapper.ts +++ b/packages/components/src/internal/components/entities/APIWrapper.ts @@ -14,14 +14,17 @@ import { GetDeleteConfirmationDataOptions, getDeleteConfirmationData, getMoveConfirmationData, + getOperationConfirmationData, getOperationConfirmationDataForModel, getEntityTypeData, getOriginalParentsFromLineage, + getParentTypeDataForLineage, handleEntityFileImport, moveEntities, initParentOptionsSelects, MoveEntitiesOptions, getCrossFolderSelectionResult, + GetParentTypeDataForLineage, } from './actions'; import { DataOperation } from './constants'; import { @@ -65,6 +68,14 @@ export interface EntityAPIWrapper { selectionKey?: string, useSnapshotSelection?: boolean ) => Promise; + getOperationConfirmationData: ( + dataType: EntityDataType, + rowIds: string[] | number[], + selectionKey?: string, + useSnapshotSelection?: boolean, + extraParams?: Record, + containerPath?: string + ) => Promise; getOperationConfirmationDataForModel: ( model: QueryModel, dataType: EntityDataType, @@ -78,6 +89,7 @@ export interface EntityAPIWrapper { originalParents: Record>; parentTypeOptions: Map>; }>; + getParentTypeDataForLineage: GetParentTypeDataForLineage; handleEntityFileImport: ( importAction: string, queryInfo: QueryInfo, @@ -113,8 +125,10 @@ export class EntityServerAPIWrapper implements EntityAPIWrapper { getDeleteConfirmationData = getDeleteConfirmationData; getMoveConfirmationData = getMoveConfirmationData; getEntityTypeData = getEntityTypeData; + getOperationConfirmationData = getOperationConfirmationData; getOperationConfirmationDataForModel = getOperationConfirmationDataForModel; getOriginalParentsFromLineage = getOriginalParentsFromLineage; + getParentTypeDataForLineage = getParentTypeDataForLineage; handleEntityFileImport = handleEntityFileImport; loadNameExpressionOptions = loadNameExpressionOptions; moveEntities = moveEntities; @@ -134,8 +148,10 @@ export function getEntityTestAPIWrapper( getDeleteConfirmationData: mockFn(), getMoveConfirmationData: mockFn(), getEntityTypeData: mockFn(), + getOperationConfirmationData: mockFn(), getOperationConfirmationDataForModel: mockFn(), getOriginalParentsFromLineage: mockFn(), + getParentTypeDataForLineage: mockFn(), handleEntityFileImport: mockFn(), loadNameExpressionOptions: mockFn(), moveEntities: mockFn(), diff --git a/packages/components/src/internal/components/entities/models.test.ts b/packages/components/src/internal/components/entities/models.test.ts index c83f3f913a..3709099ecf 100644 --- a/packages/components/src/internal/components/entities/models.test.ts +++ b/packages/components/src/internal/components/entities/models.test.ts @@ -18,7 +18,7 @@ import { List } from 'immutable'; import { SCHEMAS } from '../../schemas'; import { QueryColumn } from '../../../public/QueryColumn'; -import { EntityIdCreationModel, EntityParentType, EntityTypeOption } from './models'; +import { EntityIdCreationModel, EntityParentType, EntityTypeOption, OperationConfirmationData } from './models'; import { SampleTypeDataType } from './constants'; describe('EntityParentType', () => { @@ -212,3 +212,155 @@ describe('EntityIdCreationModel', () => { expect(sq.getSchemaQuery().queryName).toBe('a'); }); }); + +describe('OperationConfirmationData', () => { + const data = new OperationConfirmationData({ + allowed: [{ rowId: 1 }, { rowId: 2 }], + notAllowed: [{ rowId: 3 }, { rowId: 4 }, { rowId: 5 }], + notPermitted: [{ rowId: 6 }], + }); + + test('isIdActionable', () => { + expect(data.isIdActionable('1')).toBe(true); + expect(data.isIdActionable(1)).toBe(true); + expect(data.isIdActionable('3')).toBe(false); + expect(data.isIdActionable(3)).toBe(false); + expect(data.isIdActionable('6')).toBe(false); + expect(data.isIdActionable(6)).toBe(false); + }); + + test('getActionableIds', () => { + expect(data.getActionableIds()).toEqual([1, 2]); + expect(data.getActionableIds('bogus')).toEqual([]); + }); + + test('allActionable', () => { + expect(data.allActionable).toBe(false); + expect( + new OperationConfirmationData({ + allowed: [{ rowId: 1 }, { rowId: 2 }], + notAllowed: [], + notPermitted: [], + }).allActionable + ).toBe(true); + }); + + test('noneActionable', () => { + expect(data.noneActionable).toBe(false); + expect( + new OperationConfirmationData({ + allowed: [], + notAllowed: [], + notPermitted: [], + }).noneActionable + ).toBe(false); + expect( + new OperationConfirmationData({ + allowed: [{ rowId: 1 }, { rowId: 2 }], + notAllowed: [], + notPermitted: [], + }).noneActionable + ).toBe(false); + expect( + new OperationConfirmationData({ + allowed: [], + notAllowed: [{ rowId: 1 }, { rowId: 2 }], + notPermitted: [], + }).noneActionable + ).toBe(true); + expect( + new OperationConfirmationData({ + allowed: [], + notAllowed: [], + notPermitted: [{ rowId: 1 }, { rowId: 2 }], + }).noneActionable + ).toBe(true); + }); + + test('anyActionable', () => { + expect(data.anyActionable).toBe(true); + expect( + new OperationConfirmationData({ + allowed: [], + notAllowed: [], + notPermitted: [], + }).anyActionable + ).toBe(false); + expect( + new OperationConfirmationData({ + allowed: [{ rowId: 1 }, { rowId: 2 }], + notAllowed: [], + notPermitted: [], + }).anyActionable + ).toBe(true); + expect( + new OperationConfirmationData({ + allowed: [], + notAllowed: [{ rowId: 1 }, { rowId: 2 }], + notPermitted: [], + }).anyActionable + ).toBe(false); + expect( + new OperationConfirmationData({ + allowed: [], + notAllowed: [], + notPermitted: [{ rowId: 1 }, { rowId: 2 }], + }).anyActionable + ).toBe(false); + }); + + test('anyNotActionable', () => { + expect(data.anyNotActionable).toBe(true); + expect( + new OperationConfirmationData({ + allowed: [], + notAllowed: [], + notPermitted: [], + }).anyNotActionable + ).toBe(false); + expect( + new OperationConfirmationData({ + allowed: [{ rowId: 1 }, { rowId: 2 }], + notAllowed: [], + notPermitted: [], + }).anyNotActionable + ).toBe(false); + expect( + new OperationConfirmationData({ + allowed: [], + notAllowed: [{ rowId: 1 }, { rowId: 2 }], + notPermitted: [], + }).anyNotActionable + ).toBe(true); + expect( + new OperationConfirmationData({ + allowed: [], + notAllowed: [], + notPermitted: [{ rowId: 1 }, { rowId: 2 }], + }).anyNotActionable + ).toBe(true); + }); + + test('totalCount', () => { + expect(data.totalCount).toBe(5); + expect( + new OperationConfirmationData({ + allowed: [], + notAllowed: [], + notPermitted: [], + }).totalCount + ).toBe(0); + }); + + test('getContainerPaths', () => { + expect( + new OperationConfirmationData({ + containers: [ + { id: 'a', permitted: true }, + { id: 'b', permitted: false }, + { id: 'c', permitted: true }, + ], + }).getContainerPaths() + ).toEqual(['a', 'c']); + }); +}); diff --git a/packages/components/src/internal/components/entities/models.ts b/packages/components/src/internal/components/entities/models.ts index aa6aa70335..cad76a7962 100644 --- a/packages/components/src/internal/components/entities/models.ts +++ b/packages/components/src/internal/components/entities/models.ts @@ -531,10 +531,17 @@ export interface EntityDataType { uniqueFieldKey: string; } +interface OperationContainerInfo { + id: string; + path: string; + permitted: boolean; +} + export class OperationConfirmationData { [immerable]: true; readonly allowed: any[]; + readonly containers: OperationContainerInfo[]; readonly notAllowed: any[]; readonly notPermitted: any[]; // could intersect both allowed and notAllowed readonly idMap: Record; @@ -606,6 +613,10 @@ export class OperationConfirmationData { get anyNotActionable(): boolean { return this.totalNotActionable > 0; } + + getContainerPaths(permittedOnly = true): string[] { + return this.containers?.filter(c => !permittedOnly || c.permitted).map(c => c.id) ?? []; + } } export interface CrossFolderSelectionResult { diff --git a/packages/components/src/internal/components/forms/BulkUpdateForm.tsx b/packages/components/src/internal/components/forms/BulkUpdateForm.tsx index 8714cfc3d7..c837963466 100644 --- a/packages/components/src/internal/components/forms/BulkUpdateForm.tsx +++ b/packages/components/src/internal/components/forms/BulkUpdateForm.tsx @@ -9,7 +9,7 @@ import { SchemaQuery } from '../../../public/SchemaQuery'; import { getSelectedData } from '../../actions'; -import { capitalizeFirstChar, getCommonDataValues, getUpdatedData } from '../../util/utils'; +import { capitalizeFirstChar, caseInsensitive, getCommonDataValues, getUpdatedData } from '../../util/utils'; import { QueryInfoForm } from './QueryInfoForm'; @@ -35,7 +35,7 @@ interface Props { queryInfo: QueryInfo; readOnlyColumns?: string[]; requiredColumns?: string[]; - selectedIds: Set; + selectedIds: string[]; singularNoun?: string; // sortString is used so we render editable grids with the proper sorts when using onSubmitForEdit sortString?: string; @@ -46,10 +46,10 @@ interface Props { } interface State { + containerPaths: string[]; dataForSelection: Map; dataIdsForSelection: List; displayFieldUpdates: any; - errorMsg: string; isLoadingDataForSelection: boolean; originalDataForSelection: Map; } @@ -65,11 +65,11 @@ export class BulkUpdateForm extends PureComponent { super(props); this.state = { + containerPaths: undefined, originalDataForSelection: undefined, dataForSelection: undefined, displayFieldUpdates: {}, dataIdsForSelection: undefined, - errorMsg: undefined, isLoadingDataForSelection: true, }; } @@ -93,14 +93,13 @@ export class BulkUpdateForm extends PureComponent { : undefined; let columnString = columns?.map(c => c.fieldKey).join(','); if (requiredColumns) columnString = `${columnString ? columnString + ',' : ''}${requiredColumns.join(',')}`; - const { schemaName, name } = queryInfo; try { const { data, dataIds } = await getSelectedData( schemaName, name, - Array.from(selectedIds), + selectedIds, columnString, sortString, undefined, @@ -108,6 +107,7 @@ export class BulkUpdateForm extends PureComponent { ); const mappedData = this.mapDataForDisplayFields(data); this.setState({ + containerPaths: mappedData.containerPaths, originalDataForSelection: data, dataForSelection: mappedData.data, displayFieldUpdates: mappedData.bulkUpdates, @@ -121,46 +121,59 @@ export class BulkUpdateForm extends PureComponent { } }; - mapDataForDisplayFields(data: Map): { bulkUpdates: OrderedMap; data: Map } { + mapDataForDisplayFields(data: Map): { + bulkUpdates: OrderedMap; + containerPaths?: string[]; + data: Map; + } { const { displayValueFields } = this.props; let updates = Map(); let bulkUpdates = OrderedMap(); - - if (!displayValueFields) return { data, bulkUpdates }; + const containerPaths = new Set(); let conflictKeys = new Set(); data.forEach((rowData, id) => { if (rowData) { - let updatedRow = Map(); - rowData.forEach((field, key) => { - if (displayValueFields.includes(key)) { - const valuesDiffer = - field.has('displayValue') && field.get('value') !== field.get('displayValue'); - let comparisonValue = field.get('displayValue') ?? field.get('value'); - if (comparisonValue) comparisonValue += ''; // force to string - if (!conflictKeys.has(key)) { - if (!bulkUpdates.has(key)) { - bulkUpdates = bulkUpdates.set(key, comparisonValue); - } else if (bulkUpdates.get(key) !== comparisonValue) { - bulkUpdates = bulkUpdates.remove(key); - conflictKeys = conflictKeys.add(key); + const containerPath = + caseInsensitive(rowData.toJS(), 'Folder') ?? caseInsensitive(rowData.toJS(), 'Container'); + if (containerPath?.value) containerPaths.add(containerPath.value); + + if (displayValueFields) { + let updatedRow = Map(); + rowData.forEach((field, key) => { + if (displayValueFields.includes(key)) { + const valuesDiffer = + field.has('displayValue') && field.get('value') !== field.get('displayValue'); + let comparisonValue = field.get('displayValue') ?? field.get('value'); + if (comparisonValue) comparisonValue += ''; // force to string + if (!conflictKeys.has(key)) { + if (!bulkUpdates.has(key)) { + bulkUpdates = bulkUpdates.set(key, comparisonValue); + } else if (bulkUpdates.get(key) !== comparisonValue) { + bulkUpdates = bulkUpdates.remove(key); + conflictKeys = conflictKeys.add(key); + } + } + if (valuesDiffer) { + field = field.set('value', comparisonValue); } } - if (valuesDiffer) { - field = field.set('value', comparisonValue); - } + updatedRow = updatedRow.set(key, field); + }); + if (!updatedRow.isEmpty()) { + updates = updates.set(id, updatedRow); } - updatedRow = updatedRow.set(key, field); - }); - if (!updatedRow.isEmpty()) updates = updates.set(id, updatedRow); + } } }); - if (!updates.isEmpty()) return { data: data.merge(updates), bulkUpdates }; - return { data, bulkUpdates }; + if (!updates.isEmpty()) { + return { data: data.merge(updates), bulkUpdates, containerPaths: Array.from(containerPaths) }; + } + return { data, bulkUpdates, containerPaths: Array.from(containerPaths) }; } getSelectionCount(): number { - return this.props.selectedIds.size; + return this.props.selectedIds.length; } getSelectionNoun(): string { @@ -216,7 +229,7 @@ export class BulkUpdateForm extends PureComponent { } render() { - const { isLoadingDataForSelection, dataForSelection } = this.state; + const { isLoadingDataForSelection, dataForSelection, containerPaths } = this.state; const { containerFilter, onCancel, @@ -230,6 +243,11 @@ export class BulkUpdateForm extends PureComponent { const fieldValues = isLoadingDataForSelection || !dataForSelection ? undefined : getCommonDataValues(dataForSelection); + // 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 + const containerPath = containerPaths?.length === 1 ? containerPaths[0] : undefined; + const preventCrossFolderEnable = containerPaths?.length > 1; + return ( { checkRequiredFields={false} columnFilter={this.columnFilter} containerFilter={containerFilter} + containerPath={containerPath} disabled={disabled} + preventCrossFolderEnable={preventCrossFolderEnable} fieldValues={fieldValues} header={this.renderBulkUpdateHeader()} includeCommentField={true} @@ -255,6 +275,7 @@ export class BulkUpdateForm extends PureComponent { showLabelAsterisk submitForEditText="Edit with Grid" submitText={`Update ${capitalizeFirstChar(pluralNoun)}`} + pluralNoun={pluralNoun} title={this.getTitle()} onAdditionalFormDataChange={onAdditionalFormDataChange} /> diff --git a/packages/components/src/internal/components/forms/FieldLabel.spec.tsx b/packages/components/src/internal/components/forms/FieldLabel.spec.tsx deleted file mode 100644 index 8f00a160c3..0000000000 --- a/packages/components/src/internal/components/forms/FieldLabel.spec.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (c) 2019 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 renderer from 'react-test-renderer'; -import { mount, shallow } from 'enzyme'; - -import { QueryColumn } from '../../../public/QueryColumn'; - -import { ToggleIcon } from '../buttons/ToggleButtons'; - -import { FieldLabel } from './FieldLabel'; -import { LabelOverlay } from './LabelOverlay'; - -const queryColumn = new QueryColumn({ - name: 'testColumn', - caption: 'test Column', -}); - -describe('FieldLabel', () => { - test("don't show label", () => { - const tree = renderer.create(); - expect(tree === null); - }); - - test('without overlay, with label', () => { - const label = This is the label; - const wrapper = shallow(); - expect(wrapper.find('span.label-span')).toHaveLength(1); - expect(wrapper.find(LabelOverlay)).toHaveLength(0); - }); - - test('without overlay, with column', () => { - const wrapper = shallow(); - expect(wrapper.text()).toContain(queryColumn.caption); - expect(wrapper.find(LabelOverlay)).toHaveLength(0); - }); - - test('with overlay, with label', () => { - const label = This is the label; - const wrapper = shallow(); - expect(wrapper.find(LabelOverlay)).toHaveLength(1); - }); - - test('with overlay, with column', () => { - const wrapper = mount(); - expect(wrapper.text()).toContain(queryColumn.caption); - expect(wrapper.find(LabelOverlay)).toHaveLength(1); - }); - - function verifyToggle(wrapper, classNames?: string[]) { - expect(wrapper.find(ToggleIcon)).toHaveLength(1); - if (classNames?.length > 0) { - classNames.forEach(className => expect(wrapper.find('.' + className)).toHaveLength(1)); - } - } - - test('showToggle', () => { - const wrapper = shallow(); - verifyToggle(wrapper); - }); - - test('showToggle, with labelOverlayProps, not formsy', () => { - const label = 'This is the label'; - const props = { - label, - isFormsy: false, - }; - const wrapper = shallow(); - verifyToggle(wrapper, ['control-label-toggle-input', 'control-label-toggle-input-size-fixed', 'col-xs-1']); - }); - - test('showToggle, with labelOverlayProps, formsy', () => { - const label = 'This is the label'; - const props = { - label, - isFormsy: true, - }; - const wrapper = shallow(); - verifyToggle(wrapper); - }); - - test('showToggle, with labelOverlayProps, formsy, with toggleClassName', () => { - const label = 'This is the label'; - const props = { - label, - isFormsy: true, - }; - const wrapper = shallow( - - ); - verifyToggle(wrapper, ['toggle-wrapper']); - }); - - test('showToggle, with labelOverlayProps, not formsy, with toggleClassName', () => { - const label = 'This is the label'; - const props = { - label, - isFormsy: false, - }; - const wrapper = shallow( - - ); - verifyToggle(wrapper, ['toggle-wrapper', 'col-xs-1']); - }); -}); diff --git a/packages/components/src/internal/components/forms/FieldLabel.test.tsx b/packages/components/src/internal/components/forms/FieldLabel.test.tsx new file mode 100644 index 0000000000..2edf9e4433 --- /dev/null +++ b/packages/components/src/internal/components/forms/FieldLabel.test.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Formsy from 'formsy-react'; + +import { QueryColumn } from '../../../public/QueryColumn'; + +import { FieldLabel } from './FieldLabel'; + +const queryColumn = new QueryColumn({ + name: 'testColumn', + caption: 'test Column', +}); + +describe('FieldLabel', () => { + beforeAll(() => { + console.warn = jest.fn(); + }); + + test("don't show label", () => { + render(); + expect(document.body.textContent).toBe(''); + }); + + test('without overlay, with label', () => { + const label = This is the label; + render(); + expect(document.querySelector('span.label-span').textContent).toBe('This is the label'); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(0); + }); + + test('without overlay, with column', () => { + render(); + expect(document.body.textContent).toBe(queryColumn.caption); + expect(document.querySelectorAll('.span.label-span')).toHaveLength(0); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(0); + }); + + test('with overlay, with label', () => { + const label = This is the label; + render(); + expect(document.body.textContent).toBe(' '); + expect(document.querySelectorAll('.span.label-span')).toHaveLength(0); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); + }); + + test('with overlay, with column', () => { + render(); + expect(document.body.textContent).toBe(queryColumn.caption + ' '); + expect(document.querySelectorAll('.span.label-span')).toHaveLength(0); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); + }); + + test('showToggle', () => { + render( + + + + ); + expect(document.querySelectorAll('.toggle')).toHaveLength(1); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); + }); + + test('showToggle, with labelOverlayProps, not formsy', () => { + const label = 'This is the label'; + const props = { + label, + isFormsy: false, + }; + render( + + + + ); + expect(document.querySelectorAll('.toggle')).toHaveLength(1); + expect(document.querySelectorAll('.control-label-toggle-input')).toHaveLength(1); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); + }); + + test('showToggle, with labelOverlayProps, formsy', () => { + const label = 'This is the label'; + const props = { + label, + isFormsy: true, + }; + render( + + + + ); + expect(document.querySelectorAll('.toggle')).toHaveLength(1); + expect(document.querySelectorAll('.control-label-toggle-input')).toHaveLength(1); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); + }); + + test('showToggle, with labelOverlayProps, formsy, with toggleClassName', () => { + const label = 'This is the label'; + const props = { + label, + isFormsy: true, + }; + render( + + + + ); + expect(document.querySelectorAll('.toggle')).toHaveLength(1); + expect(document.querySelectorAll('.toggle-wrapper')).toHaveLength(1); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); + }); + + test('showToggle, with labelOverlayProps, not formsy, with toggleClassName', () => { + const label = 'This is the label'; + const props = { + label, + isFormsy: false, + }; + render( + + + + ); + expect(document.querySelectorAll('.toggle')).toHaveLength(1); + expect(document.querySelectorAll('.toggle-wrapper')).toHaveLength(1); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); + }); + + test('showToggle, toggleProps disabled', () => { + render( + + + + ); + expect(document.querySelectorAll('.toggle')).toHaveLength(1); + expect(document.querySelectorAll('.disabled')).toHaveLength(1); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(2); + }); + + test('showToggle, toggleProps not disabled', () => { + render( + + + + ); + expect(document.querySelectorAll('.toggle')).toHaveLength(1); + expect(document.querySelectorAll('.disabled')).toHaveLength(0); + expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); + }); +}); diff --git a/packages/components/src/internal/components/forms/FieldLabel.tsx b/packages/components/src/internal/components/forms/FieldLabel.tsx index 42eebc29ef..f544647d04 100644 --- a/packages/components/src/internal/components/forms/FieldLabel.tsx +++ b/packages/components/src/internal/components/forms/FieldLabel.tsx @@ -15,6 +15,7 @@ import { LabelOverlay, LabelOverlayProps } from './LabelOverlay'; interface ToggleProps { onClick: () => void; + toolTip?: string; } export interface FieldLabelProps { @@ -93,6 +94,8 @@ export class FieldLabel extends Component { inputFieldName={getFieldEnabledFieldName(column, fieldName)} active={!isDisabled ? 'on' : 'off'} onClick={toggleProps?.onClick} + disabled={!toggleProps?.onClick} + toolTip={toggleProps?.toolTip} />
diff --git a/packages/components/src/internal/components/forms/QueryFormInputs.tsx b/packages/components/src/internal/components/forms/QueryFormInputs.tsx index bda7a9dffc..00e4bc91f3 100644 --- a/packages/components/src/internal/components/forms/QueryFormInputs.tsx +++ b/packages/components/src/internal/components/forms/QueryFormInputs.tsx @@ -25,6 +25,10 @@ import { QueryInfo } from '../../../public/QueryInfo'; import { caseInsensitive } from '../../util/utils'; +import { getContainerFilterForLookups } from '../../query/api'; + +import { isAllProductFoldersFilteringEnabled } from '../../app/utils'; + import { FormsyInput } from './input/FormsyReactComponents'; import { resolveInputRenderer } from './input/InputRenderFactory'; import { QuerySelect } from './QuerySelect'; @@ -46,9 +50,11 @@ export interface QueryFormInputsProps { columnFilter?: (col?: QueryColumn) => boolean; // this can be used when you want to keep certain columns always filtered out (e.g., aliquot- or sample-only columns) isIncludedColumn?: (col: QueryColumn) => boolean; - componentKey?: string; // unique key to add to QuerySelect to avoid duplication w/ transpose + componentKey?: string; + // unique key to add to QuerySelect to avoid duplication w/ transpose /** A container filter that will be applied to all query-based inputs in this form */ containerFilter?: Query.ContainerFilter; + containerPath?: string; disabledFields?: List; fieldValues?: any; fireQSChangeOnInit?: boolean; @@ -59,6 +65,8 @@ export interface QueryFormInputsProps { onFieldsEnabledChange?: (numEnabled: number) => void; operation?: Operation; onSelectChange?: SelectInputChange; + pluralNoun?: string; + preventCrossFolderEnable?: boolean; queryColumns?: ExtendedMap; queryFilters?: Record>; queryInfo?: QueryInfo; @@ -155,6 +163,9 @@ export class QueryFormInputs extends React.Component {this.renderLabelField(col)} ); diff --git a/packages/components/src/internal/components/forms/QueryInfoForm.tsx b/packages/components/src/internal/components/forms/QueryInfoForm.tsx index 35447b15a2..12280af3bb 100644 --- a/packages/components/src/internal/components/forms/QueryInfoForm.tsx +++ b/packages/components/src/internal/components/forms/QueryInfoForm.tsx @@ -32,28 +32,30 @@ import { formatDate, formatDateTime } from '../../util/Date'; import { Alert } from '../base/Alert'; import { LoadingSpinner } from '../base/LoadingSpinner'; +import { getAppHomeFolderPath } from '../../app/utils'; + +import { ComponentsAPIWrapper, getDefaultAPIWrapper } from '../../APIWrapper'; + import { QueryInfoQuantity } from './QueryInfoQuantity'; import { QueryFormInputs, QueryFormInputsProps } from './QueryFormInputs'; import { getFieldEnabledFieldName } from './utils'; import { CommentTextArea } from './input/CommentTextArea'; -import { getAppHomeFolderPath } from '../../app/utils'; -import { ComponentsAPIWrapper, getDefaultAPIWrapper } from '../../APIWrapper'; export interface QueryInfoFormProps extends Omit { - api?: ComponentsAPIWrapper, + api?: ComponentsAPIWrapper; asModal?: boolean; canSubmitNotDirty?: boolean; cancelText?: string; countText?: string; - disabled?: boolean; creationTypeOptions?: SampleCreationTypeModel[]; + disabled?: boolean; errorCallback?: (error: any) => void; errorMessagePrefix?: string; footer?: ReactNode; header?: ReactNode; hideButtons?: boolean; - includeCountField?: boolean; includeCommentField?: boolean; + includeCountField?: boolean; isLoading?: boolean; isSubmittedText?: string; isSubmittingText?: string; @@ -72,22 +74,22 @@ export interface QueryInfoFormProps extends Omit { isSubmittingText: 'Submitting...', maxCount: MAX_EDITABLE_GRID_ROWS, creationTypeOptions: [], + pluralNoun: 'rows', }; constructor(props: QueryInfoFormProps) { @@ -133,12 +136,11 @@ export class QueryInfoForm extends PureComponent { if (includeCommentField) { (async () => { try { - const {container} = getServerContext(); + const { container } = getServerContext(); const response = await api.folder.getAuditSettings(getAppHomeFolderPath(new Container(container))); - this.setState({requiresUserComment: !!response?.requireUserComments}); - } - catch { - this.setState({requiresUserComment: false}); + this.setState({ requiresUserComment: !!response?.requireUserComments }); + } catch { + this.setState({ requiresUserComment: false }); } })(); } @@ -291,7 +293,7 @@ export class QueryInfoForm extends PureComponent { onCommentChange = (comment: string): void => { this.setState({ comment }); - } + }; onFieldsEnabledChange = (fieldEnabledCount: number): void => { this.setState({ fieldEnabledCount }); @@ -316,7 +318,17 @@ export class QueryInfoForm extends PureComponent { hideButtons, } = this.props; - const { count, comment, canSubmit, fieldEnabledCount, isSubmitting, isSubmitted, submitForEdit, isDirty, requiresUserComment } = this.state; + const { + count, + comment, + canSubmit, + fieldEnabledCount, + isSubmitting, + isSubmitted, + submitForEdit, + isDirty, + requiresUserComment, + } = this.state; if (hideButtons) return null; @@ -439,7 +451,11 @@ export class QueryInfoForm extends PureComponent { onCountChange={this.onCountChange} /> {(header || showQuantityHeader) &&
} - + {footer} {showErrorsAtBottom && this.renderError()} {!asModal && this.renderButtons()} diff --git a/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx b/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx index fb07ea20a2..f1ebf79eb7 100644 --- a/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx +++ b/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx @@ -39,6 +39,7 @@ import { CheckboxInput } from '../input/CheckboxInput'; import { NoLinkRenderer } from '../../../renderers/NoLinkRenderer'; import { UserDetailsRenderer } from '../../../renderers/UserDetailsRenderer'; import { ExpirationDateColumnRenderer } from '../../../renderers/ExpirationDateColumnRenderer'; +import { getContainerFilterForLookups } from '../../../query/api'; export type Renderer = (data: any, row?: any) => ReactNode; @@ -320,7 +321,9 @@ export function resolveDetailEditRenderer( return ( validationRules.isNumeric(values, v) || 'Please enter a number.'; export const AppendUnitsInput: FC = memo(props => { - const { col, formsy, initiallyDisabled, inputClass, showLabel, value } = props; + const { col, formsy, inputClass, showLabel, value, ...otherProps } = props; + const { allowFieldDisable, initiallyDisabled, onToggleDisable } = otherProps; // Issue 23462: Global Formsy validation rule for numbers if (!validationRules.isNumericWithError) { @@ -24,9 +25,11 @@ export const AppendUnitsInput: FC = memo(props => { return ( {col.units}} elementWrapperClassName={inputClass} - initiallyDisabled={initiallyDisabled} queryColumn={col} showLabel={showLabel} validations="isNumericWithError" diff --git a/packages/components/src/internal/components/forms/input/FileInput.tsx b/packages/components/src/internal/components/forms/input/FileInput.tsx index 259eaddc08..3628b8624f 100644 --- a/packages/components/src/internal/components/forms/input/FileInput.tsx +++ b/packages/components/src/internal/components/forms/input/FileInput.tsx @@ -39,6 +39,7 @@ interface Props extends DisableableInputProps, WithFormsyProps { queryColumn?: QueryColumn; renderFieldLabel?: (queryColumn: QueryColumn, label?: string, description?: string) => ReactNode; showLabel?: boolean; + toggleDisabledTooltip?: string; } interface State extends DisableableInputState { @@ -158,6 +159,7 @@ class FileInputImpl extends DisableableInput { queryColumn, renderFieldLabel, showLabel, + toggleDisabledTooltip, } = this.props; const { data, file, isDisabled, isHover } = this.state; @@ -243,7 +245,8 @@ class FileInputImpl extends DisableableInput { column={queryColumn} isDisabled={isDisabled} toggleProps={{ - onClick: this.toggleDisabled, + onClick: toggleDisabledTooltip ? undefined : this.toggleDisabled, + toolTip: toggleDisabledTooltip, }} /> )} diff --git a/packages/components/src/internal/components/forms/input/SelectInput.tsx b/packages/components/src/internal/components/forms/input/SelectInput.tsx index e366c2c41b..4836ee70ca 100644 --- a/packages/components/src/internal/components/forms/input/SelectInput.tsx +++ b/packages/components/src/internal/components/forms/input/SelectInput.tsx @@ -222,6 +222,7 @@ export interface SelectInputProps extends WithFormsyProps { showIndicatorSeparator?: boolean; showLabel?: boolean; tabSelectsValue?: boolean; + toggleDisabledTooltip?: string; value?: any; valueKey?: string; valueRenderer?: any; @@ -310,7 +311,7 @@ export class SelectInputImpl extends Component { this._isMounted = false; } - toggleDisabled = (): void => { + onToggleChange = (): void => { this.setState( state => ({ isDisabled: !state.isDisabled, @@ -473,6 +474,7 @@ export class SelectInputImpl extends Component { addLabelAsterisk, renderFieldLabel, helpTipRenderer, + toggleDisabledTooltip, } = this.props; const { isDisabled } = this.state; @@ -500,14 +502,15 @@ export class SelectInputImpl extends Component { addLabelAsterisk, isFormsy: false, required, - labelClass: !allowDisable ? this.props.labelClass : undefined, + labelClass: !allowDisable ? labelClass : undefined, helpTipRenderer, }} showLabel={showLabel} showToggle={allowDisable} isDisabled={isDisabled} toggleProps={{ - onClick: this.toggleDisabled, + onClick: toggleDisabledTooltip ? undefined : this.onToggleChange, + toolTip: toggleDisabledTooltip, }} /> ); diff --git a/packages/components/src/internal/components/samples/utils.test.ts b/packages/components/src/internal/components/samples/utils.test.ts index 8a8df085ac..4a86793c28 100644 --- a/packages/components/src/internal/components/samples/utils.test.ts +++ b/packages/components/src/internal/components/samples/utils.test.ts @@ -281,10 +281,10 @@ describe('getOperationNotPermittedMessage', () => { test('with notPermitted', () => { expect(getOperationNotPermittedMessage(new OperationConfirmationData({ notPermitted: [1] }))).toBe( - "1 of the selected samples isn't shown because you don't have permissions to edit in that project." + 'The selection includes 1 sample that you do not have permission to edit. Updates will only be made to the samples you have edit permission for.' ); expect(getOperationNotPermittedMessage(new OperationConfirmationData({ notPermitted: [1, 2] }))).toBe( - "2 of the selected samples aren't shown because you don't have permissions to edit in that project." + 'The selection includes 2 samples that you do not have permission to edit. Updates will only be made to the samples you have edit permission for.' ); }); }); diff --git a/packages/components/src/internal/components/samples/utils.tsx b/packages/components/src/internal/components/samples/utils.tsx index 88673ee673..a1311db546 100644 --- a/packages/components/src/internal/components/samples/utils.tsx +++ b/packages/components/src/internal/components/samples/utils.tsx @@ -200,10 +200,15 @@ export function getOperationNotAllowedMessage( return null; } -export function getOperationNotPermittedMessage(statusData: OperationConfirmationData, nounPlural = 'samples'): string { +export function getOperationNotPermittedMessage( + statusData: OperationConfirmationData, + nounSingular = 'sample', + nounPlural = 'samples' +): string { if (statusData && statusData.notPermitted?.length > 0) { const notPermittedCount = statusData.notPermitted.length; - return `${notPermittedCount.toLocaleString()} of the selected ${nounPlural.toLowerCase()} ${notPermittedCount > 1 ? "aren't" : "isn't"} shown because you don't have permissions to edit in that project.`; + const noun = notPermittedCount === 1 ? nounSingular : nounPlural; + return `The selection includes ${notPermittedCount.toLocaleString()} ${noun.toLowerCase()} that you do not have permission to edit. Updates will only be made to the ${nounPlural.toLowerCase()} you have edit permission for.`; } return null; } diff --git a/packages/components/src/internal/query/APIWrapper.ts b/packages/components/src/internal/query/APIWrapper.ts index 06e7a9ae06..212fbb6cae 100644 --- a/packages/components/src/internal/query/APIWrapper.ts +++ b/packages/components/src/internal/query/APIWrapper.ts @@ -46,6 +46,7 @@ import { QueryCommandResponse, selectDistinctRows, updateRows, + updateRowsByContainer, UpdateRowsOptions, getDefaultVisibleColumns, saveRowsByContainer, @@ -106,7 +107,7 @@ export interface QueryAPIWrapper { inherit: boolean, shared: boolean ) => Promise; - saveRowsByContainer: (options: SaveRowsOptions, containerField: string) => Promise; + saveRowsByContainer: (options: SaveRowsOptions, containerField?: string) => Promise; saveSessionView: ( schemaQuery: SchemaQuery, containerPath: string, @@ -131,6 +132,13 @@ export interface QueryAPIWrapper { ) => Promise; setSnapshotSelections: (key: string, ids: string[] | string, containerPath?: string) => Promise; updateRows: (options: UpdateRowsOptions) => Promise; + updateRowsByContainer: ( + schemaQuery: SchemaQuery, + rows: any[], + containerPaths: string[], + auditUserComment: string, + containerField?: string + ) => Promise; } export class QueryServerAPIWrapper implements QueryAPIWrapper { @@ -157,6 +165,7 @@ export class QueryServerAPIWrapper implements QueryAPIWrapper { setSelected = setSelected; setSnapshotSelections = setSnapshotSelections; updateRows = updateRows; + updateRowsByContainer = updateRowsByContainer; getDefaultVisibleColumns = getDefaultVisibleColumns; } @@ -191,6 +200,7 @@ export function getQueryTestAPIWrapper( setSelected: mockFn(), setSnapshotSelections: mockFn(), updateRows: mockFn(), + updateRowsByContainer: mockFn(), getDefaultVisibleColumns: mockFn(), ...overrides, }; diff --git a/packages/components/src/internal/query/api.ts b/packages/components/src/internal/query/api.ts index 3b73362dcc..2b648d0527 100644 --- a/packages/components/src/internal/query/api.ts +++ b/packages/components/src/internal/query/api.ts @@ -16,7 +16,7 @@ import { fromJS, List, Map, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable'; import { immerable } from 'immer'; import { normalize, schema } from 'normalizr'; -import { Ajax, Filter, Query, QueryDOM, Utils } from '@labkey/api'; +import { Ajax, AuditBehaviorTypes, Filter, Query, QueryDOM, Utils } from '@labkey/api'; import { ExtendedMap } from '../../public/ExtendedMap'; @@ -948,6 +948,37 @@ export function updateRows(options: UpdateRowsOptions): Promise { + // if all rows are in the same container, we can use updateRows (which supports file/attachments) + if (containerPaths.length < 2) { + return updateRows({ + containerPath: containerPaths?.[0], + auditBehavior: AuditBehaviorTypes.DETAILED, + auditUserComment, + rows, + schemaQuery, + }); + } else { + const commands = []; + commands.push({ + command: 'update', + schemaName: schemaQuery.schemaName, + queryName: schemaQuery.queryName, + rows, + auditBehavior: AuditBehaviorTypes.DETAILED, + auditUserComment, + skipReselectRows: true, + }); + return saveRowsByContainer({ commands }, containerField); + } +} + export interface DeleteRowsOptions extends Omit { schemaQuery: SchemaQuery; } diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index 48a3dc523e..c7a3838759 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -40,6 +40,7 @@ import { capitalizeFirstChar, uncapitalizeFirstChar, withTransformedKeys, + getValueFromRow, } from './utils'; const emptyList = List(); @@ -710,7 +711,7 @@ describe('getUpdatedData', () => { }); }); - test('with additionalPkCols', () => { + test('with additionalCols', () => { const updatedData = getUpdatedData( originalData, { @@ -740,6 +741,100 @@ describe('getUpdatedData', () => { Data: 'data1', }); }); + + test('with folder', () => { + const originalData_ = fromJS({ + '448': { + RowId: { + value: 448, + url: '/labkey/Sample%20Management/experiment-showMaterial.view?rowId=448', + }, + Value: { + value: null, + }, + Data: { + value: 'data1', + }, + 'And/Again': { + value: 'again', + }, + Name: { + value: 'S-20190516-9042', + url: '/labkey/Sample%20Management/experiment-showMaterial.view?rowId=448', + }, + Other: { + value: 'other1', + }, + Folder: { + displayValue: 'ProjectA', + value: 'ENTITYID-A', + }, + }, + }); + + const updatedData = getUpdatedData( + originalData_, + { + Value: 'val', + And$SAgain: 'again', + Other: 'other3', + }, + List(['RowId']) + ); + expect(updatedData[0]).toStrictEqual({ + RowId: 448, + Value: 'val', + Other: 'other3', + Folder: 'ENTITYID-A', + }); + }); + + test('with container', () => { + const originalData_ = fromJS({ + '448': { + RowId: { + value: 448, + url: '/labkey/Sample%20Management/experiment-showMaterial.view?rowId=448', + }, + Value: { + value: null, + }, + Data: { + value: 'data1', + }, + 'And/Again': { + value: 'again', + }, + Name: { + value: 'S-20190516-9042', + url: '/labkey/Sample%20Management/experiment-showMaterial.view?rowId=448', + }, + Other: { + value: 'other1', + }, + Container: { + displayValue: 'ProjectA', + value: 'ENTITYID-A', + }, + }, + }); + + const updatedData = getUpdatedData( + originalData_, + { + Value: 'val', + And$SAgain: 'again', + Other: 'other3', + }, + List(['RowId']) + ); + expect(updatedData[0]).toStrictEqual({ + RowId: 448, + Value: 'val', + Other: 'other3', + Container: 'ENTITYID-A', + }); + }); }); describe('CaseInsensitive', () => { @@ -1233,3 +1328,36 @@ describe('arrayEquals', () => { expect(arrayEquals(['a', 'b'], ['B', 'A'], false)).toBeFalsy(); }); }); + +describe('getValueFromRow', () => { + test('no row', () => { + expect(getValueFromRow(undefined, 'Name')).toEqual(undefined); + expect(getValueFromRow({}, 'Name')).toEqual(undefined); + }); + + test('returns value', () => { + const row = { Name: 'test' }; + expect(getValueFromRow(row, 'Name')).toEqual('test'); + expect(getValueFromRow(row, 'name')).toEqual('test'); + expect(getValueFromRow(row, 'bogus')).toEqual(undefined); + }); + + test('returns value from object', () => { + const row = { Name: { value: 'test' } }; + expect(getValueFromRow(row, 'Name')).toEqual('test'); + expect(getValueFromRow(row, 'name')).toEqual('test'); + expect(getValueFromRow(row, 'bogus')).toEqual(undefined); + }); + + test('returns value from array', () => { + let row = { Name: ['test1', 'test2'] }; + expect(getValueFromRow(row, 'Name')).toEqual(undefined); + expect(getValueFromRow(row, 'name')).toEqual(undefined); + expect(getValueFromRow(row, 'bogus')).toEqual(undefined); + + row = { Name: [{ value: 'test1' }, { value: 'test2' }] }; + expect(getValueFromRow(row, 'Name')).toEqual('test1'); + expect(getValueFromRow(row, 'name')).toEqual('test1'); + expect(getValueFromRow(row, 'bogus')).toEqual(undefined); + }); +}); diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts index f2621e6005..deae3b677c 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -300,18 +300,26 @@ function isSameWithStringCompare(value1: any, value2: any): boolean { * @param originalData a map from an id field to a Map from fieldKeys to an object with a 'value' field * @param updatedValues an object mapping fieldKeys to values that are being updated * @param primaryKeys the list of primary fieldKey names - * @param additionalPkCols additional array of primary fieldKey names + * @param additionalCols additional array of fieldKeys to include */ export function getUpdatedData( originalData: Map, updatedValues: any, primaryKeys: string[], - additionalPkCols?: Set + additionalCols?: Set ): any[] { const updateValuesMap = Map(updatedValues); const pkColsLc = new Set(); primaryKeys.forEach(key => pkColsLc.add(key.toLowerCase())); - additionalPkCols?.forEach(col => pkColsLc.add(col.toLowerCase())); + additionalCols?.forEach(col => pkColsLc.add(col.toLowerCase())); + + // if the originalData has the container/folder values, keep those as well (i.e. treat it as a primary key) + const folderKey = originalData + .first() + .keySeq() + .find(key => key.toLowerCase() === 'folder' || key.toLowerCase() === 'container'); + if (folderKey) pkColsLc.add(folderKey.toLowerCase()); + const updatedData = originalData.map(originalRowMap => { return originalRowMap.reduce((m, fieldValueMap, key) => { // Issue 42672: The original data has keys that are names or captions for the columns. We need to use @@ -708,3 +716,15 @@ export function arrayEquals(a: string[], b: string[], ignoreOrder = true, caseIn return caseInsensitive ? aStr.toLowerCase() === bStr.toLowerCase() : aStr === bStr; } + +export function getValueFromRow(row: Record, col: string): string | number { + if (!row) return undefined; + + const val = caseInsensitive(row, col); + if (Utils.isArray(val)) { + return val[0]?.value; + } else if (Utils.isObject(val)) { + return val?.value; + } + return val; +} diff --git a/packages/components/src/public/QueryModel/QueryModel.test.ts b/packages/components/src/public/QueryModel/QueryModel.test.ts index 13c6d73eb5..a6fb5e8264 100644 --- a/packages/components/src/public/QueryModel/QueryModel.test.ts +++ b/packages/components/src/public/QueryModel/QueryModel.test.ts @@ -235,6 +235,20 @@ describe('QueryModel', () => { expect(model.getSelectedIdsAsInts()[2]).toBe(2); }); + test('getSelectedIds', () => { + let model = new QueryModel({ schemaQuery: SCHEMA_QUERY }); + expect(model.getSelectedIds()).toBe(undefined); + model = model.mutate({ selections: new Set([]) }); + expect(model.getSelectedIds().length).toBe(0); + model = model.mutate({ selections: new Set(['1', '3', '2']) }); + expect(model.getSelectedIds().length).toBe(3); + expect(model.getSelectedIds()[0]).toBe('1'); + expect(model.getSelectedIds()[1]).toBe('3'); + expect(model.getSelectedIds()[2]).toBe('2'); + expect(model.getSelectedIds([2, 3]).length).toBe(1); + expect(model.getSelectedIds()[0]).toBe('1'); + }); + test('filters', () => { const viewName = 'TEST_VIEW'; const view = ViewInfo.fromJson({ diff --git a/packages/components/src/public/QueryModel/QueryModel.ts b/packages/components/src/public/QueryModel/QueryModel.ts index 80a17eee67..24cfa34699 100644 --- a/packages/components/src/public/QueryModel/QueryModel.ts +++ b/packages/components/src/public/QueryModel/QueryModel.ts @@ -603,10 +603,9 @@ export class QueryModel { if (excludeViewFilters) { // Issue 49634: LKSM: Saving search filters in default grid view has odd behavior const searchViewFilters = this.viewFilters.filter(f => f.getColumnName() === '*'); - return [...baseFilters, ...searchViewFilters] + return [...baseFilters, ...searchViewFilters]; } return [...baseFilters, ...this.viewFilters]; - } get modelFilters(): Filter.IFilter[] { @@ -956,6 +955,16 @@ export class QueryModel { return undefined; } + /** + * Return the selection ids as an optionally filtered, array. + */ + getSelectedIds(filterIds: number[] = []): string[] { + if (this.selections) { + return Array.from(this.selections).filter(s => filterIds.indexOf(parseInt(s, 10)) === -1); + } + return undefined; + } + /** * Get the row selection state (ALL, SOME, or NONE) for the QueryModel. */ diff --git a/packages/components/src/theme/fields.scss b/packages/components/src/theme/fields.scss index f3a1dfeb78..2f91a7f053 100644 --- a/packages/components/src/theme/fields.scss +++ b/packages/components/src/theme/fields.scss @@ -138,6 +138,10 @@ color: $gray-light; } } +.toggle-group-icon.disabled i { + cursor: default; + color: $gray-lighter; +} .control-toggle-btn-group { display: flex;