diff --git a/src/components/Permissions/PermissionsTable.tsx b/src/components/Permissions/PermissionsTable.tsx index af07e67eb8..3ebbffd9bb 100644 --- a/src/components/Permissions/PermissionsTable.tsx +++ b/src/components/Permissions/PermissionsTable.tsx @@ -4,6 +4,8 @@ import { Icon } from "@flanksource-ui/ui/Icons/Icon"; import { MRTDateCell } from "@flanksource-ui/ui/MRTDataTable/Cells/MRTDateCells"; import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; import { MRT_ColumnDef } from "mantine-react-table"; +import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"; +import type { ChangeEvent } from "react"; import CanaryLink from "../Canary/CanaryLink"; import ConfigLink from "../Configs/ConfigLink/ConfigLink"; import ConnectionIcon from "../Connections/ConnectionIcon"; @@ -33,7 +35,6 @@ const permissionsTableColumns: MRT_ColumnDef[] = [ id: "subject", accessorFn: (row) => row.subject, header: "Subject", - size: 80, Cell: ({ row }) => { const { team, group, person, subject, notification, playbook } = row.original; @@ -112,7 +113,6 @@ const permissionsTableColumns: MRT_ColumnDef[] = [ header: "Resource", enableHiding: true, enableSorting: false, - size: 150, Cell: ({ row }) => { const config = row.original.config_object; const playbook = row.original.playbook_object; @@ -249,7 +249,6 @@ const permissionsTableColumns: MRT_ColumnDef[] = [ id: "action", accessorFn: (row) => row.action, header: "Action", - size: 60, Cell: ({ row }) => { const action = row.original.action; const deny = row.original.deny; @@ -280,19 +279,16 @@ const permissionsTableColumns: MRT_ColumnDef[] = [ id: "description", header: "Description", enableSorting: false, - size: 200, accessorFn: (row) => row.description }, { id: "updated_at", - size: 40, header: "Updated", accessorFn: (row) => row.updated_at, Cell: MRTDateCell }, { id: "created_at", - size: 40, header: "Created", accessorFn: (row) => row.created_at, Cell: MRTDateCell @@ -301,7 +297,6 @@ const permissionsTableColumns: MRT_ColumnDef[] = [ id: "created_by", accessorFn: (row) => row.created_by, header: "Created By", - size: 40, Cell: ({ row }) => { const createdBy = row.original.created_by; const source = row.original.source; @@ -325,6 +320,10 @@ type PermissionsTableProps = { isLoading: boolean; pageCount: number; totalEntries: number; + enableRowSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: OnChangeFn; + onSelectAllChange?: (checked: boolean) => void; handleRowClick?: (row: PermissionsSummary) => void; hideResourceColumn?: boolean; }; @@ -334,19 +333,84 @@ export default function PermissionsTable({ isLoading, pageCount, totalEntries, + enableRowSelection = false, + rowSelection, + onRowSelectionChange, + onSelectAllChange, hideResourceColumn = false, handleRowClick = () => {} }: PermissionsTableProps) { return ( row.id} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + mantineSelectCheckboxProps={{ + size: "xs", + radius: "lg", + styles: { + icon: { + display: "none" + }, + input: { + opacity: 0.9, + borderWidth: 1, + borderColor: "#d1d5db", + backgroundColor: "#f9fafb", + transition: + "opacity 120ms ease-in-out, border-color 120ms ease-in-out", + "&:hover, &:focus": { + opacity: 1, + borderColor: "#9ca3af" + } + } + } + }} + mantineSelectAllCheckboxProps={{ + size: "xs", + radius: "lg", + onChange: (event: ChangeEvent) => { + onSelectAllChange?.(event.currentTarget.checked); + }, + styles: { + icon: { + display: "none" + }, + input: { + opacity: 0.9, + borderWidth: 1, + borderColor: "#d1d5db", + backgroundColor: "#f9fafb", + transition: + "opacity 120ms ease-in-out, border-color 120ms ease-in-out", + "&:hover, &:focus": { + opacity: 1, + borderColor: "#9ca3af" + } + } + } + }} onRowClick={handleRowClick} + displayColumnDefOptions={{ + "mrt-row-select": { + maxSize: 36 + } + }} hiddenColumns={hideResourceColumn ? ["Resource"] : []} /> ); diff --git a/src/components/Permissions/PermissionsView.tsx b/src/components/Permissions/PermissionsView.tsx index bf321bb669..7d40801045 100644 --- a/src/components/Permissions/PermissionsView.tsx +++ b/src/components/Permissions/PermissionsView.tsx @@ -9,8 +9,12 @@ import { import useReactTablePaginationState from "@flanksource-ui/ui/DataTable/Hooks/useReactTablePaginationState"; import useReactTableSortState from "@flanksource-ui/ui/DataTable/Hooks/useReactTableSortState"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from ".."; +import { toastSuccess } from "../Toast/toast"; +import useMrtBulkSelection, { + BulkSelectionPayload +} from "@flanksource-ui/ui/MRTDataTable/Hooks/useMrtBulkSelection"; import { FormikSelectDropdownOption } from "../Forms/Formik/FormikSelectDropdown"; import PermissionForm from "./ManagePermissions/Forms/PermissionForm"; import PermissionsTable from "./PermissionsTable"; @@ -72,13 +76,31 @@ export function getActionsForResourceType( return commonActions; } +type SelectionScope = { + permissionRequest: FetchPermissionsInput; + sortBy?: string; + sortOrder: "asc" | "desc"; +}; + +export type PermissionsBulkActionControls = { + hasSelectedRows: boolean; + selectionSummary: string; + onAllowSelected: () => void; + onDenySelected: () => void; + onClearSelection: () => void; +}; + type PermissionsViewProps = { permissionRequest: FetchPermissionsInput; setIsLoading?: (isLoading: boolean) => void; hideResourceColumn?: boolean; newPermissionData?: Partial; showAddPermission?: boolean; + showInlineBulkActionControls?: boolean; onRefetch?: (refetch: () => void) => void; + onBulkActionControlsChange?: ( + controls: PermissionsBulkActionControls + ) => void; }; export default function PermissionsView({ @@ -87,7 +109,9 @@ export default function PermissionsView({ hideResourceColumn = false, newPermissionData, showAddPermission = false, - onRefetch + showInlineBulkActionControls = true, + onRefetch, + onBulkActionControlsChange }: PermissionsViewProps) { const [selectedPermission, setSelectedPermission] = useState(); @@ -137,29 +161,151 @@ export default function PermissionsView({ const totalEntries = data?.totalEntries || 0; const pageCount = totalEntries ? Math.ceil(totalEntries / pageSize) : 1; - const permissions = data?.data || []; + const permissions = useMemo(() => data?.data ?? [], [data?.data]); + + const selectionScope = useMemo( + () => ({ + permissionRequest, + sortBy: mappedSortBy, + sortOrder: sortState[0]?.desc ? "desc" : "asc" + }), + [mappedSortBy, permissionRequest, sortState] + ); + + const selectionResetKey = useMemo( + () => JSON.stringify(permissionRequest), + [permissionRequest] + ); + + const { + rowSelection, + selectedCount, + hasSelectedRows, + selectionSummary, + onRowSelectionChange: handleRowSelectionChange, + onSelectAllChange: handleSelectAllChange, + clearSelection: handleClearSelection, + buildPayload + } = useMrtBulkSelection({ + rows: permissions, + totalRowCount: totalEntries, + getRowId: (row) => row.id, + selectionScope, + resetKey: selectionResetKey + }); + + const handleBulkAction = useCallback( + (action: "allow" | "deny") => { + const payload: BulkSelectionPayload = buildPayload(); + + toastSuccess( + `${action === "allow" ? "Allow Selected" : "Deny Selected"} prepared for ${selectedCount} permission${selectedCount === 1 ? "" : "s"}` + ); + + // Frontend-only wiring for now. API call will use this payload in follow-up work. + void payload; + }, + [buildPayload, selectedCount] + ); + + const handleAllowSelected = useCallback(() => { + handleBulkAction("allow"); + }, [handleBulkAction]); + + const handleDenySelected = useCallback(() => { + handleBulkAction("deny"); + }, [handleBulkAction]); + + useEffect(() => { + if (!onBulkActionControlsChange) { + return; + } + + onBulkActionControlsChange({ + hasSelectedRows, + selectionSummary, + onAllowSelected: handleAllowSelected, + onDenySelected: handleDenySelected, + onClearSelection: handleClearSelection + }); + }, [ + hasSelectedRows, + handleAllowSelected, + handleClearSelection, + handleDenySelected, + onBulkActionControlsChange, + selectionSummary + ]); return ( <> - {showAddPermission && ( -
- +
+ {showAddPermission && ( +
+ +
+ )} + +
+ {showInlineBulkActionControls && ( +
+
+ + {selectionSummary} + + + + +
+
+ )} + + setSelectedPermission(row)} + hideResourceColumn={hideResourceColumn} + />
- )} - setSelectedPermission(row)} - hideResourceColumn={hideResourceColumn} - /> +
{selectedPermission && (
-
+
Action:} />
-
+
= + | { + mode: "selective"; + includedIds: Set; + } + | { + mode: "all"; + selectionScope: S; + } + | { + mode: "allExcept"; + selectionScope: S; + excludedIds: Set; + }; + +export type BulkSelectionPayload = + | { mode: "selective"; ids: string[] } + | { mode: "all"; ids: string[]; selectionScope: S } + | { mode: "allExcept"; ids: string[]; selectionScope: S }; + +type UseMrtBulkSelectionOptions = { + rows: T[]; + totalRowCount: number; + getRowId: (row: T) => string; + selectionScope: S; + resetKey?: string; +}; + +const emptyBulkSelection = (): BulkSelectionState => ({ + mode: "selective", + includedIds: new Set() +}); + +function isIdSelected(state: BulkSelectionState, id: string) { + if (state.mode === "selective") { + return state.includedIds.has(id); + } + + if (state.mode === "all") { + return true; + } + + return !state.excludedIds.has(id); +} + +function selectId( + state: BulkSelectionState, + id: string +): BulkSelectionState { + if (state.mode === "selective") { + const next = new Set(state.includedIds); + next.add(id); + return { + mode: "selective", + includedIds: next + }; + } + + if (state.mode === "all") { + return state; + } + + const next = new Set(state.excludedIds); + next.delete(id); + + if (next.size === 0) { + return { + mode: "all", + selectionScope: state.selectionScope + }; + } + + return { + mode: "allExcept", + selectionScope: state.selectionScope, + excludedIds: next + }; +} + +function deselectId( + state: BulkSelectionState, + id: string +): BulkSelectionState { + if (state.mode === "selective") { + const next = new Set(state.includedIds); + next.delete(id); + return { + mode: "selective", + includedIds: next + }; + } + + if (state.mode === "all") { + return { + mode: "allExcept", + selectionScope: state.selectionScope, + excludedIds: new Set([id]) + }; + } + + const next = new Set(state.excludedIds); + next.add(id); + return { + mode: "allExcept", + selectionScope: state.selectionScope, + excludedIds: next + }; +} + +function countSelectedRows( + state: BulkSelectionState, + totalEntries: number +) { + if (state.mode === "selective") { + return state.includedIds.size; + } + + if (state.mode === "all") { + return totalEntries; + } + + return Math.max(totalEntries - state.excludedIds.size, 0); +} + +export default function useMrtBulkSelection({ + rows, + totalRowCount, + getRowId, + selectionScope, + resetKey +}: UseMrtBulkSelectionOptions) { + const [bulkSelection, setBulkSelection] = useState>( + emptyBulkSelection + ); + + useEffect(() => { + if (resetKey === undefined) { + return; + } + + setBulkSelection(emptyBulkSelection()); + }, [resetKey]); + + const rowSelection = useMemo(() => { + return rows.reduce((acc, row) => { + const id = getRowId(row); + if (id && isIdSelected(bulkSelection, id)) { + acc[id] = true; + } + return acc; + }, {}); + }, [bulkSelection, getRowId, rows]); + + const selectedCount = useMemo( + () => countSelectedRows(bulkSelection, totalRowCount), + [bulkSelection, totalRowCount] + ); + + const hasSelectedRows = selectedCount > 0; + + const selectionSummary = + bulkSelection.mode === "all" + ? `All ${selectedCount} selected` + : bulkSelection.mode === "allExcept" + ? `All selected except ${bulkSelection.excludedIds.size}` + : `${selectedCount} selected`; + + const onRowSelectionChange = useCallback>( + (updater: Updater) => { + const nextRowSelection = + typeof updater === "function" ? updater(rowSelection) : updater; + + setBulkSelection((previous) => { + let nextState = previous; + + rows.forEach((row) => { + const id = getRowId(row); + if (!id) { + return; + } + + const wasSelected = isIdSelected(nextState, id); + const isSelectedNow = !!nextRowSelection[id]; + + if (wasSelected === isSelectedNow) { + return; + } + + nextState = isSelectedNow + ? selectId(nextState, id) + : deselectId(nextState, id); + }); + + return nextState; + }); + }, + [getRowId, rowSelection, rows] + ); + + const onSelectAllChange = useCallback( + (checked: boolean) => { + if (checked) { + setBulkSelection({ + mode: "all", + selectionScope + }); + return; + } + + setBulkSelection(emptyBulkSelection()); + }, + [selectionScope] + ); + + const clearSelection = useCallback(() => { + setBulkSelection(emptyBulkSelection()); + }, []); + + const buildPayload = useCallback((): BulkSelectionPayload => { + if (bulkSelection.mode === "selective") { + return { + mode: "selective", + ids: Array.from(bulkSelection.includedIds) + }; + } + + if (bulkSelection.mode === "all") { + return { + mode: "all", + ids: [], + selectionScope: bulkSelection.selectionScope + }; + } + + return { + mode: "allExcept", + ids: Array.from(bulkSelection.excludedIds), + selectionScope: bulkSelection.selectionScope + }; + }, [bulkSelection]); + + return { + bulkSelection, + rowSelection, + selectedCount, + hasSelectedRows, + selectionSummary, + onRowSelectionChange, + onSelectAllChange, + clearSelection, + buildPayload + }; +} diff --git a/src/ui/MRTDataTable/MRTDataTable.tsx b/src/ui/MRTDataTable/MRTDataTable.tsx index 3d5d3c256e..0a1aec6fe3 100644 --- a/src/ui/MRTDataTable/MRTDataTable.tsx +++ b/src/ui/MRTDataTable/MRTDataTable.tsx @@ -1,7 +1,9 @@ import { memo, useCallback, useMemo } from "react"; +import type { MouseEvent } from "react"; import { GroupingState, OnChangeFn, + RowSelectionState, SortingState, VisibilityState } from "@tanstack/react-table"; @@ -17,6 +19,14 @@ import { import useReactTablePaginationState from "../DataTable/Hooks/useReactTablePaginationState"; import useReactTableSortState from "../DataTable/Hooks/useReactTableSortState"; +const defaultMantineSelectCheckboxProps = { + styles: { + icon: { + display: "none" + } + } +}; + type MRTDataTableProps = {}> = { data: T[]; enableExpanding?: boolean; @@ -44,11 +54,26 @@ type MRTDataTableProps = {}> = { enableGrouping?: boolean; onGroupingChange?: OnChangeFn; disableHiding?: boolean; + + // ////////////////// + // Row Selection stuff (https://www.mantine-react-table.com/docs/guides/row-selection#enable-row-selection) + // ////////////////// + enableRowSelection?: boolean; + enableSelectAll?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: OnChangeFn; + /** to derive a unique ID for any given row */ + getRowId?: MRT_TableOptions["getRowId"]; + mantineSelectCheckboxProps?: MRT_TableOptions["mantineSelectCheckboxProps"]; + mantineSelectAllCheckboxProps?: MRT_TableOptions["mantineSelectAllCheckboxProps"]; + // --- End Row Selection --- + mantineTableBodyRowProps?: { style?: Record; }; displayColumnDefOptions?: { "mrt-row-expand"?: Partial>; + "mrt-row-select"?: Partial>; }; /** Prefix to namespace URL search params for sorting/pagination */ urlParamPrefix?: string; @@ -78,6 +103,13 @@ function MRTDataTableInner = {}>({ enableExpanding = false, onGroupingChange = () => {}, disableHiding = false, + enableRowSelection = false, + enableSelectAll = false, + rowSelection, + onRowSelectionChange, + getRowId, + mantineSelectCheckboxProps, + mantineSelectAllCheckboxProps, mantineTableBodyRowProps, displayColumnDefOptions, urlParamPrefix, @@ -128,6 +160,11 @@ function MRTDataTableInner = {}>({ "mrt-row-expand": { size: 100, ...displayColumnDefOptions?.["mrt-row-expand"] + }, + "mrt-row-select": { + size: 40, + maxSize: 40, + ...displayColumnDefOptions?.["mrt-row-select"] } }), [displayColumnDefOptions] @@ -135,7 +172,13 @@ function MRTDataTableInner = {}>({ const tableBodyRowProps = useCallback( ({ row }: { row: MRT_Row }) => ({ - onClick: () => onRowClick(row.original), + onClick: (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (target.closest("a,button,input,[role='checkbox']")) { + return; + } + onRowClick(row.original); + }, sx: { cursor: "pointer", maxHeight: "100%", overflowY: "auto" }, ...mantineTableBodyRowProps }), @@ -151,7 +194,6 @@ function MRTDataTableInner = {}>({ enableFilters: false, enableHiding: !disableHiding, enableExpanding, - enableSelectAll: false, enableFullScreenToggle: false, layoutMode: "grid", enableTopToolbar: false, @@ -210,7 +252,8 @@ function MRTDataTableInner = {}>({ pageSize }, sorting: sortState, - grouping: groupBy + grouping: groupBy, + rowSelection: rowSelection ?? {} }, initialState: { ...initialState, @@ -221,13 +264,31 @@ function MRTDataTableInner = {}>({ }, mantineExpandButtonProps: { size: "xs" as const }, mantineExpandAllButtonProps: { size: "xs" as const }, - renderDetailPanel + renderDetailPanel, + + ////////////////////////////// + // Row Selection stuff + ////////////////////////////// + selectDisplayMode: "checkbox", + getRowId, + enableSelectAll, + enableRowSelection, + enableMultiRowSelection: enableRowSelection, + mantineSelectCheckboxProps: + mantineSelectCheckboxProps ?? defaultMantineSelectCheckboxProps, + mantineSelectAllCheckboxProps: + mantineSelectAllCheckboxProps ?? defaultMantineSelectCheckboxProps, + onRowSelectionChange: enableRowSelection + ? onRowSelectionChange + : undefined }) as MRT_TableOptions, [ data, columns, disableHiding, enableExpanding, + enableRowSelection, + enableSelectAll, enableColumnActions, enableServerSideSorting, enableServerSidePagination, @@ -236,6 +297,7 @@ function MRTDataTableInner = {}>({ setPageIndex, setSortState, onGroupingChange, + onRowSelectionChange, enableGrouping, mergedDisplayColumnDefOptions, tableBodyRowProps, @@ -244,10 +306,14 @@ function MRTDataTableInner = {}>({ pageSize, sortState, groupBy, + rowSelection, isRefetching, columnVisibility, initialState, rowsPerPageOptions, + getRowId, + mantineSelectCheckboxProps, + mantineSelectAllCheckboxProps, renderDetailPanel ] );