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 {
|
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>
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
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