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