@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
133 lines (132 loc) • 8.79 kB
JavaScript
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)));
});