import {
useMemo,
useCallback,
useState,
createElement,
useContext
} from 'react'
import PropTypes from 'prop-types'
import { Table, Flex, Tooltip, Button } from 'antd'
import { useMediaQuery } from 'react-responsive'
import { ElectronContext } from '../context/ElectronContext'
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)'
const getPermissionState = (permissions, modelName, actionName) => {
const value = permissions?.[modelName]?.[actionName]
if (value === true) return true
if (value === false) return false
return null
}
const setPermissionState = (permissions, modelName, actionName, next) => {
const current =
permissions && typeof permissions === 'object' ? permissions : {}
const modelPermissions = {
...(current[modelName] && typeof current[modelName] === 'object'
? current[modelName]
: {})
}
if (next === null) {
delete modelPermissions[actionName]
} else {
modelPermissions[actionName] = next
}
const nextPermissions = { ...current }
if (Object.keys(modelPermissions).length === 0) {
delete nextPermissions[modelName]
} else {
nextPermissions[modelName] = modelPermissions
}
return nextPermissions
}
const cyclePermissionState = (current, inherit) => {
if (!inherit) {
return current === true ? false : true
}
if (current === true) return false
if (current === false) return null
return true
}
const normalizePermissionState = (state, inherit) => {
if (!inherit && state === null) return false
return state
}
const getAggregatePermissionState = (states) => {
if (states.length === 0) return null
const first = states[0]
for (let i = 1; i < states.length; i++) {
if (states[i] !== first) return 'mixed'
}
return first
}
const getRowPermissionStates = (permissions, model, actions, inherit) =>
actions
.filter((action) => modelHasPermissionAction(model, action.name))
.map((action) =>
normalizePermissionState(
getPermissionState(permissions, model.name, action.name),
inherit
)
)
const getColumnPermissionStates = (permissions, models, action, inherit) =>
models
.filter((model) => modelHasPermissionAction(model, action.name))
.map((model) =>
normalizePermissionState(
getPermissionState(permissions, model.name, action.name),
inherit
)
)
const setRowPermissionState = (permissions, model, actions, next) => {
let nextPermissions = permissions
actions.forEach((action) => {
if (modelHasPermissionAction(model, action.name)) {
nextPermissions = setPermissionState(
nextPermissions,
model.name,
action.name,
next
)
}
})
return nextPermissions
}
const setColumnPermissionState = (permissions, models, action, next) => {
let nextPermissions = permissions
models.forEach((model) => {
if (modelHasPermissionAction(model, action.name)) {
nextPermissions = setPermissionState(
nextPermissions,
model.name,
action.name,
next
)
}
})
return nextPermissions
}
const getCheckboxTitle = (state, inherit) => {
if (state === 'mixed') return 'Mixed'
if (inherit && state === null) return 'Inherit'
if (state === true) return 'Allow'
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,
disabled = false,
inherit = true,
scrollHeight = DEFAULT_SCROLL_HEIGHT,
size = 'middle'
}) => {
const { isElectron } = useContext(ElectronContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
const models = useMemo(() => getPermissionMatrixModels(), [])
const actions = useMemo(() => getPermissionMatrixActions(), [])
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) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 298px)'
}
if (isElectron) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 238px)'
}
if (isMobile && isElectron) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 282px)'
}
const handleCycle = useCallback(
(modelName, actionName) => {
if (disabled || typeof onChange !== 'function') return
const current = getPermissionState(permissions, modelName, actionName)
const next = cyclePermissionState(current, inherit)
onChange(setPermissionState(permissions, modelName, actionName, next))
},
[disabled, inherit, onChange, permissions]
)
const handleCycleModels = useCallback(
(recordModels) => {
if (
disabled ||
typeof onChange !== 'function' ||
recordModels.length === 0
) {
return
}
const aggregate = getAggregatePermissionState(
getModelsRowPermissionStates(
permissions,
recordModels,
actions,
inherit
)
)
const next = cyclePermissionState(
aggregate === 'mixed' ? null : aggregate,
inherit
)
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
const aggregate = getAggregatePermissionState(
getColumnPermissionStates(permissions, models, action, inherit)
)
const next = cyclePermissionState(
aggregate === 'mixed' ? null : aggregate,
inherit
)
onChange(setColumnPermissionState(permissions, models, action, next))
},
[disabled, inherit, models, onChange, permissions]
)
const renderCheckbox = useCallback(
(state, onCycle) => {
const mixed = state === 'mixed'
const checked = state === true
const indeterminate = inherit && state === null
return (