UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

195 lines (194 loc) 9.74 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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 Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import _ from 'lodash'; import { isValidElement, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useHistory, useParams } from 'react-router-dom'; import { isElectron } from '../../../helpers/isElectron'; import { ConfigStore } from '../../../plugin/configStore'; import { useTypedSelector } from '../../../redux/hooks'; import NotFoundComponent from '../../404'; import { SectionHeader } from '../../common'; import ActionButton from '../../common/ActionButton'; import { ConfirmDialog } from '../../common/Dialog'; import ErrorBoundary from '../../common/ErrorBoundary'; import { SectionBox } from '../../common/SectionBox'; import { usePluginDelete } from './usePluginDelete'; // Helper function to open plugin folder in file explorer (Electron only) function openPluginFolder(plugin) { if (!isElectron()) { return; } const folderName = plugin.folderName || plugin.name.split('/').pop(); if (!folderName || !plugin.type) { return; } const { desktopApi } = window; if (desktopApi?.send) { desktopApi.send('open-plugin-folder', { folderName, type: plugin.type, }); } } // Helper to check if we can open the plugin folder function canOpenPluginFolder(plugin) { if (!isElectron()) { return false; } const folderName = plugin.folderName || plugin.name.split('/').pop(); return !!(folderName && plugin.type); } const PluginSettingsDetailsInitializer = (props) => { const { plugin } = props; const store = new ConfigStore(plugin.name); const pluginConf = store.useConfig(); const config = pluginConf(); const deletePluginAction = usePluginDelete(); function handleSave(data) { store.set(data); } function handleDeleteConfirm() { // The hook handles the snackbar, navigation, and reload. The promise rejects // on failure but we ignore it here since the user stays on the detail page. deletePluginAction(plugin).catch(() => { }); } return (_jsx(PluginSettingsDetailsPure, { config: config, plugin: plugin, onSave: handleSave, onDelete: handleDeleteConfirm })); }; export default function PluginSettingsDetails() { const pluginSettings = useTypedSelector(state => state.plugins.pluginSettings); const { name, type } = useParams(); const plugin = useMemo(() => { const decodedName = decodeURIComponent(name); const decodedType = type ? decodeURIComponent(type) : undefined; // If type is specified, find exact match by name and type if (decodedType) { return pluginSettings.find(plugin => plugin.name === decodedName && (plugin.type || 'shipped') === decodedType); } // Otherwise, find by name only (backwards compatibility) return pluginSettings.find(plugin => plugin.name === decodedName); }, [pluginSettings, name, type]); if (!plugin) { return _jsx(NotFoundComponent, {}); } return _jsx(PluginSettingsDetailsInitializer, { plugin: plugin }); } const ScrollableBox = (props) => (_jsx(Box, { sx: { overflowY: 'scroll', msOverflowStyle: 'none', scrollbarWidth: 'none', '&::-webkit-scrollbar': { display: 'none', }, }, ...props })); export function PluginSettingsDetailsPure(props) { const { config, plugin, onSave, onDelete } = props; const { t } = useTranslation(['translation']); const [data, setData] = useState(config); const [enableSaveButton, setEnableSaveButton] = useState(false); const [openDeleteDialog, setOpenDeleteDialog] = useState(false); const history = useHistory(); const [author, name] = plugin.name.includes('@') ? plugin.name.substring(1).split(/\/(.+)/) : [null, plugin.name]; useEffect(() => { if (!_.isEqual(config, data)) { // eslint-disable-next-line react-hooks/set-state-in-effect setEnableSaveButton(true); } else { setEnableSaveButton(false); } }, [data, config]); function onDataChange(data) { setData(data); } async function handleSave() { if (onSave && data) { await onSave(data); history.push('/settings/plugins'); } } function handleDelete() { setOpenDeleteDialog(true); } function handleDeleteConfirm() { onDelete(); } async function handleCancel() { await setData(config); history.push('/settings/plugins'); } let component; // Only show settings component if this plugin is actually loaded if (plugin.isLoaded !== false) { if (isValidElement(plugin.settingsComponent)) { component = plugin.settingsComponent; } else if (typeof plugin.settingsComponent === 'function') { const Comp = plugin.settingsComponent; if (plugin.displaySettingsComponentWithSaveButton) { component = _jsx(Comp, { onDataChange: onDataChange, data: data }); } else { component = _jsx(Comp, {}); } } else { component = null; } } else { component = null; } return (_jsxs(_Fragment, { children: [_jsxs(SectionBox, { "aria-live": "polite", title: _jsx(SectionHeader, { title: name, titleSideActions: [ plugin.type && (_jsx(Chip, { label: plugin.type === 'development' ? t('translation|Development') : plugin.type === 'user' ? t('translation|User-installed') : t('translation|Shipped'), size: "small", color: plugin.type === 'development' ? 'primary' : plugin.type === 'user' ? 'info' : 'default' })), plugin.isLoaded === false && plugin.overriddenBy && (_jsx(Chip, { label: t('translation|Not Loaded'), size: "small", color: "warning", variant: "outlined" })), ], actions: isElectron() ? [ ...(canOpenPluginFolder(plugin) ? [ _jsx(ActionButton, { description: t('translation|Open Plugin Folder'), icon: "mdi:folder-open", onClick: () => openPluginFolder(plugin) }), ] : []), ...(plugin.type !== 'shipped' ? [ _jsx(ActionButton, { description: t('translation|Delete Plugin'), icon: "mdi:delete", onClick: handleDelete, color: "error" }), ] : []), ] : [], subtitle: author ? `${t('translation|By')}: ${author}` : undefined, noPadding: false, headerStyle: "subsection" }), backLink: '/settings/plugins', children: [plugin.description, plugin.isLoaded === false && plugin.overriddenBy && (_jsx(Box, { mt: 2, p: 2, sx: { bgcolor: 'warning.light', borderRadius: 1 }, children: _jsx(Typography, { variant: "body2", children: t('translation|This plugin is not currently loaded because a "{{type}}" version is being used instead.', { type: plugin.overriddenBy === 'development' ? t('translation|development') : plugin.overriddenBy === 'user' ? t('translation|user-installed') : t('translation|shipped'), }) }) })), _jsxs(ScrollableBox, { style: { height: '70vh' }, py: 0, children: [_jsx(ConfirmDialog, { open: openDeleteDialog, title: t('translation|Delete Plugin'), description: t('translation|Are you sure you want to delete this plugin?'), handleClose: () => setOpenDeleteDialog(false), onConfirm: () => handleDeleteConfirm() }), _jsx(ErrorBoundary, { children: component })] })] }), plugin.isLoaded !== false && plugin.displaySettingsComponentWithSaveButton && (_jsx(Box, { py: 0, children: _jsxs(Stack, { direction: "row", spacing: 2, justifyContent: "flex-start", alignItems: "center", sx: { borderTop: '2px solid', borderColor: 'silver', padding: '10px' }, children: [_jsx(Button, { variant: "contained", disabled: !enableSaveButton, style: { backgroundColor: 'silver', color: 'black' }, onClick: handleSave, children: t('translation|Save') }), _jsx(Button, { style: { color: 'silver' }, onClick: handleCancel, children: t('translation|Cancel') })] }) }))] })); }