mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
refactor(plugins): introduce ToggleEnabledSwitch for managing plugin enable/disable state
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
5201f8a5eb
commit
505b3c529f
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
129
ui/src/plugin/ToggleEnabledSwitch.jsx
Normal file
129
ui/src/plugin/ToggleEnabledSwitch.jsx
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user