diff --git a/CHANGELOG.md b/CHANGELOG.md index 546d988badd..70b8a42a99f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,13 @@ - Introducing Actions: https://docs.hasura.io/1.0/graphql/manual/actions/index.html - Downgrade command: https://hasura.io/docs/1.0/graphql/manual/deployment/downgrading.html#downgrading-hasura-graphql-engine +- console: add multi select to data table and bulk delete (#3735) + + Added a checkbox to each row on Browse Rows view that allows selecting one or more rows from the table and bulk delete them. + - console: allow setting check constraints during table create (#3881) - There was added a component that allows adding check constraints while creating a new table in the same way as it can be done on the `Modify` view. + Added a component that allows adding check constraints while creating a new table in the same way as it can be done on the `Modify` view. ### Other changes diff --git a/console/cypress/integration/data/insert-browse/spec.js b/console/cypress/integration/data/insert-browse/spec.js index 394bde1d7ab..6f7f3f172c5 100644 --- a/console/cypress/integration/data/insert-browse/spec.js +++ b/console/cypress/integration/data/insert-browse/spec.js @@ -74,6 +74,7 @@ const checkOrder = order => { .find('[role=gridcell]') .first() .next() + .next() .contains(index); } }); @@ -84,6 +85,7 @@ const checkOrder = order => { .find('[role=gridcell]') .first() .next() + .next() .contains(22 - index); } }); @@ -231,7 +233,7 @@ export const checkPagination = () => { cy.wait(3000); // Check if the page changed cy.get( - '.rt-tbody > div:nth-child(1) > div > div:nth-child(2) > div' + '.rt-tbody > div:nth-child(1) > div > div:nth-child(3) > div' ).contains('11'); cy.get('.-pageJump > input').should('have.value', '2'); cy.get('.-previous > button').click(); diff --git a/console/cypress/integration/data/views/spec.js b/console/cypress/integration/data/views/spec.js index d75e6e95136..7dc572c1fd2 100644 --- a/console/cypress/integration/data/views/spec.js +++ b/console/cypress/integration/data/views/spec.js @@ -318,6 +318,7 @@ const checkOrder = order => { .find('[role=gridcell]') .first() .next() + .next() .contains(2); } if (index === 2) { @@ -325,6 +326,7 @@ const checkOrder = order => { .find('[role=gridcell]') .first() .next() + .next() .contains(userId); } }); @@ -335,6 +337,7 @@ const checkOrder = order => { .find('[role=gridcell]') .first() .next() + .next() .contains(2); } if (index === 1) { @@ -342,6 +345,7 @@ const checkOrder = order => { .find('[role=gridcell]') .first() .next() + .next() .contains(userId); } }); diff --git a/console/src/components/Common/Common.scss b/console/src/components/Common/Common.scss index 529a3cfb774..1be862d60d6 100644 --- a/console/src/components/Common/Common.scss +++ b/console/src/components/Common/Common.scss @@ -522,6 +522,10 @@ input { padding-left: 15px; } +.add_padd_left_18 { + padding-left: 18px; +} + .add_mar_top_small { margin-top: 10px; } diff --git a/console/src/components/Common/TableCommon/Table.scss b/console/src/components/Common/TableCommon/Table.scss index bbc91ec12b4..ed11f7d9250 100644 --- a/console/src/components/Common/TableCommon/Table.scss +++ b/console/src/components/Common/TableCommon/Table.scss @@ -1,4 +1,4 @@ -@import "../Common.scss"; +@import '../Common.scss'; .container { padding: 0; @@ -50,7 +50,7 @@ label.radioLabel { padding-top: 0; - input[type="radio"] { + input[type='radio'] { margin-top: 10px; } } @@ -64,7 +64,7 @@ width: auto; tr:nth-child(even) { - background: #f4f4f4 + background: #f4f4f4; } th { @@ -183,3 +183,21 @@ a.expanded { .tableCellExpanded { white-space: pre-wrap; } + +.tableCenterContent { + display: flex; + justify-content: center; + align-items: center; + height: 100%; +} + +.bulkDeleteButton { + margin: 10px; + font-size: 16px; + padding: 0 6px; +} + +.headerInputCheckbox { + height: 16px; + margin-bottom: 2px; +} diff --git a/console/src/components/Common/TableCommon/TableStyles.scss b/console/src/components/Common/TableCommon/TableStyles.scss index b273900a92b..36fedec89ab 100644 --- a/console/src/components/Common/TableCommon/TableStyles.scss +++ b/console/src/components/Common/TableCommon/TableStyles.scss @@ -35,7 +35,7 @@ label.radioLabel { padding-top: 0; - input[type="radio"] { + input[type='radio'] { margin-top: 10px; } } @@ -104,7 +104,7 @@ a.expanded { i:hover { cursor: pointer; - color: #B85C27; + color: #b85c27; transition: 0.2s; } } @@ -127,5 +127,5 @@ a.expanded { .relEditButtons { display: flex; - flex-direction:row; + flex-direction: row; } diff --git a/console/src/components/Common/utils/v1QueryUtils.js b/console/src/components/Common/utils/v1QueryUtils.js index 7af8ab18578..e2c62af34f0 100644 --- a/console/src/components/Common/utils/v1QueryUtils.js +++ b/console/src/components/Common/utils/v1QueryUtils.js @@ -209,3 +209,19 @@ export const getDropComputedFieldQuery = (tableDef, computedFieldName) => { }, }; }; + +export const getDeleteQuery = (pkClause, tableName, schemaName) => { + return { + type: 'delete', + args: { + table: { + name: tableName, + schema: schemaName, + }, + where: pkClause, + }, + }; +}; + +export const getBulkDeleteQuery = (pkClauses, tableName, schemaName) => + pkClauses.map(pkClause => getDeleteQuery(pkClause, tableName, schemaName)); diff --git a/console/src/components/Services/Data/TableBrowseRows/ViewActions.js b/console/src/components/Services/Data/TableBrowseRows/ViewActions.js index 692711bb726..0b968f1f369 100644 --- a/console/src/components/Services/Data/TableBrowseRows/ViewActions.js +++ b/console/src/components/Services/Data/TableBrowseRows/ViewActions.js @@ -9,6 +9,7 @@ import { } from '../../Common/Notification'; import dataHeaders from '../Common/Headers'; import { getConfirmation } from '../../../Common/utils/jsUtils'; +import { getBulkDeleteQuery } from '../../../Common/utils/v1QueryUtils'; /* ****************** View actions *************/ const V_SET_DEFAULTS = 'ViewTable/V_SET_DEFAULTS'; @@ -210,6 +211,47 @@ const deleteItem = pkClause => { }; }; +const deleteItems = pkClauses => { + return (dispatch, getState) => { + const confirmMessage = 'This will permanently delete rows from this table'; + const isOk = getConfirmation(confirmMessage); + if (!isOk) { + return; + } + + const state = getState(); + + const reqBody = { + type: 'bulk', + args: getBulkDeleteQuery( + pkClauses, + state.tables.currentTable, + state.tables.currentSchema + ), + }; + const options = { + method: 'POST', + body: JSON.stringify(reqBody), + headers: dataHeaders(getState), + credentials: globalCookiePolicy, + }; + dispatch(requestAction(Endpoints.query, options)).then( + data => { + const affected = data.reduce((acc, d) => acc + d.affected_rows, 0); + dispatch(vMakeRequest()); + dispatch( + showSuccessNotification('Rows deleted!', 'Affected rows: ' + affected) + ); + }, + err => { + dispatch( + showErrorNotification('Deleting rows failed!', err.error, err) + ); + } + ); + }; +}; + const vExpandRel = (path, relname, pk) => { return dispatch => { // Modify the query (UI will automatically change) @@ -560,6 +602,7 @@ export { vCollapseRow, V_SET_ACTIVE, deleteItem, + deleteItems, UPDATE_TRIGGER_ROW, UPDATE_TRIGGER_FUNCTION, }; diff --git a/console/src/components/Services/Data/TableBrowseRows/ViewRows.js b/console/src/components/Services/Data/TableBrowseRows/ViewRows.js index ff372ff67cd..e23a439e66b 100644 --- a/console/src/components/Services/Data/TableBrowseRows/ViewRows.js +++ b/console/src/components/Services/Data/TableBrowseRows/ViewRows.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import 'react-table/react-table.css'; import '../../../Common/TableCommon/ReactTableOverrides.css'; import DragFoldTable from '../../../Common/TableCommon/DragFoldTable'; @@ -11,6 +11,7 @@ import { vExpandRel, vCloseRel, V_SET_ACTIVE, + deleteItems, deleteItem, vExpandRow, vCollapseRow, @@ -73,8 +74,12 @@ const ViewRows = ({ location, readOnlyMode, }) => { + const [selectedRows, setSelectedRows] = useState([]); + const styles = require('../../../Common/TableCommon/Table.scss'); + const NO_PRIMARY_KEY_MSG = 'No primary key to identify row'; + // Invoke manual trigger status const invokeTrigger = (trigger, row) => { updateInvocationRow(row); @@ -86,6 +91,14 @@ const ViewRows = ({ updateInvocationFunction(null); }; + const handleAllCheckboxChange = e => { + if (e.target.checked) { + setSelectedRows(curRows); + } else { + setSelectedRows([]); + } + }; + const checkIfSingleRow = _curRelName => { let _isSingleRow = false; @@ -116,7 +129,7 @@ const ViewRows = ({ ); }; - const getGridHeadings = (_columns, _relationships) => { + const getGridHeadings = (_columns, _relationships, _disableBulkSelect) => { const _gridHeadings = []; const getColWidth = (header, contentRows = []) => { @@ -184,6 +197,26 @@ const ViewRows = ({ width: 152, }); + _gridHeadings.push({ + Header: ( +
+ 0 && selectedRows.length === curRows.length + } + disabled={_disableBulkSelect} + title={_disableBulkSelect ? 'No primary key to identify row' : ''} + type="checkbox" + onChange={handleAllCheckboxChange} + /> +
+ ), + accessor: 'tableRowSelectAction', + id: 'tableRowSelectAction', + width: 60, + }); + _columns.map(col => { const columnName = col.column_name; @@ -232,7 +265,56 @@ const ViewRows = ({ return _gridHeadings; }; - const getGridRows = (_tableSchema, _hasPrimaryKey, _isSingleRow) => { + const compareRows = (row1, row2, _tableSchema, _hasPrimaryKey) => { + let same = true; + if (!isView && _hasPrimaryKey) { + _tableSchema.primary_key.columns.map(pk => { + if (row1[pk] !== row2[pk]) { + same = false; + } + }); + return same; + } + _tableSchema.columns.map(k => { + if (row1[k.column_name] !== row2[k.column_name]) { + return false; + } + }); + return same; + }; + + const handleCheckboxChange = (row, e, ...rest) => { + if (e.target.checked) { + setSelectedRows(prev => [...prev, row]); + } else { + setSelectedRows(prev => + prev.filter(prevRow => !compareRows(prevRow, row, ...rest)) + ); + } + }; + + const getPKClause = (row, hasPrimaryKey, tableSchema) => { + const pkClause = {}; + + if (!isView && hasPrimaryKey) { + tableSchema.primary_key.columns.map(pk => { + pkClause[pk] = row[pk]; + }); + } else { + tableSchema.columns.map(k => { + pkClause[k.column_name] = row[k.column_name]; + }); + } + + return pkClause; + }; + + const getGridRows = ( + _tableSchema, + _hasPrimaryKey, + _isSingleRow, + _disableBulkSelect + ) => { const _gridRows = []; curRows.forEach((row, rowIndex) => { @@ -241,22 +323,6 @@ const ViewRows = ({ const rowCellIndex = `${curTableName}-${rowIndex}`; const isExpanded = expandedRow === rowCellIndex; - const getPKClause = () => { - const pkClause = {}; - - if (!isView && _hasPrimaryKey) { - _tableSchema.primary_key.columns.map(pk => { - pkClause[pk] = row[pk]; - }); - } else { - _tableSchema.columns.map(k => { - pkClause[k.column_name] = row[k.column_name]; - }); - } - - return pkClause; - }; - const getActionButtons = () => { let editButton; let cloneButton; @@ -441,7 +507,7 @@ const ViewRows = ({ const showActionBtns = !readOnlyMode && !_isSingleRow && !isView; if (showActionBtns) { - const pkClause = getPKClause(); + const pkClause = getPKClause(row, _hasPrimaryKey, _tableSchema); editButton = getEditButton(pkClause); deleteButton = getDeleteButton(pkClause); @@ -467,6 +533,24 @@ const ViewRows = ({ // Insert Edit, Delete, Clone in a cell newRow.tableRowActionButtons = getActionButtons(); + // Check for bulk actions + newRow.tableRowSelectAction = ( +
+ + compareRows(selectedRow, row, _tableSchema, _hasPrimaryKey) + )} + onChange={e => + handleCheckboxChange(row, e, _tableSchema, _hasPrimaryKey) + } + /> +
+ ); + // Insert column cells _tableSchema.columns.forEach(col => { const columnName = col.column_name; @@ -556,7 +640,7 @@ const ViewRows = ({ cellValue = NULL; } else { // can be expanded - const pkClause = getPKClause(); + const pkClause = getPKClause(row, _hasPrimaryKey, _tableSchema); const handleViewClick = e => { e.preventDefault(); @@ -604,9 +688,20 @@ const ViewRows = ({ const isSingleRow = checkIfSingleRow(curRelName); - const _gridHeadings = getGridHeadings(tableColumnsSorted, tableRelationships); + const disableBulkSelect = !hasPrimaryKey; - const _gridRows = getGridRows(tableSchema, hasPrimaryKey, isSingleRow); + const _gridHeadings = getGridHeadings( + tableColumnsSorted, + tableRelationships, + disableBulkSelect + ); + + const _gridRows = getGridRows( + tableSchema, + hasPrimaryKey, + isSingleRow, + disableBulkSelect + ); const getFilterQuery = () => { let _filterQuery = null; @@ -647,6 +742,36 @@ const ViewRows = ({ return _filterQuery; }; + const getSelectedRowsSection = () => { + const handleDeleteItems = () => { + const pkClauses = selectedRows.map(row => + getPKClause(row, hasPrimaryKey, tableSchema) + ); + dispatch(deleteItems(pkClauses)); + setSelectedRows([]); + }; + + let selectedRowsSection = null; + + if (selectedRows.length > 0) { + selectedRowsSection = ( +
+ Selected: + {selectedRows.length} + +
+ ); + } + + return selectedRowsSection; + }; + // If query object has expanded columns const getChildComponent = () => { let _childComponent = null; @@ -871,6 +996,7 @@ const ViewRows = ({
{getFilterQuery()}
+ {getSelectedRowsSection()}
{renderTableBody()}