diff --git a/src/components/tables/BulkEditTable/BulkEditTable.js b/src/components/tables/BulkEditTable/BulkEditTable.js new file mode 100644 index 000000000..a14037873 --- /dev/null +++ b/src/components/tables/BulkEditTable/BulkEditTable.js @@ -0,0 +1,220 @@ +import React, { useEffect } from "react"; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Checkbox from "@mui/material/Checkbox"; +import Toolbar from "./components/Toolbar"; +import Heading from "./components/Heading"; +import Row from "./components/Row"; +import useRowSelection from "./hooks/useRowSelection"; +import { SORT_ASCENDING, SORT_DESCENDING } from "../../../utils/constants"; +import styles from "./BulkEditTable.module.less"; + +const defaults = { + sortFunc: (a, b) => { + if (a < b) { + return SORT_DESCENDING; + } + if (a > b) { + return SORT_ASCENDING; + } + return 0; + }, + sortable: false, + sortCol: 0, + sortDir: 1, + colWidth: "" +}; + +function BulkEditTable(props) { + const { + options, + columns, + currentSummit, + page, + data, + handleSort, + updateData, + handleDeleteRow, + formattingFunction, + afterUpdate = [] + } = props; + + const { + selectedRows, + isSelected, + toggleRow, + isAllSelected, + toggleAll, + editField, + editEnabled, + enterEditMode, + cancel, + reset + } = useRowSelection(); + + // reset selection/edit state on data changes/pagination + useEffect(() => { + reset(); + }, [page]); + + const getSortDir = (columnKey, columnIndex, sortCol, sortDir) => { + if (columnKey && columnKey === sortCol) { + return sortDir; + } + if (sortCol === columnIndex) { + return sortDir; + } + return null; + }; + + const handledAfterUpdateData = () => { + const actionsAfterUpdate = []; + if (afterUpdate.length > 0) { + afterUpdate.forEach(({ action, propertyName }) => { + selectedRows.forEach((row) => { + if (Array.isArray(row[propertyName])) { + row[propertyName].forEach((e) => { + actionsAfterUpdate.push(action(e)); + }); + } else { + actionsAfterUpdate.push(action(row[propertyName])); + } + }); + }); + } + return Promise.all(actionsAfterUpdate); + }; + + const onUpdateEvents = (evt) => { + evt.stopPropagation(); + evt.preventDefault(); + updateData(currentSummit.id, selectedRows) + .then(() => handledAfterUpdateData()) + .then(() => reset()) + .catch((error) => { + console.error("Error updating events:", error); + }); + }; + + return ( + + 0} + onEdit={enterEditMode} + onApply={onUpdateEvents} + onCancel={cancel} + /> + + + + + + + toggleAll(data)} + inputProps={{ "aria-label": "select all" }} + /> + + {columns.map((col, i) => { + const sortCol = + typeof options.sortCol !== "undefined" + ? options.sortCol + : defaults.sortCol; + const sortDir = + typeof options.sortDir !== "undefined" + ? options.sortDir + : defaults.sortDir; + const sortFunc = + typeof options.sortFunc !== "undefined" + ? options.sortFunc + : defaults.sortFunc; + const sortable = + typeof col.sortable !== "undefined" + ? col.sortable + : defaults.sortable; + const colWidth = + typeof col.width !== "undefined" + ? col.width + : defaults.colWidth; + + return ( + + {col.value} + + ); + })} + {options.actions && ( + + {options.actionsHeader || " "} + + )} + + + + {columns.length > 0 && + data.map((row, i) => { + if (Array.isArray(row) && row.length !== columns.length) { + console.warn( + `Data at row ${i} is ${row.length}. It should be ${columns.length}.` + ); + return ; + } + + return ( + r.id === row.id) || row} + onToggle={() => toggleRow(row)} + onFieldChange={(key, value) => + editField(row.id, key, value) + } + deleteRow={handleDeleteRow} + columns={columns} + actions={options.actions} + formattingFunction={formattingFunction} + /> + ); + })} + +
+
+
+
+ ); +} + +export default BulkEditTable; diff --git a/src/components/tables/BulkEditTable/BulkEditTable.module.less b/src/components/tables/BulkEditTable/BulkEditTable.module.less new file mode 100644 index 000000000..9683cca45 --- /dev/null +++ b/src/components/tables/BulkEditTable/BulkEditTable.module.less @@ -0,0 +1,57 @@ +.tableWrapper { + width: 100%; + overflow-x: auto; + position: relative; + + td { + max-width: 150px; + text-overflow: ellipsis; + vertical-align: middle; + + &.dataColumn { + min-width: 150px; + } + } + + // shared by header (th) and body (td) cells so the checkbox/action columns + // stay pinned and aligned in both rows while the data columns scroll + // horizontally. Background color is intentionally NOT set here: header + // cells need the header's grey, body cells need white, set via sx where + // each is rendered (an explicit color is required, `inherit` resolves to + // transparent here and lets the scrolling columns show through). + .checkColumn { + text-align: center; + position: sticky; + z-index: 5; + left: 0; + } + + .actionColumn { + text-align: center; + position: sticky; + z-index: 5; + right: 0; + width: 60px; + min-width: 60px; + max-width: 60px; + } + + .bulkEditCol { + min-width: 250px; + } +} + +.dottedBorderLeft { + position: relative; + border-left: none; + &::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + border-left: 1px dashed #e0e0e0; + height: 60%; + align-self: center; + } +} diff --git a/src/components/tables/editable-table/__tests__/EditableTable.test.js b/src/components/tables/BulkEditTable/__tests__/BulkEditTable.test.js similarity index 93% rename from src/components/tables/editable-table/__tests__/EditableTable.test.js rename to src/components/tables/BulkEditTable/__tests__/BulkEditTable.test.js index 189b36a09..73c9668da 100644 --- a/src/components/tables/editable-table/__tests__/EditableTable.test.js +++ b/src/components/tables/BulkEditTable/__tests__/BulkEditTable.test.js @@ -1,9 +1,9 @@ import React from "react"; import userEvent from "@testing-library/user-event"; import { act, render, screen, waitFor } from "@testing-library/react"; -import EditableTable from "../EditableTable"; +import BulkEditTable from "../BulkEditTable"; -describe("EditableTable", () => { +describe("BulkEditTable", () => { const baseProps = { options: { className: "test-table", @@ -40,7 +40,7 @@ describe("EditableTable", () => { const user = userEvent.setup(); const updateData = jest.fn(() => Promise.resolve()); - render(); + render(); const checkboxes = screen.getAllByRole("checkbox"); @@ -65,7 +65,7 @@ describe("EditableTable", () => { const afterUpdateAction = jest.fn(() => Promise.resolve()); render( - + ); + } + + if (isEditingRow && col.editableField) { + // editableField functions may short-circuit (e.g. `cond && `) and + // return `undefined` rather than `false`, which React rejects as a component return value. + return ( + col.editableField({ + value: + editRow[col.columnKey]?.id || + editRow[col.columnKey]?.value || + editRow[col.columnKey], + onChange, + row: editRow, + rowData: editRow[col.columnKey], + onRemoveOption + }) ?? null + ); + } + + if (col.render) { + return col.render(row[col.columnKey], row) ?? null; + } + + return ( + + {formattedData[col.columnKey] ?? null} + + ); +} + +Cell.propTypes = { + col: PropTypes.object.isRequired, + row: PropTypes.object.isRequired, + editRow: PropTypes.object.isRequired, + isEditingRow: PropTypes.bool, + onChange: PropTypes.func, + onRemoveOption: PropTypes.func, + formattedData: PropTypes.object +}; + +export default Cell; diff --git a/src/components/tables/BulkEditTable/components/Heading.js b/src/components/tables/BulkEditTable/components/Heading.js new file mode 100644 index 000000000..ac7820931 --- /dev/null +++ b/src/components/tables/BulkEditTable/components/Heading.js @@ -0,0 +1,72 @@ +import React from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import Box from "@mui/material/Box"; +import TableCell from "@mui/material/TableCell"; +import TableSortLabel from "@mui/material/TableSortLabel"; +import { visuallyHidden } from "@mui/utils"; +import { SORT_ASCENDING, SORT_DESCENDING } from "../../../../utils/constants"; + +function Heading(props) { + const { + editEnabled, + sortable, + sortDir, + onSort, + columnIndex, + columnKey, + sortFunc, + width, + children + } = props; + + const handleSort = () => { + if (!onSort || !sortable || editEnabled) return; + + onSort( + columnIndex, + columnKey, + sortDir ? sortDir * SORT_DESCENDING : SORT_ASCENDING, + sortFunc + ); + }; + + const headerSx = width ? { width, minWidth: width, maxWidth: width } : {}; + + if (!sortable || editEnabled) { + return {children}; + } + + return ( + + + {children} + {sortDir ? ( + + {sortDir === SORT_DESCENDING + ? T.translate("mui_table.sorted_desc") + : T.translate("mui_table.sorted_asc")} + + ) : null} + + + ); +} + +Heading.propTypes = { + editEnabled: PropTypes.bool, + onSort: PropTypes.func, + sortDir: PropTypes.number, + columnIndex: PropTypes.number, + columnKey: PropTypes.any, + sortable: PropTypes.bool, + sortFunc: PropTypes.func, + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + children: PropTypes.node +}; + +export default Heading; diff --git a/src/components/tables/BulkEditTable/components/Row.js b/src/components/tables/BulkEditTable/components/Row.js new file mode 100644 index 000000000..08dcd6576 --- /dev/null +++ b/src/components/tables/BulkEditTable/components/Row.js @@ -0,0 +1,152 @@ +import React from "react"; +import PropTypes from "prop-types"; +import Box from "@mui/material/Box"; +import TableRow from "@mui/material/TableRow"; +import TableCell from "@mui/material/TableCell"; +import Checkbox from "@mui/material/Checkbox"; +import IconButton from "@mui/material/IconButton"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import history from "../../../../history"; +import Cell from "./Cell"; +import styles from "../BulkEditTable.module.less"; + +const getCellStyle = (col, isEditingRow) => ({ + ...(isEditingRow && col.editableField ? { minWidth: 250 } : {}), + ...(col.width + ? { width: col.width, minWidth: col.width, maxWidth: col.width } + : {}), + ...col.customStyle +}); + +function Row(props) { + const { + row, + columns, + editEnabled, + isSelected, + editRow, + onToggle, + onFieldChange, + deleteRow, + currentSummit, + actions, + formattingFunction + } = props; + + const formattedData = formattingFunction(row, currentSummit); + const isEditingRow = isSelected && editEnabled; + + const onRowChange = (ev) => { + const { value, id } = ev.target; + + if (id.includes("___")) { + const [arrayProp, elementIdRaw, prop] = id.split("___"); // ['array property', '', 'element property'] + const elementId = parseInt(elementIdRaw, 10); + const arrayToChange = (editRow[arrayProp] || []).map((elem) => + elem.id === elementId ? { ...elem, [prop]: value } : elem + ); + onFieldChange(arrayProp, arrayToChange); + } else { + onFieldChange(id, value); + } + }; + + const onRemoveOption = (optionId, columnKey) => { + const newOptions = (editRow[columnKey] || []).filter( + (option) => option.id !== optionId + ); + onFieldChange(columnKey, newOptions); + }; + + return ( + + + + + {row.id} + {columns + .filter((col) => col.columnKey !== "id") + .map((col) => ( + + + + ))} + {(actions?.edit || actions?.delete) && ( + + + {actions.edit && ( + + history.push( + `/app/summits/${currentSummit.id}/events/${row.id}` + ) + } + sx={{ padding: 0 }} + aria-label={`Edit event ${row.id}`} + > + + + )} + {actions.delete && ( + deleteRow(row.id)} + sx={{ padding: 0 }} + aria-label={`Delete event ${row.id}`} + > + + + )} + + + )} + + ); +} + +Row.propTypes = { + row: PropTypes.object.isRequired, + columns: PropTypes.array.isRequired, + editEnabled: PropTypes.bool, + isSelected: PropTypes.bool, + editRow: PropTypes.object.isRequired, + onToggle: PropTypes.func, + onFieldChange: PropTypes.func, + deleteRow: PropTypes.func, + currentSummit: PropTypes.object, + actions: PropTypes.object, + formattingFunction: PropTypes.func.isRequired +}; + +export default Row; diff --git a/src/components/tables/BulkEditTable/components/Toolbar.js b/src/components/tables/BulkEditTable/components/Toolbar.js new file mode 100644 index 000000000..fd258aa2d --- /dev/null +++ b/src/components/tables/BulkEditTable/components/Toolbar.js @@ -0,0 +1,36 @@ +import React from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; + +function Toolbar({ editEnabled, hasSelection, onEdit, onApply, onCancel }) { + return ( + + {editEnabled ? ( + <> + + + + ) : ( + + )} + + ); +} + +Toolbar.propTypes = { + editEnabled: PropTypes.bool, + hasSelection: PropTypes.bool, + onEdit: PropTypes.func, + onApply: PropTypes.func, + onCancel: PropTypes.func +}; + +export default Toolbar; diff --git a/src/components/tables/BulkEditTable/hooks/useRowSelection.js b/src/components/tables/BulkEditTable/hooks/useRowSelection.js new file mode 100644 index 000000000..46710b35c --- /dev/null +++ b/src/components/tables/BulkEditTable/hooks/useRowSelection.js @@ -0,0 +1,49 @@ +import { useState } from "react"; + +const useRowSelection = () => { + const [selectedRows, setSelectedRows] = useState([]); + const [editEnabled, setEditEnabled] = useState(false); + + const isSelected = (rowId) => selectedRows.some((row) => row.id === rowId); + + const toggleRow = (row) => { + setSelectedRows((current) => + isSelected(row.id) + ? current.filter((r) => r.id !== row.id) + : [...current, row] + ); + }; + + const isAllSelected = (rows) => + rows.length > 0 && rows.every((row) => isSelected(row.id)); + + const toggleAll = (rows) => { + setSelectedRows(isAllSelected(rows) ? [] : rows); + }; + + const editField = (rowId, key, value) => { + setSelectedRows((current) => + current.map((row) => (row.id === rowId ? { ...row, [key]: value } : row)) + ); + }; + + const reset = () => { + setSelectedRows([]); + setEditEnabled(false); + }; + + return { + selectedRows, + isSelected, + toggleRow, + isAllSelected, + toggleAll, + editField, + editEnabled, + enterEditMode: () => setEditEnabled(true), + cancel: reset, + reset + }; +}; + +export default useRowSelection; diff --git a/src/components/tables/BulkEditTable/index.js b/src/components/tables/BulkEditTable/index.js new file mode 100644 index 000000000..37f44e226 --- /dev/null +++ b/src/components/tables/BulkEditTable/index.js @@ -0,0 +1,3 @@ +import BulkEditTable from "./BulkEditTable"; + +export default BulkEditTable; diff --git a/src/components/tables/editable-table/EditableTable.js b/src/components/tables/editable-table/EditableTable.js deleted file mode 100644 index a8f959d07..000000000 --- a/src/components/tables/editable-table/EditableTable.js +++ /dev/null @@ -1,269 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Tooltip } from "react-tooltip"; -import T from "i18n-react/dist/i18n-react"; -import EditableTableHeading from "./EditableTableHeading"; -import EditableTableRow from "./EditableTableRow"; -import { - INDEX_NOT_FOUND, - SORT_ASCENDING, - SORT_DESCENDING -} from "../../../utils/constants"; - -import styles from "./index.module.less"; - -const defaults = { - sortFunc: (a, b) => { - if (a < b) { - return SORT_DESCENDING; - } - if (a > b) { - return SORT_ASCENDING; - } - return 0; - }, - sortable: false, - sortCol: 0, - sortDir: 1, - colWidth: "" -}; - -function EditableTable(props) { - const { - options, - columns, - currentSummit, - page, - data, - handleSort, - updateData, - handleDeleteRow, - formattingFunction, - afterUpdate = [] - } = props; - let tableClass = options.hasOwnProperty("className") ? options.className : ""; - const [editButton, setEditButton] = useState(false); - const [editEnabled, setEditEnabled] = useState(false); - const [selected, setSelected] = useState([]); - const [selectAll, setSelectAll] = useState(false); - tableClass += options.actions?.edit ? " table-hover" : ""; - - const getSortDir = (columnKey, columnIndex, sortCol, sortDir) => { - if (columnKey && columnKey === sortCol) { - return sortDir; - } - if (sortCol === columnIndex) { - return sortDir; - } - return null; - }; - - const resetState = () => { - setSelectAll(false); - setEditButton(false); - setEditEnabled(false); - setSelected([]); - }; - - // reseting states on data changes/pagination - useEffect(() => { - resetState(); - }, [page]); - - useEffect(() => { - if (selected.length > 0) { - setEditButton(true); - } else { - setEditButton(false); - setEditEnabled(false); - } - }, [selected]); - - useEffect(() => { - if (selectAll) { - setSelected(data); - setSelectAll(true); - } else { - setSelectAll(false); - setSelected([]); - } - }, [selectAll]); - - const updateSelected = (row, checked) => { - const selectedRow = row; - const rowIndex = selected.findIndex((s) => s.id === selectedRow.id); - const exists = rowIndex !== INDEX_NOT_FOUND; - - if (checked) { - if (exists) { - // if already on selected list, replace with new data - const updatedSelected = { ...selectedRow }; - const newSelected = selected.slice(); - newSelected[rowIndex] = updatedSelected; - setSelected(newSelected); - } else { - // append to list - setSelected((currSelected) => [...currSelected, selectedRow]); - } - } else { - setSelected(selected.filter((se) => se.id !== selectedRow.id)); - } - }; - - const handledAfterUpdateData = () => { - const actionsAfterUpdate = []; - if (afterUpdate.length > 0) { - afterUpdate.forEach(({ action, propertyName }) => { - selected.forEach((row) => { - if (Array.isArray(row[propertyName])) { - row[propertyName].forEach((e) => { - actionsAfterUpdate.push(action(e)); - }); - } else { - actionsAfterUpdate.push(action(row[propertyName])); - } - }); - }); - } - return Promise.all(actionsAfterUpdate); - }; - - const onUpdateEvents = (evt) => { - evt.stopPropagation(); - evt.preventDefault(); - updateData(currentSummit.id, selected) - .then(() => handledAfterUpdateData()) - .then(() => resetState()) - .catch((error) => { - console.error("Error updating events:", error); - }); - }; - - return ( -
-
-
-
- {editEnabled ? ( - <> - - - - ) : ( - - )} -
-
-
- - - - - {columns.map((col, i) => { - const sortCol = - typeof options.sortCol !== "undefined" - ? options.sortCol - : defaults.sortCol; - const sortDir = - typeof options.sortDir !== "undefined" - ? options.sortDir - : defaults.sortDir; - const sortFunc = - typeof options.sortFunc !== "undefined" - ? options.sortFunc - : defaults.sortFunc; - const sortable = - typeof col.sortable !== "undefined" - ? col.sortable - : defaults.sortable; - const colWidth = - typeof col.width !== "undefined" - ? col.width - : defaults.colWidth; - - return ( - - {col.value} - - ); - })} - {options.actions && ( - - {options.actionsHeader || " "} - - )} - - - - {columns.length > 0 && - data.map((row, i) => { - if (Array.isArray(row) && row.length !== columns.length) { - console.warn( - `Data at row ${i} is ${row.length}. It should be ${columns.length}.` - ); - return ; - } - - return ( - - - - ); - })} - -
- setSelectAll(!selectAll)} - checked={selectAll} - /> -
-
- -
- ); -} - -export default EditableTable; diff --git a/src/components/tables/editable-table/EditableTableHeading.js b/src/components/tables/editable-table/EditableTableHeading.js deleted file mode 100644 index 17578effb..000000000 --- a/src/components/tables/editable-table/EditableTableHeading.js +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { SORT_ASCENDING, SORT_DESCENDING } from "../../../utils/constants"; - -function EditableTableHeading(props) { - const { - editEnabled, - sortable, - sortDir, - onSort, - columnIndex, - columnKey, - sortFunc, - width, - children - } = props; - const getSortClass = () => { - // disable sorting if on edit mode - if (!sortable || editEnabled) return null; - - switch (sortDir) { - case SORT_ASCENDING: - return "sorting_asc"; - case SORT_DESCENDING: - return "sorting_desc"; - default: - return sortable ? "sorting" : null; - } - }; - - const handleSort = (e) => { - e.preventDefault(); - - if (!onSort || !sortable) return; - - onSort( - columnIndex, - columnKey, - sortDir ? sortDir * SORT_DESCENDING : SORT_ASCENDING, - sortFunc - ); - }; - - return ( - - {children} - - ); -} - -EditableTableHeading.propTypes = { - onSort: PropTypes.func, - sortDir: PropTypes.number, - columnIndex: PropTypes.number, - columnKey: PropTypes.any, - sortable: PropTypes.bool, - sortFunc: PropTypes.func -}; - -export default EditableTableHeading; diff --git a/src/components/tables/editable-table/EditableTableRow.js b/src/components/tables/editable-table/EditableTableRow.js deleted file mode 100644 index bcd49f6f7..000000000 --- a/src/components/tables/editable-table/EditableTableRow.js +++ /dev/null @@ -1,190 +0,0 @@ -import React, { useEffect, useState } from "react"; -import TextArea from "openstack-uicore-foundation/lib/components/inputs/textarea-input"; -import T from "i18n-react/dist/i18n-react"; -import history from "../../../history"; - -import styles from "./index.module.less"; - -function EditableTableRow(props) { - const { - row, - columns, - editEnabled, - selected, - updateSelected, - deleteRow, - selectAll, - currentSummit, - actions, - formattingFunction - } = props; - const [checked, setChecked] = useState(false); - const [editData, setEditData] = useState(row); - - const formattedData = formattingFunction(row, currentSummit); - - useEffect(() => { - setEditData(row); - }, [row]); - useEffect(() => { - updateSelected(editData, checked); - }, [checked, row]); - useEffect(() => { - setChecked(selectAll); - }, [selectAll]); - useEffect(() => { - if (selected.length === 0) { - setChecked(false); - } - }, [selected]); - useEffect(() => { - updateSelected(editData, checked); - }, [editData]); - useEffect(() => { - if (!editEnabled) { - setEditData(row); - } - }, [editEnabled]); - - const onRowChange = (ev) => { - const { value, id } = ev.target; - if (id.includes("___")) { - const parts = id.split("___"); // ['array property', '', 'element property'] - const arrayProp = parts[0]; - const elementId = parseInt(parts[1], 10); - const prop = parts[2]; - - const arrayToChange = editData[arrayProp].map((elem) => { - if (elem.id === elementId) { - return { ...elem, [prop]: value }; - } - return elem; - }); - - const newEventData = { ...editData, [arrayProp]: arrayToChange }; - setEditData(newEventData); - } else { - const newEventData = { ...editData, [id]: value }; - setEditData(newEventData); - } - }; - - const onRemoveOption = (rowId, id) => { - const currentRow = selected.find((r) => r.id === row.id); - const newOptions = currentRow[id].filter((s) => s.id !== rowId); - const newEventData = { ...editData, [id]: newOptions }; - setEditData(newEventData); - }; - - return ( - <> - - setChecked(!checked)} - checked={checked} - /> - - {row.id} - {selected.find((s) => s.id === row.id) && editEnabled && checked ? ( - <> - {columns.map((col) => { - if (col.columnKey === "id") { - return null; - } - if (col.editableField === true) { - // Default field as text - return ( - -