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 { import {
Datagrid, Datagrid,
TextField, TextField,
useUpdate,
useNotify,
useRefresh,
useRecordContext, useRecordContext,
useTranslate, useTranslate,
FunctionField,
useResourceContext,
} from 'react-admin' } from 'react-admin'
import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core' import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core'
import { MdError } from 'react-icons/md' import { MdError } from 'react-icons/md'
import { List, DateField, SimpleList } from '../common' import { List, DateField, SimpleList } from '../common'
import ToggleEnabledSwitch from './ToggleEnabledSwitch'
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
errorIcon: { errorIcon: {
@ -26,84 +21,8 @@ const useStyles = makeStyles((theme) => ({
backgroundColor: theme.palette.error.light, backgroundColor: theme.palette.error.light,
color: theme.palette.error.contrastText, 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 ErrorIndicator = () => {
const record = useRecordContext() const record = useRecordContext()
const translate = useTranslate() const translate = useTranslate()
@ -125,19 +44,26 @@ const ErrorIndicator = () => {
) )
} }
const ManifestField = ({ source }) => { const useManifest = () => {
const record = useRecordContext() const record = useRecordContext()
return useMemo(() => {
if (!record?.manifest) return null
try {
return JSON.parse(record.manifest)
} catch {
return null
}
}, [record?.manifest])
}
if (!record?.manifest) { const ManifestField = ({ source }) => {
return null const manifest = useManifest()
if (!manifest) {
return <Typography variant="body2">-</Typography>
} }
try { return <Typography variant="body2">{manifest[source] || '-'}</Typography>
const manifest = JSON.parse(record.manifest)
return <Typography source>{manifest[source] || '-'}</Typography>
} catch {
return <Typography source>-</Typography>
}
} }
const PluginList = (props) => { const PluginList = (props) => {
@ -170,7 +96,7 @@ const PluginList = (props) => {
<ManifestField source="name" /> <ManifestField source="name" />
{!isXsmall && <ManifestField source="description" />} {!isXsmall && <ManifestField source="description" />}
<ManifestField source="version" /> <ManifestField source="version" />
<ToggleEnabledInput source={'enabled'} /> <ToggleEnabledSwitch source={'enabled'} />
<ErrorIndicator source="lastError" /> <ErrorIndicator source="lastError" />
<DateField source="updatedAt" sortByOrder={'DESC'} /> <DateField source="updatedAt" sortByOrder={'DESC'} />
</Datagrid> </Datagrid>

View File

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

View File

@ -13,8 +13,6 @@ import {
import { import {
Typography, Typography,
Box, Box,
Switch,
FormControlLabel,
Card, Card,
CardContent, CardContent,
TextField as MuiTextField, TextField as MuiTextField,
@ -34,6 +32,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { MdExpandMore, MdSave } from 'react-icons/md' import { MdExpandMore, MdSave } from 'react-icons/md'
import { Title, DateField } from '../common' import { Title, DateField } from '../common'
import { validateJson } from './jsonValidation' import { validateJson } from './jsonValidation'
import ToggleEnabledSwitch from './ToggleEnabledSwitch'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
@ -215,46 +214,14 @@ const ErrorSection = ({ error, translate }) => {
} }
// Status card with enable/disable toggle // Status card with enable/disable toggle
const StatusCard = ({ const StatusCard = ({ classes, translate }) => {
record,
classes,
translate,
onToggle,
loading,
hasError,
}) => {
const isDisabled = loading || hasError
return ( return (
<Card className={classes.section}> <Card className={classes.section}>
<CardContent> <CardContent>
<Typography variant="h6" className={classes.sectionTitle}> <Typography variant="h6" className={classes.sectionTitle}>
{translate('resources.plugin.sections.status')} {translate('resources.plugin.sections.status')}
</Typography> </Typography>
<Tooltip <ToggleEnabledSwitch showLabel size="medium" />
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>
</CardContent> </CardContent>
</Card> </Card>
) )
@ -332,53 +299,32 @@ const InfoCard = ({ record, manifest, classes, translate, isSmall }) => (
</InfoRow> </InfoRow>
)} )}
{manifest?.permissions && ( {manifest?.permissions &&
<InfoRow Object.keys(manifest.permissions).length > 0 && (
label={translate('resources.plugin.fields.permissions')} <InfoRow
classes={classes} label={translate('resources.plugin.fields.permissions')}
isSmall={isSmall} 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' }}
> >
{translate('resources.plugin.messages.clickPermissions')} <Box className={classes.permissionsContainer}>
</Typography> {Object.entries(manifest.permissions).map(([key, value]) => (
</InfoRow> <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 <InfoRow
label={translate('resources.plugin.fields.path')} 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( const handleConfigChange = useCallback(
(e) => { (e) => {
const value = e.target.value const value = e.target.value
@ -584,14 +525,7 @@ const PluginShowLayout = () => {
<Box className={classes.root}> <Box className={classes.root}>
<ErrorSection error={record.lastError} translate={translate} /> <ErrorSection error={record.lastError} translate={translate} />
<StatusCard <StatusCard classes={classes} translate={translate} />
record={record}
classes={classes}
translate={translate}
onToggle={handleToggleEnabled}
loading={loading}
hasError={!!record.lastError}
/>
<InfoCard <InfoCard
record={record} 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