refactor(plugins): introduce ToggleEnabledSwitch for managing plugin enable/disable state

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-28 14:01:32 -05:00
parent 5201f8a5eb
commit 505b3c529f
4 changed files with 184 additions and 191 deletions

View File

@ -1,20 +1,15 @@
import React, { useCallback } from 'react'
import React, { useMemo } from 'react'
import {
Datagrid,
TextField,
useUpdate,
useNotify,
useRefresh,
useRecordContext,
useTranslate,
FunctionField,
useResourceContext,
} from 'react-admin'
import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core'
import { MdError } from 'react-icons/md'
import { List, DateField, SimpleList } from '../common'
import ToggleEnabledSwitch from './ToggleEnabledSwitch'
const useStyles = makeStyles((theme) => ({
errorIcon: {
@ -26,84 +21,8 @@ const useStyles = makeStyles((theme) => ({
backgroundColor: theme.palette.error.light,
color: theme.palette.error.contrastText,
},
enabledSwitch: {
'& .MuiSwitch-colorSecondary.Mui-checked': {
color: theme.palette.success?.main || theme.palette.primary.main,
},
'& .MuiSwitch-colorSecondary.Mui-checked + .MuiSwitch-track': {
backgroundColor:
theme.palette.success?.main || theme.palette.primary.main,
},
},
}))
const ToggleEnabledInput = ({ props }) => {
const resource = useResourceContext(props)
const record = useRecordContext(props)
const notify = useNotify()
const refresh = useRefresh()
const translate = useTranslate()
const classes = useStyles()
const [toggleEnabled, { loading }] = useUpdate(
resource,
record.id,
{ enabled: !record.enabled },
record,
{
undoable: false,
onSuccess: () => {
refresh()
notify(
record.enabled
? 'resources.plugin.notifications.disabled'
: 'resources.plugin.notifications.enabled',
'info',
)
},
onFailure: (error) => {
notify(
error?.message || 'resources.plugin.notifications.error',
'warning',
)
},
},
)
const handleClick = useCallback(
(e) => {
e.stopPropagation()
toggleEnabled()
},
[toggleEnabled],
)
const hasError = !!record.lastError
const isDisabled = loading || hasError
return (
<Tooltip
title={translate(
hasError
? 'resources.plugin.actions.disabledDueToError'
: record.enabled
? 'resources.plugin.actions.disable'
: 'resources.plugin.actions.enable',
)}
>
<span>
<Switch
checked={record.enabled}
onClick={handleClick}
disabled={isDisabled}
className={classes.enabledSwitch}
size="small"
/>
</span>
</Tooltip>
)
}
const ErrorIndicator = () => {
const record = useRecordContext()
const translate = useTranslate()
@ -125,19 +44,26 @@ const ErrorIndicator = () => {
)
}
const ManifestField = ({ source }) => {
const useManifest = () => {
const record = useRecordContext()
return useMemo(() => {
if (!record?.manifest) return null
try {
return JSON.parse(record.manifest)
} catch {
return null
}
}, [record?.manifest])
}
if (!record?.manifest) {
return null
const ManifestField = ({ source }) => {
const manifest = useManifest()
if (!manifest) {
return <Typography variant="body2">-</Typography>
}
try {
const manifest = JSON.parse(record.manifest)
return <Typography source>{manifest[source] || '-'}</Typography>
} catch {
return <Typography source>-</Typography>
}
return <Typography variant="body2">{manifest[source] || '-'}</Typography>
}
const PluginList = (props) => {
@ -170,7 +96,7 @@ const PluginList = (props) => {
<ManifestField source="name" />
{!isXsmall && <ManifestField source="description" />}
<ManifestField source="version" />
<ToggleEnabledInput source={'enabled'} />
<ToggleEnabledSwitch source={'enabled'} />
<ErrorIndicator source="lastError" />
<DateField source="updatedAt" sortByOrder={'DESC'} />
</Datagrid>

View File

@ -11,18 +11,17 @@ vi.mock('react-admin', async () => {
useNotify: vi.fn(() => vi.fn()),
useRefresh: vi.fn(() => vi.fn()),
useTranslate: vi.fn(() => (key) => key),
useResourceContext: vi.fn(() => 'plugin'),
useRecordContext: vi.fn(() => ({
id: 'test-plugin',
manifest: JSON.stringify({
name: 'Test Plugin',
version: '1.0.0',
description: 'Test plugin',
}),
enabled: true,
lastError: null,
})),
FunctionField: ({ render, label }) => (
<span data-testid={`field-${label}`}>{render && render()}</span>
),
Datagrid: ({ children }) => (
<table data-testid="datagrid">{children}</table>
),
@ -54,6 +53,11 @@ vi.mock('@material-ui/core', async () => {
}
})
// Mock ToggleEnabledSwitch
vi.mock('./ToggleEnabledSwitch', () => ({
default: () => <span data-testid="toggle-switch" />,
}))
import PluginList from './PluginList'
describe('PluginList', () => {

View File

@ -13,8 +13,6 @@ import {
import {
Typography,
Box,
Switch,
FormControlLabel,
Card,
CardContent,
TextField as MuiTextField,
@ -34,6 +32,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { MdExpandMore, MdSave } from 'react-icons/md'
import { Title, DateField } from '../common'
import { validateJson } from './jsonValidation'
import ToggleEnabledSwitch from './ToggleEnabledSwitch'
const useStyles = makeStyles(
(theme) => ({
@ -215,46 +214,14 @@ const ErrorSection = ({ error, translate }) => {
}
// Status card with enable/disable toggle
const StatusCard = ({
record,
classes,
translate,
onToggle,
loading,
hasError,
}) => {
const isDisabled = loading || hasError
const StatusCard = ({ classes, translate }) => {
return (
<Card className={classes.section}>
<CardContent>
<Typography variant="h6" className={classes.sectionTitle}>
{translate('resources.plugin.sections.status')}
</Typography>
<Tooltip
title={
hasError
? translate('resources.plugin.actions.disabledDueToError')
: ''
}
disableHoverListener={!hasError}
>
<FormControlLabel
control={
<Switch
checked={record.enabled}
onChange={onToggle}
disabled={isDisabled}
color="primary"
/>
}
label={translate(
record.enabled
? 'resources.plugin.actions.disable'
: 'resources.plugin.actions.enable',
)}
/>
</Tooltip>
<ToggleEnabledSwitch showLabel size="medium" />
</CardContent>
</Card>
)
@ -332,53 +299,32 @@ const InfoCard = ({ record, manifest, classes, translate, isSmall }) => (
</InfoRow>
)}
{manifest?.permissions && (
<InfoRow
label={translate('resources.plugin.fields.permissions')}
classes={classes}
isSmall={isSmall}
>
<Box className={classes.permissionsContainer}>
<PermissionChip
label="HTTP"
permission={manifest.permissions.http}
classes={classes}
/>
<PermissionChip
label="Subsonic API"
permission={manifest.permissions.subsonicapi}
classes={classes}
/>
<PermissionChip
label="Scheduler"
permission={manifest.permissions.scheduler}
classes={classes}
/>
<PermissionChip
label="WebSocket"
permission={manifest.permissions.websocket}
classes={classes}
/>
<PermissionChip
label="Artwork"
permission={manifest.permissions.artwork}
classes={classes}
/>
<PermissionChip
label="Cache"
permission={manifest.permissions.cache}
classes={classes}
/>
</Box>
<Typography
variant="caption"
color="textSecondary"
style={{ marginTop: 4, display: 'block' }}
{manifest?.permissions &&
Object.keys(manifest.permissions).length > 0 && (
<InfoRow
label={translate('resources.plugin.fields.permissions')}
classes={classes}
isSmall={isSmall}
>
{translate('resources.plugin.messages.clickPermissions')}
</Typography>
</InfoRow>
)}
<Box className={classes.permissionsContainer}>
{Object.entries(manifest.permissions).map(([key, value]) => (
<PermissionChip
key={key}
label={key}
permission={value}
classes={classes}
/>
))}
</Box>
<Typography
variant="caption"
color="textSecondary"
style={{ marginTop: 4, display: 'block' }}
>
{translate('resources.plugin.messages.clickPermissions')}
</Typography>
</InfoRow>
)}
<InfoRow
label={translate('resources.plugin.fields.path')}
@ -515,11 +461,6 @@ const PluginShowLayout = () => {
},
)
const handleToggleEnabled = useCallback(() => {
if (!record) return
updatePlugin('plugin', record.id, { enabled: !record.enabled }, record)
}, [updatePlugin, record])
const handleConfigChange = useCallback(
(e) => {
const value = e.target.value
@ -584,14 +525,7 @@ const PluginShowLayout = () => {
<Box className={classes.root}>
<ErrorSection error={record.lastError} translate={translate} />
<StatusCard
record={record}
classes={classes}
translate={translate}
onToggle={handleToggleEnabled}
loading={loading}
hasError={!!record.lastError}
/>
<StatusCard classes={classes} translate={translate} />
<InfoCard
record={record}

View File

@ -0,0 +1,129 @@
import React, { useCallback, useMemo } from 'react'
import {
useUpdate,
useNotify,
useRefresh,
useRecordContext,
useTranslate,
useResourceContext,
} from 'react-admin'
import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles'
import { Tooltip, FormControlLabel } from '@material-ui/core'
const useStyles = makeStyles((theme) => ({
enabledSwitch: {
'& .MuiSwitch-colorSecondary.Mui-checked': {
color: theme.palette.success?.main || theme.palette.primary.main,
},
'& .MuiSwitch-colorSecondary.Mui-checked + .MuiSwitch-track': {
backgroundColor:
theme.palette.success?.main || theme.palette.primary.main,
},
},
}))
/**
* Shared toggle switch for enabling/disabling plugins.
* Used in both PluginList (compact) and PluginShow (with label).
*
* @param {Object} props
* @param {boolean} [props.showLabel=false] - Whether to show the enable/disable label
* @param {string} [props.size='small'] - Switch size ('small' or 'medium')
*/
const ToggleEnabledSwitch = ({ showLabel = false, size = 'small' }) => {
const resource = useResourceContext()
const record = useRecordContext()
const notify = useNotify()
const refresh = useRefresh()
const translate = useTranslate()
const classes = useStyles()
const [toggleEnabled, { loading }] = useUpdate(
resource,
record?.id,
{ enabled: !record?.enabled },
record,
{
undoable: false,
onSuccess: () => {
refresh()
notify(
record?.enabled
? 'resources.plugin.notifications.disabled'
: 'resources.plugin.notifications.enabled',
'info',
)
},
onFailure: (error) => {
notify(
error?.message || 'resources.plugin.notifications.error',
'warning',
)
},
},
)
const handleClick = useCallback(
(e) => {
e.stopPropagation()
toggleEnabled()
},
[toggleEnabled],
)
const hasError = !!record?.lastError
const isDisabled = loading || hasError
const tooltipTitle = useMemo(() => {
if (hasError) {
return translate('resources.plugin.actions.disabledDueToError')
}
if (!showLabel) {
return translate(
record?.enabled
? 'resources.plugin.actions.disable'
: 'resources.plugin.actions.enable',
)
}
return ''
}, [hasError, showLabel, record?.enabled, translate])
const switchElement = (
<Switch
checked={record?.enabled ?? false}
onClick={handleClick}
disabled={isDisabled}
className={classes.enabledSwitch}
size={size}
color="primary"
/>
)
if (showLabel) {
return (
<Tooltip
title={tooltipTitle}
disableHoverListener={!hasError}
disableFocusListener={!hasError}
>
<FormControlLabel
control={switchElement}
label={translate(
record?.enabled
? 'resources.plugin.actions.disable'
: 'resources.plugin.actions.enable',
)}
/>
</Tooltip>
)
}
return (
<Tooltip title={tooltipTitle}>
<span>{switchElement}</span>
</Tooltip>
)
}
export default ToggleEnabledSwitch