UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

133 lines (132 loc) 8.79 kB
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; /* * Copyright 2025 The Kubernetes Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Icon } from '@iconify/react'; import { alpha, Badge, Box, Button, Checkbox, Popover, Stack, styled, TextField, Typography, } from '@mui/material'; import Fuse from 'fuse.js'; import { t } from 'i18next'; import { groupBy, xor } from 'lodash'; import { memo, useMemo, useState } from 'react'; import { Trans } from 'react-i18next'; import { apiResourceId } from '../../lib/k8s/api/v2/ApiResource'; import { KubeIcon } from '../resourceMap/kubeIcon/KubeIcon'; const Node = styled('div')(() => ({ display: 'flex', flexDirection: 'column', })); const NodeHeader = styled('div')(({ theme }) => ({ display: 'flex', flexDirection: 'row', alignItems: 'center', borderRadius: theme.spacing(1), paddingLeft: theme.spacing(0.5), paddingRight: theme.spacing(0.5), userSelect: 'none', ':hover': { background: theme.palette.action.hover, }, ':active': { background: alpha(theme.palette.action.active, theme.palette.action.activatedOpacity), }, })); const LabelAndCheckbox = ({ label, checked, indeterminate, showIcon, onClick, }) => (_jsxs(_Fragment, { children: [_jsx(Box, { mr: 1, display: "flex", children: showIcon && (_jsx(Badge, { badgeContent: 0, overlap: "circular", children: _jsx(KubeIcon, { kind: label, width: "20px", height: "20px" }) })) }), _jsx(Typography, { variant: "subtitle2", children: label }), _jsx(Checkbox, { sx: () => ({ marginLeft: 'auto', padding: 0.75 }), checked: checked, indeterminate: indeterminate, size: "small", onClick: e => { e.stopPropagation(); onClick(); }, onKeyDown: e => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onClick(); } } })] })); function ApiResourceGroup({ name, resources, selection, activeItemId, setActiveItemId, setSelectedResources, }) { const resourcesInThisGroup = resources.filter(it => name === 'core' ? it.groupName === undefined : it.groupName === name); const isActive = name === activeItemId; const checked = resourcesInThisGroup.every(resource => selection?.has(apiResourceId(resource))); const indeterminate = !checked && resourcesInThisGroup.some(resource => selection?.has(apiResourceId(resource))); return (_jsxs(Node, { children: [_jsxs(NodeHeader, { role: "button", tabIndex: 0, onClick: () => setActiveItemId(isActive ? undefined : name), onKeyDown: e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setActiveItemId(isActive ? undefined : name); } }, children: [_jsx(Icon, { icon: isActive ? 'mdi:chevron-down' : 'mdi:chevron-right', width: 18, height: 18, style: { flexShrink: 0 } }), _jsx(LabelAndCheckbox, { showIcon: false, label: name, checked: checked, indeterminate: indeterminate, onClick: () => { const newSet = new Set(selection); if (checked) { resourcesInThisGroup.forEach(resource => { newSet.delete(apiResourceId(resource)); }); } else { resourcesInThisGroup.forEach(resource => { newSet.add(apiResourceId(resource)); }); } setSelectedResources(newSet); } })] }), _jsx(Stack, { ml: 3, children: isActive && resources.map(resource => { const id = apiResourceId(resource); const toggle = () => { setSelectedResources(new Set(xor([...(selection ?? [])], [id]))); }; return (_jsx(Node, { onClick: toggle, children: _jsx(NodeHeader, { children: _jsx(LabelAndCheckbox, { showIcon: true, label: resource.kind, checked: selection?.has(id), indeterminate: false, onClick: toggle }) }) }, resource.pluralName)); }) })] })); } /** * A custom hook that provides fuzzy search functionality using Fuse.js. */ function useFuseSearch(items, options, query) { const fuse = useMemo(() => new Fuse(items, options), [items, options]); return useMemo(() => (query.trim() === '' ? items : fuse.search(query).map(it => it.item)), // eslint-disable-next-line react-hooks/exhaustive-deps [fuse, query]); } /** * A component that renders a dropdown button which opens a popover with searchable resource selection UI. */ export const ApiResourcesView = memo(({ resources, selectedResources: selectedSources, setSelectedResources, }) => { const [query, setQuery] = useState(''); const [anchorEl, setAnchorEl] = useState(null); const [activeItemId, setActiveItemId] = useState(undefined); const matchingResources = useFuseSearch(resources, useMemo(() => ({ keys: ['kind', 'groupName'], threshold: 0.3 }), []), query); const groups = useMemo(() => groupBy(matchingResources, it => it.groupName), // eslint-disable-next-line react-hooks/exhaustive-deps [matchingResources, query]); const size = selectedSources?.size ?? 0; return (_jsxs(_Fragment, { children: [_jsx(Badge, { badgeContent: size, color: "primary", anchorOrigin: { horizontal: 'right', vertical: 'top', }, slotProps: { badge: { style: { top: '4px', right: '4px', }, }, }, children: _jsx(Button, { variant: "contained", color: "secondary", startIcon: _jsx(Icon, { icon: "mdi:filter" }), onClick: e => setAnchorEl(e.currentTarget), children: _jsx(Trans, { children: "Select Resources" }) }) }), _jsx(Popover, { elevation: 4, anchorOrigin: { vertical: 'bottom', horizontal: 'left', }, onClose: () => setAnchorEl(null), anchorEl: anchorEl, open: Boolean(anchorEl), children: _jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', width: 'fit-content', minWidth: '320px', padding: 1.5, gap: 1, }, children: [_jsx(TextField, { placeholder: t('Search'), label: t('Search Resources'), variant: "outlined", size: "small", value: query, onChange: e => setQuery(e.target.value) }), _jsxs(Box, { sx: { display: 'flex', width: '100%', gap: 1 }, children: [_jsx(Button, { startIcon: _jsx(Icon, { icon: "mdi:playlist-check" }), size: "small", variant: "contained", color: "secondary", sx: { flex: '1 1 0' }, onClick: () => setSelectedResources(new Set(matchingResources.map(resource => apiResourceId(resource)))), children: _jsx(Trans, { children: "Select All" }) }), _jsx(Button, { startIcon: _jsx(Icon, { icon: "mdi:playlist-remove" }), size: "small", variant: "contained", color: "secondary", sx: { flex: '1 1 0' }, onClick: () => setSelectedResources(new Set()), children: _jsx(Trans, { children: "Clear Selection" }) })] }), _jsx(Box, { sx: { maxHeight: '60vh', overflowY: 'auto' }, children: _jsx(GroupList, { groups: groups, selectedSources: selectedSources, setSelectedResources: setSelectedResources, activeItemId: activeItemId, setActiveItemId: setActiveItemId }) })] }) })] })); }); const GroupList = memo(function ({ groups, selectedSources, setSelectedResources, activeItemId, setActiveItemId, }) { return Object.entries(groups).map(([groupName, resources]) => (_jsx(ApiResourceGroup, { name: groupName === 'undefined' ? 'core' : groupName, resources: resources, selection: selectedSources, setSelectedResources: setSelectedResources, activeItemId: activeItemId, setActiveItemId: id => setActiveItemId(id) }, groupName))); });