From eb1b3df7c50872eca2002fa324f3029b70db9a3f Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Tue, 12 Aug 2025 16:34:08 +0530 Subject: [PATCH] BULK_ACTIONS: fix the keyboard delete action in bulk actions (#22841) * fix the keyboard delete action in bulk actions * reverted the file change * added support for column header click select all column delete handle as well --- .../e2e/Features/BulkImport.spec.ts | 222 ++++++++++++++++++ .../ui/playwright/utils/importUtils.ts | 90 ++++++- .../ui/src/hooks/useGridEditController.ts | 141 ++++++++++- 3 files changed, 450 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts index e8d629c2182..a1289697d2f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts @@ -38,6 +38,8 @@ import { fillRowDetails, fillStoredProcedureCode, firstTimeGridAddRowAction, + performColumnSelectAndDeleteOperation, + performDeleteOperationOnEntity, pressKeyXTimes, validateImportStatus, } from '../../utils/importUtils'; @@ -878,6 +880,226 @@ test.describe('Bulk Import Export', () => { await afterAction(); }); + test('Keyboard Delete selection', async ({ page }) => { + test.slow(true); + + const dbEntity = new DatabaseClass(); + + const { apiContext, afterAction } = await getApiContext(page); + await dbEntity.create(apiContext); + + await test.step('should export data database schema details', async () => { + await dbEntity.visitEntityPage(page); + + const downloadPromise = page.waitForEvent('download'); + await page.click('[data-testid="manage-button"]'); + await page.click('[data-testid="export-button-title"]'); + await page.fill('#fileName', dbEntity.entity.name); + await page.click('#submit-button'); + + const download = await downloadPromise; + + // Wait for the download process to complete and save the downloaded file somewhere. + await download.saveAs('downloads/' + download.suggestedFilename()); + }); + + await test.step( + 'should import and perform edit operation on entity', + async () => { + await dbEntity.visitEntityPage(page); + + await page.click('[data-testid="manage-button"] > .anticon'); + await page.click('[data-testid="import-button-title"]'); + const fileInput = await page.$('[type="file"]'); + await fileInput?.setInputFiles([ + 'downloads/' + dbEntity.entity.name + '.csv', + ]); + + // Adding manual wait for the file to load + await page.waitForTimeout(500); + + // Adding some assertion to make sure that CSV loaded correctly + await expect(page.locator('.rdg-header-row')).toBeVisible(); + await expect(page.getByTestId('add-row-btn')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Next' })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Previous' }) + ).toBeVisible(); + + // Click on first cell and edit + await page.click('.rdg-cell[role="gridcell"]'); + await fillRowDetails( + { + ...databaseDetails1, + owners: [ + EntityDataClass.user1.responseData?.['displayName'], + EntityDataClass.user2.responseData?.['displayName'], + ], + domains: EntityDataClass.domain1.responseData, + }, + page, + undefined, + true + ); + + await fillRecursiveEntityTypeFQNDetails( + `${dbEntity.entityResponseData.fullyQualifiedName}.${databaseSchemaDetails1.name}`, + databaseSchemaDetails1.entityType, + page + ); + + await page.getByRole('button', { name: 'Next' }).click(); + + await validateImportStatus(page, { + passed: '9', + processed: '9', + failed: '0', + }); + + const rowStatus = [ + 'Entity created', + 'Entity updated', + 'Entity updated', + 'Entity updated', + 'Entity updated', + 'Entity updated', + 'Entity updated', + 'Entity updated', + ]; + + await expect(page.locator('.rdg-cell-details')).toHaveText(rowStatus); + + const updateButtonResponse = page.waitForResponse( + `/api/v1/databases/name/*/importAsync?*dryRun=false&recursive=true*` + ); + + await page.getByRole('button', { name: 'Update' }).click(); + await page + .locator('.inovua-react-toolkit-load-mask__background-layer') + .waitFor({ state: 'detached' }); + + await updateButtonResponse; + await page.waitForEvent('framenavigated'); + await toastNotification(page, /details updated successfully/); + } + ); + + await test.step( + 'should export data database schema details after edit changes', + async () => { + await dbEntity.visitEntityPage(page); + + const downloadPromise = page.waitForEvent('download'); + await page.click('[data-testid="manage-button"]'); + await page.click('[data-testid="export-button-title"]'); + await page.fill('#fileName', `${dbEntity.entity.name}-delete`); + await page.click('#submit-button'); + + const download = await downloadPromise; + + // Wait for the download process to complete and save the downloaded file somewhere. + await download.saveAs('downloads/' + download.suggestedFilename()); + } + ); + + await test.step('Perform Column Select and Delete Operation', async () => { + await page.click('[data-testid="manage-button"] > .anticon'); + await page.click('[data-testid="import-button-title"]'); + const fileInput = await page.$('[type="file"]'); + await fileInput?.setInputFiles([ + 'downloads/' + `${dbEntity.entity.name}-delete` + '.csv', + ]); + + // Adding manual wait for the file to load + await page.waitForTimeout(500); + + // Adding some assertion to make sure that CSV loaded correctly + await expect(page.locator('.rdg-header-row')).toBeVisible(); + await expect(page.getByTestId('add-row-btn')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Next' })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Previous' }) + ).toBeVisible(); + + // Perform Delete Operation on Edit Operation on Entity + await performColumnSelectAndDeleteOperation(page); + }); + + await test.step('Perform Cell Delete Operation and Save', async () => { + await page.locator('.rdg-cell-name').first().click(); + + // Perform Delete Operation on Edit Operation on Entity + await performDeleteOperationOnEntity(page); + + await page.getByRole('button', { name: 'Next' }).click(); + + await validateImportStatus(page, { + passed: '10', + processed: '10', + failed: '0', + }); + + const rowStatus = [ + 'Entity updated', + 'Entity updated', + 'Entity updated', + 'Entity updated', + 'Entity updated', + 'Entity updated', + 'Entity updated', + 'Entity updated', + 'Entity updated', + ]; + + await expect(page.locator('.rdg-cell-details')).toHaveText(rowStatus); + + const updateButtonResponse = page.waitForResponse( + `/api/v1/databases/name/*/importAsync?*dryRun=false&recursive=true*` + ); + + await page.getByRole('button', { name: 'Update' }).click(); + await page + .locator('.inovua-react-toolkit-load-mask__background-layer') + .waitFor({ state: 'detached' }); + + await updateButtonResponse; + await page.waitForEvent('framenavigated'); + await toastNotification(page, /details updated successfully/); + }); + + await test.step('should verify the removed value from entity', async () => { + await page.getByTestId('column-name').first().click(); + await page.waitForLoadState('networkidle'); + + await expect( + page + .getByTestId('asset-description-container') + .getByText('No description') + ).toBeVisible(); + + await expect( + page.getByTestId('tags-container').getByTestId('add-tag') + ).toBeVisible(); + + await expect( + page.getByTestId('glossary-container').getByTestId('add-tag') + ).toBeVisible(); + + await expect(page.getByTestId('Tier')).toContainText('No Tier'); + + await expect(page.getByTestId('certification-label')).toContainText( + 'No Certification' + ); + + await expect(page.getByTestId('owner-label')).toContainText('No Owners'); + + await expect(page.getByTestId('no-domain-text')).toBeVisible(); + }); + + await dbEntity.delete(apiContext); + await afterAction(); + }); + // Skip this test for now, since it is not working in AUT but working in local and CI // test.skip('Range selection', async ({ page }) => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts index e6d72ff56d0..859f1865cf7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts @@ -580,9 +580,12 @@ export const fillRowDetails = async ( }; }, page: Page, - customPropertyRecord?: Record + customPropertyRecord?: Record, + isFirstCellClick?: boolean ) => { - await page.locator('.rdg-cell-name').last().click(); + if (!isFirstCellClick) { + await page.locator('.rdg-cell-name').last().click(); + } const activeCell = page.locator(RDG_ACTIVE_CELL_SELECTOR); const isActive = await activeCell.isVisible(); @@ -960,3 +963,86 @@ export const firstTimeGridAddRowAction = async (page: Page) => { await expect(lastRowFirstCell).toBeFocused(); }; + +export const performDeleteOperationOnEntity = async (page: Page) => { + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('ArrowRight'); + + // Description Remove + await page + .locator(RDG_ACTIVE_CELL_SELECTOR) + .press('ArrowRight', { delay: 100 }); + + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('Backspace'); + + // Owner Remove + await page + .locator(RDG_ACTIVE_CELL_SELECTOR) + .press('ArrowRight', { delay: 100 }); + + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('Backspace'); + + // Tag Remove + await page + .locator(RDG_ACTIVE_CELL_SELECTOR) + .press('ArrowRight', { delay: 100 }); + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('Backspace'); + + // Glossary Remove + await page + .locator(RDG_ACTIVE_CELL_SELECTOR) + .press('ArrowRight', { delay: 100 }); + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('Backspace'); + + // Tier Remove + await page + .locator(RDG_ACTIVE_CELL_SELECTOR) + .press('ArrowRight', { delay: 100 }); + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('Delete'); + + // Certification Remove + await page + .locator(RDG_ACTIVE_CELL_SELECTOR) + .press('ArrowRight', { delay: 100 }); + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('Delete'); + + // Retention Period Remove + await page + .locator(RDG_ACTIVE_CELL_SELECTOR) + .press('ArrowRight', { delay: 100 }); + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('Delete'); + + // Source URL Remove + await page + .locator(RDG_ACTIVE_CELL_SELECTOR) + .press('ArrowRight', { delay: 100 }); + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('Delete'); + + // Domains Remove + await page + .locator(RDG_ACTIVE_CELL_SELECTOR) + .press('ArrowRight', { delay: 100 }); + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('Delete'); +}; + +export const performColumnSelectAndDeleteOperation = async (page: Page) => { + const displayNameHeader = page.getByRole('columnheader', { + name: 'Display Name', + }); + + const firstRow = page.locator('.rdg-row').first(); + const firstCell = firstRow.locator('.rdg-cell').nth(1); + + await displayNameHeader.click(); + + await expect(firstCell).not.toBeFocused(); + + await expect(displayNameHeader).toBeFocused(); + + await expect(page.locator('.rdg-cell-range-selections')).toHaveCount(9); + + await page.locator(RDG_ACTIVE_CELL_SELECTOR).press('Delete'); + + await expect( + page.getByRole('gridcell', { name: 'Playwright,Database', exact: true }) + ).not.toBeVisible(); // Display Name cell should be deleted +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts index 847efe9e2b9..e0ee261630c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts @@ -482,6 +482,136 @@ export function useGridEditController({ return; } + // Handle Delete and Backspace keys to clear cell content + if (e.key === 'Delete' || e.key === 'Backspace') { + // Only handle if we're in a cell and not in a text input + const target = e.target as HTMLElement; + const isInInput = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.contentEditable === 'true'; + + // Check if we're in a custom editor (like tag selector, dropdown, etc.) + const isInCustomEditor = + target.closest('.async-select-list') || + target.closest('.react-grid-select-dropdown') || + target.closest('.ant-select') || + target.closest('.ant-select-dropdown'); + + if (!isInInput && !isInCustomEditor) { + e.preventDefault(); + e.stopPropagation(); + + // Check if we're in a column header (whole column selection) + const isInColumnHeader = + target.closest('.rdg-header-row') || + target.getAttribute('role') === 'columnheader'; + + // Check if we're in a row header (whole row selection) + const isInRowHeader = + target.closest('.rdg-row-header') || + target.getAttribute('role') === 'rowheader'; + + if (isInColumnHeader) { + // Handle whole column deletion + const colAttr = target.getAttribute('aria-colindex'); + if (colAttr) { + const colIndex = parseInt(colAttr, 10) - 1; + const column = columns[colIndex]; + + if (column && column.editable) { + // Clear all cells in the column + const newRows = [...dataSource]; + for (let row = 0; row < newRows.length; row++) { + newRows[row] = { ...newRows[row], [column.key]: '' }; + } + setDataSource(newRows); + pushToUndoStack(dataSource); + } + } + } else if (isInRowHeader) { + // Handle whole row deletion + const rowAttr = target.getAttribute('aria-rowindex'); + if (rowAttr) { + const rowIndex = parseInt(rowAttr, 10) - 2; // -2 for header row offset + + if (rowIndex >= 0 && rowIndex < dataSource.length) { + // Clear all cells in the row + const newRows = [...dataSource]; + const row = newRows[rowIndex]; + + // Clear all editable columns in the row + columns.forEach((column) => { + if (column.editable) { + row[column.key] = ''; + } + }); + + setDataSource(newRows); + pushToUndoStack(dataSource); + } + } + } else if (selectedRange) { + // Handle range selection deletion + const { startRow, endRow, startCol, endCol } = selectedRange; + const newRows = [...dataSource]; + + for (let row = startRow; row <= endRow; row++) { + if (row < newRows.length) { + for (let col = startCol; col <= endCol; col++) { + const column = columns[col]; + if (column && column.editable) { + newRows[row] = { ...newRows[row], [column.key]: '' }; + } + } + } + } + + setDataSource(newRows); + pushToUndoStack(dataSource); + } else { + // Handle single cell deletion + const cellIndices = getCellIndices(target); + if (cellIndices) { + const { row, col } = cellIndices; + const column = columns[col]; + + if (column && column.editable && row < dataSource.length) { + // Clear the cell content + const newRows = [...dataSource]; + newRows[row] = { ...newRows[row], [column.key]: '' }; + setDataSource(newRows); + pushToUndoStack(dataSource); + } + } + } + + return; + } + + // For custom editors, we need to handle the case where the event is stopped + // but we still want to clear the cell content + if (isInCustomEditor) { + // Get the cell that contains this custom editor + const cellElement = target.closest('.rdg-cell'); + if (cellElement) { + const cellIndices = getCellIndices(cellElement); + if (cellIndices) { + const { row, col } = cellIndices; + const column = columns[col]; + + if (column && column.editable && row < dataSource.length) { + // Clear the cell content for custom editors + const newRows = [...dataSource]; + newRows[row] = { ...newRows[row], [column.key]: '' }; + setDataSource(newRows); + pushToUndoStack(dataSource); + } + } + } + } + } + // Shift+Arrow for range selection (Excel-like) if ( selectedRange && @@ -568,7 +698,16 @@ export function useGridEditController({ ); } }; - }, [undo, redo, gridContainer, dataSource, selectedRange, columns]); + }, [ + undo, + redo, + gridContainer, + dataSource, + selectedRange, + columns, + setDataSource, + pushToUndoStack, + ]); useEffect(() => { if (!gridContainer) {