From f07b0d5eef6d12a844934789b2ee2ca4841a3eea Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Thu, 20 Aug 2026 17:20:22 +0100 Subject: [PATCH] Enhance PermissionsMatrix with sidebar integration and state management - Added utility functions for normalizing sidebar paths and collecting metadata from sidebar items. - Implemented logic to match models with sidebar sections, improving the organization of permissions. - Enhanced the data source construction for the PermissionsMatrix, allowing for better grouping and display of models. - Introduced state management for expanded rows and hovered columns to improve user interaction. - Refactored existing functionality to accommodate new features while maintaining performance and clarity. --- .../Dashboard/common/PermissionsMatrix.jsx | 404 ++++++++++++++++-- 1 file changed, 359 insertions(+), 45 deletions(-) diff --git a/src/components/Dashboard/common/PermissionsMatrix.jsx b/src/components/Dashboard/common/PermissionsMatrix.jsx index 5337de58..29fcfa10 100644 --- a/src/components/Dashboard/common/PermissionsMatrix.jsx +++ b/src/components/Dashboard/common/PermissionsMatrix.jsx @@ -1,15 +1,23 @@ -import { useMemo, useCallback, createElement } from 'react' +import { + useMemo, + useCallback, + useState, + createElement, + useContext +} from 'react' import PropTypes from 'prop-types' -import { Table, Flex, Tooltip } from 'antd' +import { Table, Flex, Tooltip, Button } from 'antd' import { useMediaQuery } from 'react-responsive' import { ElectronContext } from '../context/ElectronContext' -import { useContext } from 'react' import { getPermissionMatrixModels, getPermissionMatrixActions, modelHasPermissionAction } from '../../../database/ObjectModels' +import { getSidebar, getSidebarMenuSections } from '../../../database/Sidebars' +import { getSidebarIconNode } from '../../Icons/sidebarIconMap' import PermissionCheckbox from './PermissionCheckbox' +import { CaretRightOutlined } from '@ant-design/icons' const DEFAULT_SCROLL_HEIGHT = 'calc(var(--unit-100vh) - 258px)' @@ -124,6 +132,167 @@ const getCheckboxTitle = (state, inherit) => { return 'Deny' } +const normalizePath = (path) => { + if (!path || typeof path !== 'string') return '' + return path.split('?')[0].replace(/\/+$/, '') +} + +const collectSidebarMeta = (items, acc = { paths: [], iconKeys: [] }) => { + items.forEach((item) => { + if (item?.type === 'divider') return + if (item.path) acc.paths.push(normalizePath(item.path)) + if (item.iconKey) acc.iconKeys.push(item.iconKey) + if (item.children?.length) collectSidebarMeta(item.children, acc) + }) + return acc +} + +const getModelPaths = (model) => { + const paths = [] + const addPath = (url) => { + if (typeof url === 'string') { + paths.push(normalizePath(url)) + return + } + if (typeof url === 'function') { + try { + const sample = url('') + if (typeof sample === 'string') { + paths.push(normalizePath(sample.split('?')[0])) + } + } catch { + // ignore uncallable url builders + } + } + } + + addPath(model?.url) + ;(model?.actions || []).forEach((action) => addPath(action?.url)) + return [...new Set(paths.filter(Boolean))] +} + +const pathMatches = (modelPath, sidebarPath) => + modelPath === sidebarPath || modelPath.startsWith(`${sidebarPath}/`) + +const getModelSectionOrder = (model, section) => { + const modelPaths = getModelPaths(model) + const pathIndex = section.paths.findIndex((sidebarPath) => + modelPaths.some((modelPath) => pathMatches(modelPath, sidebarPath)) + ) + if (pathIndex !== -1) return pathIndex + const iconIndex = section.iconKeys.findIndex( + (iconKey) => iconKey === model.name + ) + if (iconIndex !== -1) return iconIndex + return Number.MAX_SAFE_INTEGER +} + +const modelMatchesSection = (model, section) => { + const modelPaths = getModelPaths(model) + if ( + modelPaths.some((modelPath) => + section.paths.some((sidebarPath) => pathMatches(modelPath, sidebarPath)) + ) + ) { + return true + } + if (section.iconKeys.includes(model.name)) return true + const prefix = `/dashboard/${section.key}` + return modelPaths.some((modelPath) => pathMatches(modelPath, prefix)) +} + +const getRecordModels = (record) => { + if (record?.isGroup) { + return (record.children || []).map((child) => child.model).filter(Boolean) + } + return record?.model ? [record.model] : [] +} + +const getModelsRowPermissionStates = (permissions, models, actions, inherit) => + models.flatMap((model) => + getRowPermissionStates(permissions, model, actions, inherit) + ) + +const setModelsRowPermissionState = (permissions, models, actions, next) => { + let nextPermissions = permissions + models.forEach((model) => { + nextPermissions = setRowPermissionState( + nextPermissions, + model, + actions, + next + ) + }) + return nextPermissions +} + +const buildPermissionMatrixDataSource = (models) => { + const assigned = new Set() + const sections = getSidebarMenuSections({ includeDev: false }).map( + (section) => { + const sidebar = getSidebar(section.key) + const meta = collectSidebarMeta(section.items || []) + Object.keys(sidebar?.routeAliases || {}).forEach((path) => { + meta.paths.push(normalizePath(path)) + }) + return { + key: section.key, + label: section.label, + iconKey: section.iconKey, + paths: meta.paths, + iconKeys: meta.iconKeys + } + } + ) + + const dataSource = [] + sections.forEach((section) => { + const sectionModels = models + .filter( + (model) => + !assigned.has(model.name) && modelMatchesSection(model, section) + ) + .sort( + (a, b) => + getModelSectionOrder(a, section) - getModelSectionOrder(b, section) + ) + + sectionModels.forEach((model) => assigned.add(model.name)) + if (sectionModels.length === 0) return + + dataSource.push({ + key: `section-${section.key}`, + isGroup: true, + label: section.label, + iconNode: getSidebarIconNode(section.iconKey), + children: sectionModels.map((model) => ({ + key: model.name, + label: model.label, + icon: model.icon, + model + })) + }) + }) + + const leftover = models.filter((model) => !assigned.has(model.name)) + if (leftover.length > 0) { + dataSource.push({ + key: 'section-other', + isGroup: true, + label: 'Other', + iconNode: null, + children: leftover.map((model) => ({ + key: model.name, + label: model.label, + icon: model.icon, + model + })) + }) + } + + return dataSource +} + const PermissionsMatrix = ({ value, onChange, @@ -136,7 +305,50 @@ const PermissionsMatrix = ({ const isMobile = useMediaQuery({ maxWidth: 768 }) const models = useMemo(() => getPermissionMatrixModels(), []) const actions = useMemo(() => getPermissionMatrixActions(), []) - const permissions = value && typeof value === 'object' ? value : {} + const permissions = useMemo( + () => (value && typeof value === 'object' ? value : {}), + [value] + ) + const dataSource = useMemo( + () => buildPermissionMatrixDataSource(models), + [models] + ) + const [expandedRowKeys, setExpandedRowKeys] = useState(null) + const [hoveredColumnKey, setHoveredColumnKey] = useState(null) + + const resolvedExpandedRowKeys = useMemo( + () => expandedRowKeys ?? dataSource.map((row) => row.key), + [dataSource, expandedRowKeys] + ) + + const isSectionExpanded = useCallback( + (record) => resolvedExpandedRowKeys.includes(record.key), + [resolvedExpandedRowKeys] + ) + + const handleToggleSection = useCallback( + (record) => { + if (!record.isGroup) return + setExpandedRowKeys((current) => { + const keys = current ?? dataSource.map((row) => row.key) + return keys.includes(record.key) + ? keys.filter((key) => key !== record.key) + : [...keys, record.key] + }) + }, + [dataSource] + ) + + const getColumnHoverProps = useCallback( + (columnKey, isHeader = false) => ({ + onMouseEnter: () => setHoveredColumnKey(columnKey), + className: + !isHeader && hoveredColumnKey === columnKey + ? 'ant-table-cell-row-hover' + : undefined + }), + [hoveredColumnKey] + ) var adjustedScrollHeight = scrollHeight if (isMobile) { @@ -159,21 +371,53 @@ const PermissionsMatrix = ({ [disabled, inherit, onChange, permissions] ) - const handleCycleRow = useCallback( - (model) => { - if (disabled || typeof onChange !== 'function') return + const handleCycleModels = useCallback( + (recordModels) => { + if ( + disabled || + typeof onChange !== 'function' || + recordModels.length === 0 + ) { + return + } const aggregate = getAggregatePermissionState( - getRowPermissionStates(permissions, model, actions, inherit) + getModelsRowPermissionStates( + permissions, + recordModels, + actions, + inherit + ) ) const next = cyclePermissionState( aggregate === 'mixed' ? null : aggregate, inherit ) - onChange(setRowPermissionState(permissions, model, actions, next)) + onChange( + setModelsRowPermissionState(permissions, recordModels, actions, next) + ) }, [actions, disabled, inherit, onChange, permissions] ) + const handleCycleModelsAction = useCallback( + (recordModels, action) => { + if (disabled || typeof onChange !== 'function') return + const applicable = recordModels.filter((model) => + modelHasPermissionAction(model, action.name) + ) + if (applicable.length === 0) return + const aggregate = getAggregatePermissionState( + getColumnPermissionStates(permissions, applicable, action, inherit) + ) + const next = cyclePermissionState( + aggregate === 'mixed' ? null : aggregate, + inherit + ) + onChange(setColumnPermissionState(permissions, applicable, action, next)) + }, + [disabled, inherit, onChange, permissions] + ) + const handleCycleColumn = useCallback( (action) => { if (disabled || typeof onChange !== 'function') return @@ -218,10 +462,18 @@ const PermissionsMatrix = ({ key: 'model', dataIndex: 'label', fixed: isMobile ? undefined : 'left', - width: 248, + width: 280, + onHeaderCell: () => getColumnHoverProps('model', true), + onCell: () => getColumnHoverProps('model'), render: (label, record) => { + const recordModels = getRecordModels(record) const aggregate = getAggregatePermissionState( - getRowPermissionStates(permissions, record.model, actions, inherit) + getModelsRowPermissionStates( + permissions, + recordModels, + actions, + inherit + ) ) return ( @@ -231,13 +483,52 @@ const PermissionsMatrix = ({ justify='space-between' style={{ width: '100%', paddingRight: '10px' }} > - - {record.icon ? createElement(record.icon) : null} + + {record.isGroup ? ( +