diff --git a/packages/grid-pro/src/vaadin-grid-pro-edit-column-mixin.d.ts b/packages/grid-pro/src/vaadin-grid-pro-edit-column-mixin.d.ts index dd51709791..47e0881ee6 100644 --- a/packages/grid-pro/src/vaadin-grid-pro-edit-column-mixin.d.ts +++ b/packages/grid-pro/src/vaadin-grid-pro-edit-column-mixin.d.ts @@ -58,6 +58,24 @@ export declare class GridProEditColumnMixinClass { */ editorValuePath: string; + /** + * A function to check whether a specific cell of this column can be + * edited. This allows to disable editing of individual rows or cells, + * based on the item. + * + * Receives a `model` object containing the item for an individual row, + * and should return a boolean indicating whether the column's cell in + * that row is editable. + * + * The `model` object contains: + * - `model.index` The index of the item. + * - `model.item` The item. + * - `model.expanded` Sublevel toggle state. + * - `model.level` Level of the tree represented with a horizontal offset of the toggle button. + * - `model.selected` Selected state. + */ + isCellEditable: (model: GridItemModel) => boolean; + protected _getEditorComponent(cell: HTMLElement): HTMLElement | null; protected _getEditorValue(editor: HTMLElement): unknown | null; diff --git a/packages/grid-pro/src/vaadin-grid-pro-edit-column-mixin.js b/packages/grid-pro/src/vaadin-grid-pro-edit-column-mixin.js index 1819b38ae8..9b5d994673 100644 --- a/packages/grid-pro/src/vaadin-grid-pro-edit-column-mixin.js +++ b/packages/grid-pro/src/vaadin-grid-pro-edit-column-mixin.js @@ -8,7 +8,6 @@ * See https://vaadin.com/commercial-license-and-service-terms for the full * license. */ -import { addValueToAttribute } from '@vaadin/component-base/src/dom-utils.js'; import { get, set } from '@vaadin/component-base/src/path-utils.js'; /** @@ -84,13 +83,36 @@ export const GridProEditColumnMixin = (superClass) => sync: true, }, + /** + * A function to check whether a specific cell of this column can be + * edited. This allows to disable editing of individual rows or cells, + * based on the item. + * + * Receives a `model` object containing the item for an individual row, + * and should return a boolean indicating whether the column's cell in + * that row is editable. + * + * The `model` object contains: + * - `model.index` The index of the item. + * - `model.item` The item. + * - `model.expanded` Sublevel toggle state. + * - `model.level` Level of the tree represented with a horizontal offset of the toggle button. + * - `model.selected` Selected state. + * + * @type {(model: GridItemModel) => boolean} + */ + isCellEditable: { + type: Function, + observer: '_isCellEditableChanged', + }, + /** @private */ _oldRenderer: Function, }; } static get observers() { - return ['_editModeRendererChanged(editModeRenderer, __initialized)', '_cellsChanged(_cells)']; + return ['_editModeRendererChanged(editModeRenderer, __initialized)']; } constructor() { @@ -121,11 +143,9 @@ export const GridProEditColumnMixin = (superClass) => } /** @private */ - _cellsChanged() { - this._cells.forEach((cell) => { - const target = cell._focusButton || cell; - addValueToAttribute(target, 'part', 'editable-cell'); - }); + _isCellEditableChanged(newValue, oldValue) { + // Re-render grid to update editable-cell part names + this._grid.requestContentUpdate(); } /** @private */ diff --git a/packages/grid-pro/src/vaadin-grid-pro-inline-editing-mixin.js b/packages/grid-pro/src/vaadin-grid-pro-inline-editing-mixin.js index 53edc48be4..208f6ef275 100644 --- a/packages/grid-pro/src/vaadin-grid-pro-inline-editing-mixin.js +++ b/packages/grid-pro/src/vaadin-grid-pro-inline-editing-mixin.js @@ -11,6 +11,7 @@ import { animationFrame } from '@vaadin/component-base/src/async.js'; import { Debouncer } from '@vaadin/component-base/src/debounce.js'; import { get, set } from '@vaadin/component-base/src/path-utils.js'; +import { iterateRowCells, updatePart } from '@vaadin/grid/src/vaadin-grid-helpers.js'; /** * @polymerMixin @@ -218,6 +219,10 @@ export const InlineEditingMixin = (superClass) => return; } + if (!this._isCellEditable(cell)) { + return; + } + callback(e); } }); @@ -325,8 +330,10 @@ export const InlineEditingMixin = (superClass) => /** @private */ _startEdit(cell, column) { + const isCellEditable = this._isCellEditable(cell); + // TODO: remove `_editingDisabled` after Flow counterpart is updated. - if (this.disabled || this._editingDisabled) { + if (this.disabled || this._editingDisabled || !isCellEditable) { return; } // Cancel debouncer enqueued on focusout @@ -421,59 +428,67 @@ export const InlineEditingMixin = (superClass) => this._cancelStopEdit(); - const cols = this._getEditColumns(); - + const editableColumns = this._getEditColumns(); const { cell, column, model } = this.__edited; - const colIndex = cols.indexOf(column); - const { index } = model; - let nextCol = null; - let nextIdx = index; - - // Enter key - if (e.keyCode === 13) { - nextCol = column; - - // Move up / down - if (this.enterNextRow) { - nextIdx = e.shiftKey ? index - 1 : index + 1; - } + this._stopEdit(); + e.preventDefault(); + // Prevent vaadin-grid handler from being called + e.stopImmediatePropagation(); + + // Try to find the next editable cell + let nextIndex = model.index; + let nextColumn = column; + let nextCell = cell; + let directionX = 0; + let directionY = 0; + + // Enter key: move up / down + if (e.keyCode === 13 && this.enterNextRow) { + directionY = e.shiftKey ? -1 : 1; } // Tab: move right / left if (e.keyCode === 9) { - if (e.shiftKey) { - if (cols[colIndex - 1]) { - nextCol = cols[colIndex - 1]; - } else if (index > 0) { - nextIdx = index - 1; - nextCol = cols[cols.length - 1]; + directionX = e.shiftKey ? -1 : 1; + } + + if (directionX || directionY) { + while (nextCell) { + if (directionX) { + // Move horizontally + nextColumn = editableColumns[editableColumns.indexOf(nextColumn) + directionX]; + if (!nextColumn) { + // Wrap to the next or previous row + nextIndex += directionX; + nextColumn = editableColumns[directionX > 0 ? 0 : editableColumns.length - 1]; + } + } + // Move vertically + if (directionY) { + nextIndex += directionY; + } + // Stop looking if the next cell is editable + const nextRow = this._getRowByIndex(nextIndex); + // eslint-disable-next-line @typescript-eslint/no-loop-func + nextCell = nextRow && Array.from(nextRow.children).find((cell) => cell._column === nextColumn); + if (nextCell && this._isCellEditable(nextCell)) { + break; } - } else if (cols[colIndex + 1]) { - nextCol = cols[colIndex + 1]; - } else { - nextIdx = index + 1; - nextCol = cols[0]; } } - const nextRow = nextIdx === index ? cell.parentNode : this._getRowByIndex(nextIdx) || null; - - this._stopEdit(); - - if (nextRow && nextCol) { - const nextCell = Array.from(nextRow.children).find((cell) => cell._column === nextCol); - e.preventDefault(); - - // Prevent vaadin-grid handler from being called - e.stopImmediatePropagation(); + // Focus current cell as fallback + if (!nextCell) { + nextCell = cell; + nextIndex = model.index; + } - if (!this.singleCellEdit && nextCell !== cell) { - this._startEdit(nextCell, nextCol); - } else { - this._ensureScrolledToIndex(nextIdx); - nextCell.focus(); - } + if (!this.singleCellEdit && nextCell !== cell) { + this._startEdit(nextCell, nextColumn); + } else { + this._ensureScrolledToIndex(nextIndex); + nextCell.focus(); } } @@ -492,6 +507,38 @@ export const InlineEditingMixin = (superClass) => super._updateItem(row, item); } + /** + * Override method from `StylingMixin` to apply `editable-cell` part to the + * cells of edit columns. + * + * @override + */ + _generateCellPartNames(row, model) { + super._generateCellPartNames(row, model); + + iterateRowCells(row, (cell) => { + const isEditable = this._isCellEditable(cell); + const target = cell._focusButton || cell; + updatePart(target, isEditable, 'editable-cell'); + }); + } + + /** @private */ + _isCellEditable(cell) { + const column = cell._column; + // Not editable if the column is not an edit column + if (!this._isEditColumn(column)) { + return false; + } + // Cell is editable by default if isCellEditable is not configured + if (!column.isCellEditable) { + return true; + } + // Otherwise, check isCellEditable function + const model = this.__getRowModel(cell.parentElement); + return column.isCellEditable(model); + } + /** * Fired before exiting the cell edit mode, if the value has been changed. * If the default is prevented, value change would not be applied. diff --git a/packages/grid-pro/test/edit-column.common.js b/packages/grid-pro/test/edit-column.common.js index 8567123a97..a9c4fbb5a6 100644 --- a/packages/grid-pro/test/edit-column.common.js +++ b/packages/grid-pro/test/edit-column.common.js @@ -1,5 +1,5 @@ import { expect } from '@esm-bundle/chai'; -import { enter, fixtureSync, focusin, focusout, isIOS, nextFrame, tab } from '@vaadin/testing-helpers'; +import { enter, esc, fixtureSync, focusin, focusout, isIOS, nextFrame, tab } from '@vaadin/testing-helpers'; import sinon from 'sinon'; import { createItems, @@ -254,6 +254,263 @@ describe('edit column', () => { }); }); + describe('disable editing for individual cells', () => { + let grid, amountColumn; + + function isCellEditable(row, col) { + const cell = getContainerCell(grid.$.items, row, col); + enter(cell); + const isEditable = !!getCellEditor(cell); + esc(cell); + return isEditable; + } + + function hasEditablePart(row, col) { + const cell = getContainerCell(grid.$.items, row, col); + const target = cell._focusButton || cell; + return !!target.getAttribute('part')?.includes('editable-cell'); + } + + function triggersNavigatingState(row, col) { + const cell = getContainerCell(grid.$.items, row, col); + // Mimic the real events sequence to avoid using fake focus shim from grid + cell.dispatchEvent(new CustomEvent('mousedown', { bubbles: true, composed: true })); + return grid.hasAttribute('navigating'); + } + + beforeEach(async () => { + grid = fixtureSync(` + + + + + + + `); + grid.items = [ + { id: 1, status: 'draft', amount: 100, notes: 'foo' }, + { id: 2, status: 'completed', amount: 200, notes: 'bar' }, + ]; + // Disable editing for the amount when status is completed + amountColumn = grid.querySelector('[path="amount"]'); + amountColumn.isCellEditable = (model) => model.item.status !== 'completed'; + flushGrid(grid); + await nextFrame(); + }); + + it('should not show editor when cell is not editable', () => { + // Cells in first row are editable + expect(isCellEditable(0, 1)).to.be.true; + expect(isCellEditable(0, 2)).to.be.true; + expect(isCellEditable(0, 3)).to.be.true; + // Amount cell in second row is not editable + expect(isCellEditable(1, 1)).to.be.true; + expect(isCellEditable(1, 2)).to.be.false; + expect(isCellEditable(1, 3)).to.be.true; + }); + + it('should show editor when cell becomes editable after updating provider function', () => { + // Not editable initially + expect(isCellEditable(1, 2)).to.be.false; + // Enable editing + amountColumn.isCellEditable = () => true; + expect(isCellEditable(1, 2)).to.be.true; + }); + + it('should show editor when cell becomes editable after removing provider function', () => { + // Not editable initially + expect(isCellEditable(1, 2)).to.be.false; + // Remove provider + amountColumn.isCellEditable = null; + expect(isCellEditable(1, 2)).to.be.true; + }); + + it('should not add editable-cell part to non-editable cells', () => { + expect(hasEditablePart(0, 0)).to.be.false; + expect(hasEditablePart(0, 1)).to.be.true; + expect(hasEditablePart(0, 2)).to.be.true; + expect(hasEditablePart(0, 3)).to.be.true; + expect(hasEditablePart(1, 0)).to.be.false; + expect(hasEditablePart(1, 1)).to.be.true; + expect(hasEditablePart(1, 2)).to.be.false; + expect(hasEditablePart(1, 3)).to.be.true; + }); + + it('should update part name when cell becomes editable after updating provider function', async () => { + // Not editable initially + expect(hasEditablePart(1, 2)).to.be.false; + // Enable editing + amountColumn.isCellEditable = () => true; + await nextFrame(); + expect(hasEditablePart(1, 2)).to.be.true; + }); + + it('should not be in navigating state when clicking non-editable cells', () => { + expect(triggersNavigatingState(0, 1)).to.be.true; + expect(triggersNavigatingState(0, 2)).to.be.true; + expect(triggersNavigatingState(1, 1)).to.be.true; + expect(triggersNavigatingState(1, 2)).to.be.false; + }); + + it('should be in navigating state when cell becomes editable after updating provider function', () => { + // Not editable initially + expect(triggersNavigatingState(1, 2)).to.be.false; + // Enable editing + amountColumn.isCellEditable = () => true; + expect(triggersNavigatingState(1, 2)).to.be.true; + }); + + describe('editor navigation', () => { + beforeEach(async () => { + // Five rows, only second and forth are editable + grid.items = [ + { id: 1, status: 'completed', amount: 100, notes: 'foo' }, + { id: 2, status: 'draft', amount: 200, notes: 'bar' }, + { id: 3, status: 'completed', amount: 100, notes: 'foo' }, + { id: 4, status: 'draft', amount: 100, notes: 'foo' }, + { id: 5, status: 'completed', amount: 200, notes: 'bar' }, + ]; + grid.querySelectorAll('vaadin-grid-pro-edit-column').forEach((column) => { + column.isCellEditable = (model) => model.item.status !== 'completed'; + }); + flushGrid(grid); + await nextFrame(); + }); + + describe('with Tab', () => { + it('should skip non-editable cells when navigating with Tab', () => { + let cell = getContainerCell(grid.$.items, 1, 2); + enter(cell); + expect(getCellEditor(cell)).to.be.ok; + + tab(cell); + cell = getContainerCell(grid.$.items, 1, 3); + expect(getCellEditor(cell)).to.be.ok; + + // Should skip non-editable row + tab(cell); + cell = getContainerCell(grid.$.items, 3, 1); + expect(getCellEditor(cell)).to.be.ok; + + tab(cell); + cell = getContainerCell(grid.$.items, 3, 2); + expect(getCellEditor(cell)).to.be.ok; + }); + + it('should skip non-editable cells when navigating with Shift-Tab', () => { + let cell = getContainerCell(grid.$.items, 3, 2); + enter(cell); + expect(getCellEditor(cell)).to.be.ok; + + tab(cell, ['shift']); + cell = getContainerCell(grid.$.items, 3, 1); + expect(getCellEditor(cell)).to.be.ok; + + // Should skip non-editable rows + tab(cell, ['shift']); + cell = getContainerCell(grid.$.items, 1, 3); + expect(getCellEditor(cell)).to.be.ok; + + tab(cell, ['shift']); + cell = getContainerCell(grid.$.items, 1, 2); + expect(getCellEditor(cell)).to.be.ok; + }); + + it('should skip cells that become non-editable after editing current cell', () => { + // Edit status in row 2 to be completed, so none of the cells in this + // row should be editable anymore + let cell = getContainerCell(grid.$.items, 1, 1); + enter(cell); + const input = getCellEditor(cell); + input.value = 'completed'; + tab(cell); + + // Should skip to row 4 + cell = getContainerCell(grid.$.items, 3, 1); + expect(getCellEditor(cell)).to.be.ok; + }); + + it('should stop editing and focus last edited cell if there are no more editable cells with Tab', () => { + const cell = getContainerCell(grid.$.items, 3, 3); + enter(cell); + expect(getCellEditor(cell)).to.be.ok; + expect(grid.querySelector('vaadin-grid-pro-edit-text-field')).to.be.ok; + + tab(cell); + expect(grid.querySelector('vaadin-grid-pro-edit-text-field')).to.not.be.ok; + const target = cell._focusButton || cell; + expect(grid.shadowRoot.activeElement).to.equal(target); + expect(grid.hasAttribute('navigating')).to.be.true; + }); + + it('should stop editing and focus last edited cell if there are no more editable cells with Shift-Tab', () => { + const cell = getContainerCell(grid.$.items, 1, 1); + enter(cell); + expect(getCellEditor(cell)).to.be.ok; + expect(grid.querySelector('vaadin-grid-pro-edit-text-field')).to.be.ok; + + tab(cell, ['shift']); + expect(grid.querySelector('vaadin-grid-pro-edit-text-field')).to.not.be.ok; + const target = cell._focusButton || cell; + expect(grid.shadowRoot.activeElement).to.equal(target); + expect(grid.hasAttribute('navigating')).to.be.true; + }); + }); + + describe('with Enter', () => { + beforeEach(() => { + grid.enterNextRow = true; + }); + + it('should skip non-editable cells when navigating with Enter', () => { + let cell = getContainerCell(grid.$.items, 1, 1); + enter(cell); + expect(getCellEditor(cell)).to.be.ok; + + enter(cell); + cell = getContainerCell(grid.$.items, 3, 1); + expect(getCellEditor(cell)).to.be.ok; + }); + + it('should skip non-editable cells when navigating with Shift-Enter', () => { + let cell = getContainerCell(grid.$.items, 3, 1); + enter(cell); + expect(getCellEditor(cell)).to.be.ok; + + enter(cell, ['shift']); + cell = getContainerCell(grid.$.items, 1, 1); + expect(getCellEditor(cell)).to.be.ok; + }); + + it('should stop editing and focus last edited cell if there are no more editable cells with Enter', () => { + const cell = getContainerCell(grid.$.items, 3, 1); + enter(cell); + expect(getCellEditor(cell)).to.be.ok; + expect(grid.querySelector('vaadin-grid-pro-edit-text-field')).to.be.ok; + + enter(cell); + expect(grid.querySelector('vaadin-grid-pro-edit-text-field')).to.not.be.ok; + const target = cell._focusButton || cell; + expect(grid.shadowRoot.activeElement).to.equal(target); + expect(grid.hasAttribute('navigating')).to.be.true; + }); + + it('should stop editing and focus last edited cell if there are no more editable cells with Shift-Enter', () => { + const cell = getContainerCell(grid.$.items, 1, 1); + enter(cell); + expect(getCellEditor(cell)).to.be.ok; + expect(grid.querySelector('vaadin-grid-pro-edit-text-field')).to.be.ok; + + enter(cell, ['shift']); + expect(grid.querySelector('vaadin-grid-pro-edit-text-field')).to.not.be.ok; + const target = cell._focusButton || cell; + expect(grid.shadowRoot.activeElement).to.equal(target); + expect(grid.hasAttribute('navigating')).to.be.true; + }); + }); + }); + }); + describe('vertical scrolling', () => { let grid, input, firstCell;