From a57ec7bd93f144b0462ff9c1e448964ac0c8e472 Mon Sep 17 00:00:00 2001 From: Anton Kulyk <kuliks.anton@gmail.com> Date: Fri, 22 Jul 2022 17:50:05 +0100 Subject: [PATCH] Add bulk update and bulk delete (#24139) * Add bulk action logic to data app context * Select List viz items with a checkbox * Enable Edit / Delete buttons when selecting items * Handle bulk delete (UI) * Stop checkbox click propagation * Show bulk selection controls on hover * Select items on row click when selection starts * Add overflow-x hidden for list item rows * Allow turning off bulk actions via viz setting * Add `primaryKeys` method to `Table` class * Add bulk delete endpoint * Implement bulk delete * Clean bulk selection after successful delete * Add bulk update endpoint * Add `type` and `mode` props to writeback form * Implement bulk update * Don't show inline actions in bulk selection mode --- .../src/metabase-lib/lib/metadata/Table.ts | 10 ++ .../core/components/CheckBox/CheckBox.tsx | 2 + .../metabase/dashboard/writeback-actions.ts | 138 ++++++++++++++++++ frontend/src/metabase/services.js | 2 + .../components/List/List.styled.tsx | 17 ++- .../visualizations/components/List/List.tsx | 86 ++++++++++- .../visualizations/visualizations/List.tsx | 6 + frontend/src/metabase/writeback/actions.ts | 32 ++++ .../components/ActionsViz/ActionsViz.tsx | 132 +++++++++++++++-- .../DataAppContext/DataAppContext.ts | 14 ++ .../DataAppContext/DataAppContextProvider.tsx | 50 ++++++- .../writeback/containers/WritebackForm.tsx | 29 +++- .../containers/WritebackModalForm.tsx | 15 +- 13 files changed, 498 insertions(+), 35 deletions(-) diff --git a/frontend/src/metabase-lib/lib/metadata/Table.ts b/frontend/src/metabase-lib/lib/metadata/Table.ts index e9711be1406..d606c2c1aa9 100644 --- a/frontend/src/metabase-lib/lib/metadata/Table.ts +++ b/frontend/src/metabase-lib/lib/metadata/Table.ts @@ -132,6 +132,16 @@ class TableInner extends Base { return fks.map(fk => new Table(fk.origin.table)); } + primaryKeys(): { field: Field; index: number }[] { + const pks = []; + this.fields.forEach((field, index) => { + if (field.isPK()) { + pks.push({ field, index }); + } + }); + return pks; + } + /** * @private * @param {string} description diff --git a/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx b/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx index f899499b6b5..ddf2381bb79 100644 --- a/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx +++ b/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx @@ -47,6 +47,7 @@ const CheckBox = forwardRef<HTMLLabelElement, CheckBoxProps>(function Checkbox( checkedColor = DEFAULT_CHECKED_COLOR, uncheckedColor = DEFAULT_UNCHECKED_COLOR, autoFocus, + onClick, onChange, onFocus, onBlur, @@ -72,6 +73,7 @@ const CheckBox = forwardRef<HTMLLabelElement, CheckBoxProps>(function Checkbox( size={size} disabled={disabled} autoFocus={autoFocus} + onClick={onClick} onChange={isControlledCheckBoxInput ? onChange : undefined} onFocus={onFocus} onBlur={onBlur} diff --git a/frontend/src/metabase/dashboard/writeback-actions.ts b/frontend/src/metabase/dashboard/writeback-actions.ts index 3860a441c1d..e29faa32471 100644 --- a/frontend/src/metabase/dashboard/writeback-actions.ts +++ b/frontend/src/metabase/dashboard/writeback-actions.ts @@ -7,14 +7,19 @@ import { createRow, updateRow, deleteRow, + updateManyRows, + deleteManyRows, InsertRowPayload, UpdateRowPayload, DeleteRowPayload, + BulkUpdatePayload, + BulkDeletePayload, } from "metabase/writeback/actions"; import { DashboardWithCards, DashCard } from "metabase-types/types/Dashboard"; import { fetchCardData } from "./actions"; +import { getCardData } from "./selectors"; import { isVirtualDashCard } from "./utils"; export type InsertRowFromDataAppPayload = InsertRowPayload & { @@ -92,6 +97,139 @@ export const deleteRowFromDataApp = (payload: DeleteRowFromDataAppPayload) => { }; }; +export type BulkUpdateFromDataAppPayload = Omit< + BulkUpdatePayload, + "records" +> & { + dashCard: DashCard; + rowIndexes: number[]; + changes: Record<string, unknown>; +}; + +export const updateManyRowsFromDataApp = ( + payload: BulkUpdateFromDataAppPayload, +) => { + return async (dispatch: any, getState: any) => { + function showErrorToast() { + dispatch( + addUndo({ + icon: "warning", + toastColor: "error", + message: t`Something went wrong while updating`, + }), + ); + } + + try { + const { dashCard, rowIndexes, changes, table } = payload; + const data = getCardData(getState())[dashCard.id][dashCard.card_id]; + const pks = table.primaryKeys(); + + const records: Record<string, unknown>[] = []; + + rowIndexes.forEach(rowIndex => { + const rowPKs: Record<string, unknown> = {}; + pks.forEach(pk => { + const name = pk.field.name; + const rawValue = data.data.rows[rowIndex][pk.index]; + const value = pk?.field.isNumeric() + ? parseInt(rawValue, 10) + : rawValue; + rowPKs[name] = value; + }); + records.push({ + ...changes, + ...rowPKs, + }); + }); + + const result = await updateManyRows({ records, table }); + if (result?.["rows-updated"] > 0) { + dispatch( + fetchCardData(dashCard.card, dashCard, { + reload: true, + ignoreCache: true, + }), + ); + dispatch( + addUndo({ + message: t`Successfully updated ${rowIndexes.length} records`, + toastColor: "success", + }), + ); + } else { + showErrorToast(); + } + } catch (err) { + console.error(err); + showErrorToast(); + } + }; +}; + +export type BulkDeleteFromDataAppPayload = Omit<BulkDeletePayload, "ids"> & { + dashCard: DashCard; + rowIndexes: number[]; +}; + +export const deleteManyRowsFromDataApp = ( + payload: BulkDeleteFromDataAppPayload, +) => { + return async (dispatch: any, getState: any) => { + function showErrorToast() { + dispatch( + addUndo({ + icon: "warning", + toastColor: "error", + message: t`Something went wrong while deleting`, + }), + ); + } + + try { + const { dashCard, rowIndexes, table } = payload; + const data = getCardData(getState())[dashCard.id][dashCard.card_id]; + const pks = table.primaryKeys(); + + const ids: Record<string, number | string>[] = []; + + rowIndexes.forEach(rowIndex => { + const rowPKs: Record<string, number | string> = {}; + pks.forEach(pk => { + const name = pk.field.name; + const rawValue = data.data.rows[rowIndex][pk.index]; + const value = pk?.field.isNumeric() + ? parseInt(rawValue, 10) + : rawValue; + rowPKs[name] = value; + }); + ids.push(rowPKs); + }); + + const result = await deleteManyRows({ ids, table }); + if (result?.["success"]) { + dispatch( + fetchCardData(dashCard.card, dashCard, { + reload: true, + ignoreCache: true, + }), + ); + dispatch( + addUndo({ + message: t`Successfully deleted ${rowIndexes.length} records`, + toastColor: "success", + }), + ); + } else { + showErrorToast(); + } + } catch (err) { + console.error(err); + showErrorToast(); + } + }; +}; + export type ExecuteRowActionPayload = { dashboard: DashboardWithCards; emitterId: number; diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index a4ff0bf2e97..55b686f1fc4 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -528,6 +528,8 @@ export const ActionsApi = { create: POST("/api/action/row/create"), update: POST("/api/action/row/update"), delete: POST("/api/action/row/delete"), + bulkUpdate: POST("/api/action/bulk/update/:tableId"), + bulkDelete: POST("/api/action/bulk/delete/:tableId"), }; export const EmittersApi = { diff --git a/frontend/src/metabase/visualizations/components/List/List.styled.tsx b/frontend/src/metabase/visualizations/components/List/List.styled.tsx index dd4092c0384..6eb17881b27 100644 --- a/frontend/src/metabase/visualizations/components/List/List.styled.tsx +++ b/frontend/src/metabase/visualizations/components/List/List.styled.tsx @@ -26,6 +26,17 @@ export const RowActionsContainer = styled.div` transition: all 0.1s ease-in-out; `; +export const BulkSelectionControlContainer = styled(RowActionsContainer)<{ + isSelectingItems?: boolean; +}>` + ${props => + props.isSelectingItems && + css` + width: 100% !important; + opacity: 1 !important; + `} +`; + export const RowActionButtonContainer = styled(CellRoot)` padding-left: 0.25rem; padding-right: 0.25rem; @@ -44,6 +55,8 @@ export const ListItemContainer = styled.div<{ disabled?: boolean }>` background-color: ${color("bg-white")}; + overflow-x: hidden; + transition: all 0.1s ease-in-out; ${props => @@ -55,13 +68,13 @@ export const ListItemContainer = styled.div<{ disabled?: boolean }>` } `} - ${RowActionsContainer} { + ${RowActionsContainer}, ${BulkSelectionControlContainer} { width: 0; opacity: 0; } &:hover { - ${RowActionsContainer} { + ${RowActionsContainer}, ${BulkSelectionControlContainer} { width: 100%; opacity: 1; } diff --git a/frontend/src/metabase/visualizations/components/List/List.tsx b/frontend/src/metabase/visualizations/components/List/List.tsx index 5c4ee78253f..f40f02c0a7c 100644 --- a/frontend/src/metabase/visualizations/components/List/List.tsx +++ b/frontend/src/metabase/visualizations/components/List/List.tsx @@ -11,6 +11,7 @@ import { t } from "ttag"; import { connect } from "react-redux"; import Button from "metabase/core/components/Button"; +import CheckBox from "metabase/core/components/CheckBox"; import ExplicitSize from "metabase/components/ExplicitSize"; import Modal from "metabase/components/Modal"; @@ -24,6 +25,8 @@ import { updateRowFromDataApp, } from "metabase/dashboard/writeback-actions"; +import { useDataAppContext } from "metabase/writeback/containers/DataAppContext"; + import Question from "metabase-lib/lib/Question"; import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; import Metadata from "metabase-lib/lib/metadata/Metadata"; @@ -42,6 +45,7 @@ import { Footer, ListItemContainer, ListItemContent, + BulkSelectionControlContainer, RowActionsContainer, RowActionButtonContainer, LIST_ITEM_VERTICAL_GAP, @@ -51,6 +55,10 @@ function getBoundingClientRectSafe(ref: React.RefObject<HTMLBaseElement>) { return ref.current?.getBoundingClientRect?.() ?? ({} as DOMRect); } +function stopClickPropagation(event: React.SyntheticEvent) { + event.stopPropagation(); +} + interface ListVizDispatchProps { updateRow: (payload: UpdateRowFromDataAppPayload) => void; deleteRow: (payload: DeleteRowFromDataAppPayload) => void; @@ -96,6 +104,8 @@ function List({ const footerRef = useRef(null); const firstRowRef = useRef(null); + const { bulkActions } = useDataAppContext(); + useLayoutEffect(() => { const { height: footerHeight = 0 } = getBoundingClientRectSafe(footerRef); const { height: rowHeight = 0 } = getBoundingClientRectSafe(firstRowRef); @@ -223,6 +233,47 @@ function List({ const hasEditButton = settings["buttons.edit"]; const hasDeleteButton = settings["buttons.delete"]; + const canSelectForBulkAction = useMemo(() => { + return ( + settings["actions.bulk_enabled"] && + (!bulkActions.cardId || bulkActions.cardId === connectedDashCard?.card_id) + ); + }, [connectedDashCard, settings, bulkActions]); + + const isSelectingItems = useMemo(() => { + return ( + settings["actions.bulk_enabled"] && + bulkActions.cardId === connectedDashCard?.card_id && + bulkActions.selectedRowIndexes.length > 0 + ); + }, [connectedDashCard, settings, bulkActions]); + + const renderBulkSelectionControl = useCallback( + (rowIndex: number) => { + const isSelected = bulkActions.selectedRowIndexes.includes(rowIndex); + + return ( + <BulkSelectionControlContainer isSelectingItems={isSelectingItems}> + <CheckBox + checked={isSelected} + onClick={stopClickPropagation} + onChange={event => { + const isSelectedNow = event.target.checked; + if (isSelectedNow) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bulkActions.addRow(card.id, rowIndex); + } else { + bulkActions.removeRow(rowIndex); + } + }} + /> + </BulkSelectionControlContainer> + ); + }, + [card, bulkActions, isSelectingItems], + ); + const renderListItemCell = useCallback( (rowIndex: number, columnIndex: number | null, slot: CellSlot) => { if (columnIndex === null) { @@ -251,6 +302,7 @@ function List({ const [firstColumnIndex, secondColumnIndex, thirdColumnIndex] = left; return ( <ListItemContent> + {canSelectForBulkAction && renderBulkSelectionControl(rowIndex)} {renderListItemCell(rowIndex, firstColumnIndex, "left")} <div> {renderListItemCell(rowIndex, secondColumnIndex, "left")} @@ -262,13 +314,20 @@ function List({ return ( <ListItemContent> + {canSelectForBulkAction && renderBulkSelectionControl(rowIndex)} {left.map(columnIndex => renderListItemCell(rowIndex, columnIndex, "left"), )} </ListItemContent> ); }, - [settings, listColumnIndexes, renderListItemCell], + [ + settings, + listColumnIndexes, + canSelectForBulkAction, + renderListItemCell, + renderBulkSelectionControl, + ], ); const renderListItem = useCallback( @@ -295,7 +354,18 @@ function List({ isDataApp && checkIsVisualizationClickable(clickObject); const onRowClick = () => { - onVisualizationClick(clickObject); + if (isSelectingItems) { + const isSelected = bulkActions.selectedRowIndexes.includes(rowIndex); + if (isSelected) { + bulkActions.removeRow(rowIndex); + } else { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bulkActions.addRow(card.id, rowIndex); + } + } else { + onVisualizationClick(clickObject); + } }; const onEditClick = (event: React.SyntheticEvent) => { @@ -308,11 +378,16 @@ function List({ event.stopPropagation(); }; + const canClick = isSelectingItems || isClickable; + + const hasInlineActions = + !isSelectingItems && (hasEditButton || hasDeleteButton); + return ( <ListItemContainer key={rowIndex} onClick={onRowClick} - disabled={!isClickable} + disabled={!canClick} ref={ref} data-testid="table-row" > @@ -321,7 +396,7 @@ function List({ {right.map(columnIndex => renderListItemCell(rowIndex, columnIndex, "right"), )} - {(hasEditButton || hasDeleteButton) && ( + {hasInlineActions && ( <RowActionsContainer> {hasEditButton && ( <RowActionButtonContainer slot="right"> @@ -349,12 +424,15 @@ function List({ ); }, [ + card, data, settings, listColumnIndexes, hasEditButton, hasDeleteButton, isDataApp, + isSelectingItems, + bulkActions, checkIsVisualizationClickable, onVisualizationClick, renderListItemLeftPart, diff --git a/frontend/src/metabase/visualizations/visualizations/List.tsx b/frontend/src/metabase/visualizations/visualizations/List.tsx index 24f1620fd0a..d525c9d36e2 100644 --- a/frontend/src/metabase/visualizations/visualizations/List.tsx +++ b/frontend/src/metabase/visualizations/visualizations/List.tsx @@ -73,6 +73,12 @@ export default Object.assign(ListViz, { widget: "toggle", default: true, }, + "actions.bulk_enabled": { + section: t`Actions`, + title: t`Bulk actions`, + widget: "toggle", + default: true, + }, "list.variant": { section: t`Options`, title: t`Variant`, diff --git a/frontend/src/metabase/writeback/actions.ts b/frontend/src/metabase/writeback/actions.ts index 036706b5c8c..5e8b36edfe4 100644 --- a/frontend/src/metabase/writeback/actions.ts +++ b/frontend/src/metabase/writeback/actions.ts @@ -43,6 +43,22 @@ export const updateRow = (payload: UpdateRowPayload) => { }); }; +export type BulkUpdatePayload = { + table: Table; + records: Record<string, unknown>[]; +}; + +export const updateManyRows = (payload: BulkUpdatePayload) => { + const { table, records } = payload; + return ActionsApi.bulkUpdate( + { + tableId: table.id, + body: records, + }, + { bodyParamName: "body" }, + ); +}; + export type DeleteRowPayload = { table: Table; id: number | string; @@ -65,3 +81,19 @@ export const deleteRow = (payload: DeleteRowPayload) => { }, }); }; + +export type BulkDeletePayload = { + table: Table; + ids: Record<string, number | string>[]; +}; + +export const deleteManyRows = (payload: BulkDeletePayload) => { + const { table, ids } = payload; + return ActionsApi.bulkDelete( + { + tableId: table.id, + body: ids, + }, + { bodyParamName: "body" }, + ); +}; diff --git a/frontend/src/metabase/writeback/components/ActionsViz/ActionsViz.tsx b/frontend/src/metabase/writeback/components/ActionsViz/ActionsViz.tsx index 64db067601f..b4895aff9bb 100644 --- a/frontend/src/metabase/writeback/components/ActionsViz/ActionsViz.tsx +++ b/frontend/src/metabase/writeback/components/ActionsViz/ActionsViz.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { t } from "ttag"; import { connect } from "react-redux"; @@ -10,6 +10,8 @@ import { useToggle } from "metabase/hooks/use-toggle"; // TODO ActionsViz should ideally be independent from dashboard import { getCardData } from "metabase/dashboard/selectors"; + +import { useDataAppContext } from "metabase/writeback/containers/DataAppContext"; import WritebackModalForm from "metabase/writeback/containers/WritebackModalForm"; // TODO This should better be extracted to metabase/lib/somewhere @@ -30,9 +32,13 @@ import { DeleteRowFromDataAppPayload, InsertRowFromDataAppPayload, UpdateRowFromDataAppPayload, + BulkUpdateFromDataAppPayload, + BulkDeleteFromDataAppPayload, deleteRowFromDataApp, createRowFromDataApp, updateRowFromDataApp, + updateManyRowsFromDataApp, + deleteManyRowsFromDataApp, } from "metabase/dashboard/writeback-actions"; import { HorizontalAlignmentValue } from "./types"; @@ -115,6 +121,9 @@ interface ActionWizDispatchProps { deleteRow: (payload: DeleteRowFromDataAppPayload) => void; insertRow: (payload: InsertRowFromDataAppPayload) => void; updateRow: (payload: UpdateRowFromDataAppPayload) => void; + + updateManyRows: (payload: BulkUpdateFromDataAppPayload) => void; + deleteManyRows: (payload: BulkDeleteFromDataAppPayload) => void; } type ActionsVizProps = ActionVizOwnProps & @@ -132,6 +141,9 @@ const mapDispatchToProps = { deleteRow: deleteRowFromDataApp, insertRow: createRowFromDataApp, updateRow: updateRowFromDataApp, + + updateManyRows: updateManyRowsFromDataApp, + deleteManyRows: deleteManyRowsFromDataApp, }; function getObjectDetailViewData( @@ -150,20 +162,32 @@ function ActionsViz({ deleteRow, insertRow, updateRow, + updateManyRows, + deleteManyRows, }: ActionsVizProps) { const [isModalOpen, { turnOn: showModal, turnOff: hideModal }] = useToggle(false); const { modalContent: confirmationModalContent, show: requestConfirmation } = useConfirmation(); + const { bulkActions } = useDataAppContext(); + const connectedDashCardId = settings["actions.linked_card"]; const connectedDashCard = dashboard.ordered_cards.find( dashCard => dashCard.id === connectedDashCardId, ); - const question = connectedDashCard - ? new Question(connectedDashCard?.card, metadata) - : null; + const isSelectingItems = + bulkActions.cardId === connectedDashCard?.card_id && + bulkActions.selectedRowIndexes.length > 0; + + const question = useMemo( + () => + connectedDashCard + ? new Question(connectedDashCard?.card, metadata) + : null, + [connectedDashCard, metadata], + ); const isObjectDetailView = question?.display() === "object"; const table = question?.table(); @@ -176,15 +200,34 @@ function ActionsViz({ : undefined; const row = connectedCardData?.rows[0]; + const isBulkSelectActive = bulkActions.cardId === connectedDashCard?.card_id; + const hasCreateButton = settings["actions.create_enabled"] && (!isObjectDetailView || !connectedDashCardId); - const hasUpdateButton = - settings["actions.update_enabled"] && - (isObjectDetailView || !connectedDashCardId); - const hasDeleteButton = - settings["actions.delete_enabled"] && - (isObjectDetailView || !connectedDashCardId); + const canCreate = !!question; + + const hasUpdateButton = settings["actions.update_enabled"]; + const canUpdate = useMemo(() => { + if (!question) { + return false; + } + if (isObjectDetailView) { + return true; + } + return isBulkSelectActive && bulkActions.selectedRowIndexes.length > 0; + }, [question, isObjectDetailView, isBulkSelectActive, bulkActions]); + + const hasDeleteButton = settings["actions.delete_enabled"]; + const canDelete = useMemo(() => { + if (!question) { + return false; + } + if (isObjectDetailView) { + return true; + } + return isBulkSelectActive && bulkActions.selectedRowIndexes.length > 0; + }, [question, isObjectDetailView, isBulkSelectActive, bulkActions]); const horizontalAlignment = settings[ "actions.align_horizontal" @@ -200,7 +243,7 @@ function ActionsViz({ } } - function handleUpdate(values: Record<string, unknown>) { + function handleSingleRecordUpdate(values: Record<string, unknown>) { if (!table || !connectedDashCard || !connectedCardData || !row) { return; } @@ -218,6 +261,41 @@ function ActionsViz({ } } + async function handleBulkUpdate(values: Record<string, unknown>) { + if (!table || !connectedDashCard) { + return; + } + await updateManyRows({ + table, + dashCard: connectedDashCard, + rowIndexes: bulkActions.selectedRowIndexes, + changes: values, + }); + bulkActions.clearSelection(); + } + + async function handleBulkDelete() { + if (!table || !connectedDashCard) { + return; + } + + const rowCount = bulkActions.selectedRowIndexes.length; + const objectName = table?.displayName(); + + requestConfirmation({ + title: t`Delete ${rowCount} ${objectName}?`, + message: t`This can't be undone`, + onConfirm: async () => { + await deleteManyRows({ + table, + dashCard: connectedDashCard, + rowIndexes: bulkActions.selectedRowIndexes, + }); + bulkActions.clearSelection(); + }, + }); + } + function handleDelete() { if ( !question || @@ -258,19 +336,39 @@ function ActionsViz({ }); } + function onDeleteClick() { + if (isBulkSelectActive) { + handleBulkDelete(); + } else { + handleDelete(); + } + } + + function onFormSubmit(values: Record<string, unknown>) { + if (row && !isSelectingItems) { + return handleSingleRecordUpdate(values); + } + if (isSelectingItems) { + return handleBulkUpdate(values); + } + return handleInsert(values); + } + + const isUpdateForm = row || isSelectingItems; + return ( <> <Root horizontalAlignment={horizontalAlignment}> {hasCreateButton && ( - <Button disabled={!question} onClick={showModal}>{t`New`}</Button> + <Button disabled={!canCreate} onClick={showModal}>{t`New`}</Button> )} {hasUpdateButton && ( - <Button disabled={!question} onClick={showModal}>{t`Edit`}</Button> + <Button disabled={!canUpdate} onClick={showModal}>{t`Edit`}</Button> )} {hasDeleteButton && ( <Button - disabled={!question} - onClick={handleDelete} + disabled={!canDelete} + onClick={onDeleteClick} danger >{t`Delete`}</Button> )} @@ -280,7 +378,9 @@ function ActionsViz({ <WritebackModalForm table={table} row={row} - onSubmit={row ? handleUpdate : handleInsert} + type={isUpdateForm ? "update" : "insert"} + mode={isSelectingItems ? "bulk" : "row"} + onSubmit={onFormSubmit} onClose={hideModal} /> </Modal> diff --git a/frontend/src/metabase/writeback/containers/DataAppContext/DataAppContext.ts b/frontend/src/metabase/writeback/containers/DataAppContext/DataAppContext.ts index 98e7306d268..7f0cea02883 100644 --- a/frontend/src/metabase/writeback/containers/DataAppContext/DataAppContext.ts +++ b/frontend/src/metabase/writeback/containers/DataAppContext/DataAppContext.ts @@ -22,12 +22,26 @@ export type DataContextType = Record< export type DataAppContextType = { data: DataContextType; + bulkActions: { + cardId: number | null; + selectedRowIndexes: number[]; + addRow: (cardId: number, index: number) => void; + removeRow: (index: number) => void; + clearSelection: () => void; + }; isLoaded: boolean; format: (text: string) => string; }; export const DataAppContext = createContext<DataAppContextType>({ data: {}, + bulkActions: { + cardId: null, + selectedRowIndexes: [], + addRow: _.noop, + removeRow: _.noop, + clearSelection: _.noop, + }, isLoaded: true, format: (text: string) => text, }); diff --git a/frontend/src/metabase/writeback/containers/DataAppContext/DataAppContextProvider.tsx b/frontend/src/metabase/writeback/containers/DataAppContext/DataAppContextProvider.tsx index 36794d0669d..672666681af 100644 --- a/frontend/src/metabase/writeback/containers/DataAppContext/DataAppContextProvider.tsx +++ b/frontend/src/metabase/writeback/containers/DataAppContext/DataAppContextProvider.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { connect } from "react-redux"; import _ from "lodash"; import { getIn } from "icepick"; @@ -52,6 +52,37 @@ function DataAppContextProvider({ isLoaded, children, }: DataAppContextProviderProps) { + const [bulkActionCardId, setBulkActionCardId] = useState<number | null>(null); + const [selectedRows, setSelectedRows] = useState<number[]>([]); + + const handleRowSelected = useCallback( + (cardId: number, rowIndex: number) => { + if (bulkActionCardId !== cardId) { + setBulkActionCardId(cardId); + setSelectedRows([rowIndex]); + } else { + setSelectedRows(rows => rows.concat(rowIndex)); + } + }, + [bulkActionCardId], + ); + + const handleRowDeselected = useCallback( + (rowIndex: number) => { + const nextRows = selectedRows.filter(row => row !== rowIndex); + setSelectedRows(nextRows); + if (nextRows.length === 0) { + setBulkActionCardId(null); + } + }, + [selectedRows], + ); + + const handleClearSelection = useCallback(() => { + setSelectedRows([]); + setBulkActionCardId(null); + }, []); + const objectDetails = useMemo( () => Object.values(dashCards).filter( @@ -95,6 +126,13 @@ function DataAppContextProvider({ const value: DataAppContextType = { data: dataContext, isLoaded, + bulkActions: { + cardId: bulkActionCardId, + selectedRowIndexes: selectedRows, + addRow: handleRowSelected, + removeRow: handleRowDeselected, + clearSelection: handleClearSelection, + }, format: (text: string) => text, }; @@ -110,7 +148,15 @@ function DataAppContextProvider({ ); return value; - }, [dataContext, isLoaded]); + }, [ + dataContext, + isLoaded, + bulkActionCardId, + selectedRows, + handleRowSelected, + handleRowDeselected, + handleClearSelection, + ]); return ( <DataAppContext.Provider value={context}> diff --git a/frontend/src/metabase/writeback/containers/WritebackForm.tsx b/frontend/src/metabase/writeback/containers/WritebackForm.tsx index e175395dafe..394e80d362e 100644 --- a/frontend/src/metabase/writeback/containers/WritebackForm.tsx +++ b/frontend/src/metabase/writeback/containers/WritebackForm.tsx @@ -15,6 +15,8 @@ import CategoryFieldPicker from "./CategoryFieldPicker"; export interface WritebackFormProps { table: Table; row?: unknown[]; + type?: "insert" | "update"; + mode?: "row" | "bulk"; onSubmit: (values: Record<string, unknown>) => void; // Form props @@ -69,11 +71,22 @@ function getFieldValidationProp(field: Field) { }; } -function WritebackForm({ table, row, onSubmit, ...props }: WritebackFormProps) { - const editableFields = useMemo( - () => table.fields.filter(isEditableField), - [table], - ); +function WritebackForm({ + table, + row, + type = row ? "update" : "insert", + mode, + onSubmit, + ...props +}: WritebackFormProps) { + const editableFields = useMemo(() => { + const fields = table.fields.filter(isEditableField); + if (mode === "bulk") { + // Ideally we need to filter out fields with 'unique' constraint + return fields.filter(field => !field.isPK()); + } + return fields; + }, [table, mode]); const form = useMemo(() => { return { @@ -100,7 +113,7 @@ function WritebackForm({ table, row, onSubmit, ...props }: WritebackFormProps) { const handleSubmit = useCallback( values => { - const isUpdate = !!row; + const isUpdate = type === "update"; const changes = isUpdate ? {} : values; if (isUpdate) { @@ -118,10 +131,10 @@ function WritebackForm({ table, row, onSubmit, ...props }: WritebackFormProps) { return onSubmit?.(changes); }, - [form, row, onSubmit], + [form, type, onSubmit], ); - const submitTitle = row ? t`Update` : t`Create`; + const submitTitle = type === "update" ? t`Update` : t`Create`; return ( <StyledForm diff --git a/frontend/src/metabase/writeback/containers/WritebackModalForm.tsx b/frontend/src/metabase/writeback/containers/WritebackModalForm.tsx index 13a9a5b007d..3487acf8b04 100644 --- a/frontend/src/metabase/writeback/containers/WritebackModalForm.tsx +++ b/frontend/src/metabase/writeback/containers/WritebackModalForm.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useMemo } from "react"; import { t } from "ttag"; import ModalContent from "metabase/components/ModalContent"; @@ -12,12 +12,19 @@ interface WritebackModalFormProps extends WritebackFormProps { function WritebackModalForm({ table, row, + type = row ? "update" : "insert", + mode, onClose, onSubmit, ...props }: WritebackModalFormProps) { - const objectName = table.objectName(); - const title = row ? t`Edit ${objectName}` : t`New ${objectName}`; + const title = useMemo(() => { + if (type === "update" && mode === "bulk") { + return t`Update`; + } + const objectName = table.objectName(); + return type === "update" ? t`Edit ${objectName}` : t`New ${objectName}`; + }, [table, type, mode]); const handleSubmit = useCallback( async (values: Record<string, unknown>) => { @@ -32,6 +39,8 @@ function WritebackModalForm({ <WritebackForm table={table} row={row} + type={type} + mode={mode} onSubmit={handleSubmit} {...props} /> -- GitLab