feat(plugins UI): add PluginList and PluginShow components with plugin management functionality

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-27 20:36:53 -05:00
parent 690785120a
commit 2cc29793a6
12 changed files with 947 additions and 21 deletions

View File

@ -14,6 +14,7 @@ import (
"net/http"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
@ -116,6 +117,13 @@ func GetManager() *Manager {
})
}
// IsConfigured returns true if the manager has been configured with a DataStore.
// This is useful for API handlers to know if they should use the manager or fall back
// to direct DB operations (e.g., in test environments).
func (m *Manager) IsConfigured() bool {
return m.ds != nil
}
// adminContext returns a context with admin privileges for DB operations.
func adminContext(ctx context.Context) context.Context {
return request.WithUser(ctx, model.User{IsAdmin: true})
@ -941,6 +949,7 @@ func (m *Manager) UnloadPlugin(name string) error {
}
}
runtime.GC()
log.Info(m.ctx, "Unloaded plugin", "plugin", name)
return nil
}

View File

@ -11,6 +11,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/server"
)
@ -49,9 +50,11 @@ type PluginUpdateRequest struct {
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
repo := api.ds.Plugin(r.Context())
ctx := r.Context()
repo := api.ds.Plugin(ctx)
manager := plugins.GetManager()
// Get existing plugin
// Get existing plugin to verify it exists
plugin, err := repo.Get(id)
if err != nil {
if errors.Is(err, rest.ErrPermissionDenied) {
@ -62,7 +65,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Plugin not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting plugin", "id", id, err)
log.Error(ctx, "Error getting plugin", "id", id, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
@ -70,38 +73,82 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
// Parse update request
var req PluginUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Error(r.Context(), "Error decoding request", err)
log.Error(ctx, "Error decoding request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Apply updates
if req.Enabled != nil {
plugin.Enabled = *req.Enabled
}
if req.Config != nil {
// Validate JSON if not empty
if *req.Config != "" && !isValidJSON(*req.Config) {
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
// If manager is configured, use it to properly load/unload plugins
// Otherwise, fall back to direct DB operations (e.g., in tests)
if manager.IsConfigured() {
// Handle config update first (if provided)
if req.Config != nil {
// Validate JSON if not empty
if *req.Config != "" && !isValidJSON(*req.Config) {
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
return
}
if err := manager.UpdatePluginConfig(ctx, id, *req.Config); err != nil {
log.Error(ctx, "Error updating plugin config", "id", id, err)
http.Error(w, "Error updating plugin configuration: "+err.Error(), http.StatusInternalServerError)
return
}
}
// Handle enable/disable
if req.Enabled != nil {
if *req.Enabled {
if err := manager.EnablePlugin(ctx, id); err != nil {
log.Error(ctx, "Error enabling plugin", "id", id, err)
// Refresh plugin from DB to get the error
plugin, _ = repo.Get(id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
_ = json.NewEncoder(w).Encode(plugin)
return
}
} else {
if err := manager.DisablePlugin(ctx, id); err != nil {
log.Error(ctx, "Error disabling plugin", "id", id, err)
http.Error(w, "Error disabling plugin: "+err.Error(), http.StatusInternalServerError)
return
}
}
}
} else {
// Fallback: direct DB operations (for tests or when manager is not started)
if req.Config != nil {
if *req.Config != "" && !isValidJSON(*req.Config) {
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
return
}
plugin.Config = *req.Config
}
if req.Enabled != nil {
plugin.Enabled = *req.Enabled
}
if err := repo.Put(plugin); err != nil {
if errors.Is(err, rest.ErrPermissionDenied) {
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
return
}
log.Error(ctx, "Error updating plugin", "id", id, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
plugin.Config = *req.Config
}
// Save
if err := repo.Put(plugin); err != nil {
if errors.Is(err, rest.ErrPermissionDenied) {
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
return
}
log.Error(r.Context(), "Error updating plugin", "id", id, err)
// Refresh and return updated plugin
plugin, err = repo.Get(id)
if err != nil {
log.Error(ctx, "Error getting updated plugin", "id", id, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(plugin); err != nil {
log.Error(r.Context(), "Error encoding plugin response", err)
log.Error(ctx, "Error encoding plugin response", err)
}
}

View File

@ -74,6 +74,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat,
"separator": string(os.PathSeparator),
"enableInspect": conf.Server.Inspect.Enabled,
"pluginsEnabled": conf.Server.Plugins.Enabled,
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL)

View File

@ -16,6 +16,7 @@ import playlist from './playlist'
import radio from './radio'
import share from './share'
import library from './library'
import plugin from './plugin'
import { Player } from './audioplayer'
import customRoutes from './routes'
import {
@ -139,6 +140,13 @@ const Admin = (props) => {
options={{ subMenu: 'settings' }}
/>
) : null,
permissions === 'admin' && config.pluginsEnabled ? (
<Resource
name="plugin"
{...plugin}
options={{ subMenu: 'settings' }}
/>
) : null,
<Resource name="translation" />,
<Resource name="genre" />,

View File

@ -38,6 +38,7 @@ const defaultConfig = {
publicBaseUrl: '/share',
separator: '/',
enableInspect: true,
pluginsEnabled: true,
}
let config

View File

@ -330,6 +330,47 @@
"scanInProgress": "Scan in progress...",
"noLibrariesAssigned": "No libraries assigned to this user"
}
},
"plugin": {
"name": "Plugin |||| Plugins",
"fields": {
"name": "Name",
"description": "Description",
"version": "Version",
"enabled": "Enabled",
"status": "Status",
"path": "Path",
"lastError": "Error",
"hasError": "Error",
"updatedAt": "Updated",
"createdAt": "Installed"
},
"sections": {
"status": "Status",
"info": "Plugin Information",
"configuration": "Configuration",
"manifest": "Manifest"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"actions": {
"enable": "Enable",
"disable": "Disable"
},
"notifications": {
"enabled": "Plugin enabled",
"disabled": "Plugin disabled",
"updated": "Plugin updated",
"error": "Error updating plugin"
},
"validation": {
"invalidJson": "Configuration must be valid JSON"
},
"messages": {
"configHelp": "Enter plugin configuration as a JSON object. Leave empty if the plugin requires no configuration."
}
}
},
"ra": {

View File

@ -0,0 +1,223 @@
import React, { useMemo, useCallback } from 'react'
import {
Datagrid,
TextField,
useUpdate,
useNotify,
useRefresh,
useRecordContext,
useTranslate,
FunctionField,
} from 'react-admin'
import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery, Tooltip, Chip } from '@material-ui/core'
import { MdError } from 'react-icons/md'
import { List, DateField, SimpleList } from '../common'
const useStyles = makeStyles((theme) => ({
errorIcon: {
color: theme.palette.error.main,
marginRight: theme.spacing(0.5),
verticalAlign: 'middle',
},
errorChip: {
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 = ({ resource }) => {
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],
)
return (
<Tooltip
title={translate(
record.enabled
? 'resources.plugin.actions.disable'
: 'resources.plugin.actions.enable',
)}
>
<span>
<Switch
checked={record.enabled}
onClick={handleClick}
disabled={loading}
className={classes.enabledSwitch}
size="small"
/>
</span>
</Tooltip>
)
}
const ErrorIndicator = () => {
const record = useRecordContext()
const translate = useTranslate()
const classes = useStyles()
if (!record.lastError) {
return null
}
return (
<Tooltip title={record.lastError}>
<Chip
size="small"
icon={<MdError className={classes.errorIcon} />}
label={translate('resources.plugin.fields.hasError')}
className={classes.errorChip}
/>
</Tooltip>
)
}
const VersionField = () => {
const record = useRecordContext()
if (!record.manifest) {
return null
}
try {
const manifest = JSON.parse(record.manifest)
return <span>{manifest.version || '-'}</span>
} catch {
return <span>-</span>
}
}
const DescriptionField = () => {
const record = useRecordContext()
if (!record.manifest) {
return null
}
try {
const manifest = JSON.parse(record.manifest)
return <span>{manifest.description || '-'}</span>
} catch {
return <span>-</span>
}
}
const PluginList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const translate = useTranslate()
const toggleableFields = useMemo(
() => ({
description: !isXsmall && (
<FunctionField
source="manifest"
label="resources.plugin.fields.description"
render={() => <DescriptionField />}
/>
),
version: (
<FunctionField
source="manifest"
label="resources.plugin.fields.version"
render={() => <VersionField />}
/>
),
enabled: (
<FunctionField
source="enabled"
label="resources.plugin.fields.enabled"
render={() => <ToggleEnabledInput resource="plugin" />}
/>
),
error: (
<FunctionField
source="lastError"
label="resources.plugin.fields.status"
render={() => <ErrorIndicator />}
/>
),
}),
[isXsmall],
)
return (
<List {...props} sort={{ field: 'id', order: 'ASC' }} exporter={false}>
{isXsmall ? (
<SimpleList
primaryText={(record) => record.id}
secondaryText={(record) => {
try {
const manifest = JSON.parse(record.manifest)
return manifest.description || ''
} catch {
return ''
}
}}
tertiaryText={(record) =>
record.enabled
? translate('resources.plugin.status.enabled')
: translate('resources.plugin.status.disabled')
}
linkType="show"
/>
) : (
<Datagrid rowClick="show">
<TextField source="id" label="resources.plugin.fields.name" />
{toggleableFields['description']}
{toggleableFields['version']}
{toggleableFields['enabled']}
{toggleableFields['error']}
<DateField source="updatedAt" sortByOrder={'DESC'} />
</Datagrid>
)}
</List>
)
}
export default PluginList

View File

@ -0,0 +1,73 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock react-admin hooks
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
useUpdate: vi.fn(() => [vi.fn(), { loading: false }]),
useNotify: vi.fn(() => vi.fn()),
useRefresh: vi.fn(() => vi.fn()),
useTranslate: vi.fn(() => (key) => key),
useRecordContext: vi.fn(() => ({
id: 'test-plugin',
manifest: JSON.stringify({
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>
),
TextField: ({ source }) => <span data-testid={`text-${source}`} />,
}
})
// Mock common components
vi.mock('../common', async () => {
return {
List: ({ children, ...props }) => (
<div data-testid="list" {...props}>
{children}
</div>
),
DateField: ({ source }) => <span data-testid={`date-${source}`} />,
SimpleList: ({ primaryText, secondaryText }) => (
<div data-testid="simple-list" />
),
}
})
// Mock Material-UI
vi.mock('@material-ui/core', async () => {
const actual = await vi.importActual('@material-ui/core')
return {
...actual,
useMediaQuery: vi.fn(() => false),
}
})
import PluginList from './PluginList'
describe('PluginList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the list component', () => {
render(<PluginList />)
expect(screen.getByTestId('list')).toBeInTheDocument()
})
it('renders the datagrid on desktop', () => {
render(<PluginList />)
expect(screen.getByTestId('datagrid')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,349 @@
import React, { useState, useCallback } from 'react'
import {
Show,
SimpleShowLayout,
TextField,
useTranslate,
useUpdate,
useNotify,
useRefresh,
useRecordContext,
Toolbar,
SaveButton,
} from 'react-admin'
import {
Typography,
Box,
Switch,
FormControlLabel,
Card,
CardContent,
TextField as MuiTextField,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import { MdExpandMore, MdError, MdCheckCircle } from 'react-icons/md'
import { Title, DateField } from '../common'
import { validateJson } from './jsonValidation'
const useStyles = makeStyles((theme) => ({
root: {
padding: theme.spacing(2),
maxWidth: 900,
},
section: {
marginBottom: theme.spacing(3),
},
sectionTitle: {
marginBottom: theme.spacing(1),
fontWeight: 600,
},
errorBox: {
backgroundColor: theme.palette.error.light,
color: theme.palette.error.contrastText,
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
marginBottom: theme.spacing(2),
display: 'flex',
alignItems: 'flex-start',
gap: theme.spacing(1),
},
errorIcon: {
marginTop: 2,
},
manifestBox: {
backgroundColor:
theme.palette.type === 'dark'
? theme.palette.grey[900]
: theme.palette.grey[100],
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
fontFamily: 'monospace',
fontSize: '0.85rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflow: 'auto',
maxHeight: 400,
},
configInput: {
fontFamily: 'monospace',
fontSize: '0.85rem',
},
statusEnabled: {
color: theme.palette.success?.main || theme.palette.primary.main,
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
},
statusDisabled: {
color: theme.palette.text.secondary,
},
toolbar: {
display: 'flex',
justifyContent: 'flex-start',
paddingLeft: 0,
paddingRight: 0,
marginTop: theme.spacing(2),
},
infoGrid: {
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: theme.spacing(1, 2),
'& dt': {
fontWeight: 500,
color: theme.palette.text.secondary,
},
'& dd': {
margin: 0,
},
},
pathField: {
fontFamily: 'monospace',
fontSize: '0.85rem',
wordBreak: 'break-all',
},
}))
const PluginTitle = ({ record }) => {
const translate = useTranslate()
const resourceName = translate('resources.plugin.name', { smart_count: 1 })
return (
<Title subTitle={`${resourceName} ${record ? `"${record.id}"` : ''}`} />
)
}
const PluginShowContent = () => {
const record = useRecordContext()
const classes = useStyles()
const translate = useTranslate()
const notify = useNotify()
const refresh = useRefresh()
const [config, setConfig] = useState(record?.config || '')
const [configError, setConfigError] = useState(null)
const [isDirty, setIsDirty] = useState(false)
const [updatePlugin, { loading }] = useUpdate(
'plugin',
record?.id,
{},
record,
{
undoable: false,
onSuccess: () => {
refresh()
setIsDirty(false)
notify('resources.plugin.notifications.updated', 'info')
},
onFailure: (error) => {
notify(
error?.message || 'resources.plugin.notifications.error',
'warning',
)
},
},
)
const handleToggleEnabled = useCallback(() => {
updatePlugin('plugin', record.id, { enabled: !record.enabled }, record)
}, [updatePlugin, record])
const handleConfigChange = useCallback(
(e) => {
const value = e.target.value
setConfig(value)
setIsDirty(value !== (record?.config || ''))
if (value === '') {
setConfigError(null)
} else {
const validation = validateJson(value)
setConfigError(validation.error)
}
},
[record?.config],
)
const handleSaveConfig = useCallback(() => {
if (configError) {
notify('resources.plugin.validation.invalidJson', 'warning')
return
}
updatePlugin('plugin', record.id, { config }, record)
}, [updatePlugin, record, config, configError, notify])
if (!record) {
return null
}
let manifest = null
let manifestJson = ''
try {
manifest = JSON.parse(record.manifest)
manifestJson = JSON.stringify(manifest, null, 2)
} catch {
manifestJson = record.manifest
}
return (
<Box className={classes.root}>
{/* Error Section */}
{record.lastError && (
<Box className={classes.errorBox}>
<MdError size={20} className={classes.errorIcon} />
<Box>
<Typography variant="subtitle2">
{translate('resources.plugin.fields.lastError')}
</Typography>
<Typography variant="body2">{record.lastError}</Typography>
</Box>
</Box>
)}
{/* Status and Enable/Disable */}
<Card className={classes.section}>
<CardContent>
<Typography variant="h6" className={classes.sectionTitle}>
{translate('resources.plugin.sections.status')}
</Typography>
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
>
<Box>
{record.enabled ? (
<Typography className={classes.statusEnabled}>
<MdCheckCircle />
{translate('resources.plugin.status.enabled')}
</Typography>
) : (
<Typography className={classes.statusDisabled}>
{translate('resources.plugin.status.disabled')}
</Typography>
)}
</Box>
<FormControlLabel
control={
<Switch
checked={record.enabled}
onChange={handleToggleEnabled}
disabled={loading}
color="primary"
/>
}
label={translate(
record.enabled
? 'resources.plugin.actions.disable'
: 'resources.plugin.actions.enable',
)}
labelPlacement="start"
/>
</Box>
</CardContent>
</Card>
{/* Plugin Info */}
<Card className={classes.section}>
<CardContent>
<Typography variant="h6" className={classes.sectionTitle}>
{translate('resources.plugin.sections.info')}
</Typography>
<dl className={classes.infoGrid}>
<dt>{translate('resources.plugin.fields.name')}</dt>
<dd>{record.id}</dd>
{manifest?.version && (
<>
<dt>{translate('resources.plugin.fields.version')}</dt>
<dd>{manifest.version}</dd>
</>
)}
{manifest?.description && (
<>
<dt>{translate('resources.plugin.fields.description')}</dt>
<dd>{manifest.description}</dd>
</>
)}
<dt>{translate('resources.plugin.fields.path')}</dt>
<dd className={classes.pathField}>{record.path}</dd>
<dt>{translate('resources.plugin.fields.updatedAt')}</dt>
<dd>
<DateField record={record} source="updatedAt" showTime />
</dd>
<dt>{translate('resources.plugin.fields.createdAt')}</dt>
<dd>
<DateField record={record} source="createdAt" showTime />
</dd>
</dl>
</CardContent>
</Card>
{/* Configuration */}
<Card className={classes.section}>
<CardContent>
<Typography variant="h6" className={classes.sectionTitle}>
{translate('resources.plugin.sections.configuration')}
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
{translate('resources.plugin.messages.configHelp')}
</Typography>
<MuiTextField
multiline
fullWidth
minRows={4}
maxRows={15}
variant="outlined"
value={config}
onChange={handleConfigChange}
error={!!configError}
helperText={configError}
placeholder="{}"
InputProps={{
className: classes.configInput,
}}
/>
<Toolbar className={classes.toolbar}>
<SaveButton
handleSubmitWithRedirect={handleSaveConfig}
disabled={!isDirty || !!configError || loading}
saving={loading}
/>
</Toolbar>
</CardContent>
</Card>
{/* Manifest */}
<Accordion>
<AccordionSummary expandIcon={<MdExpandMore />}>
<Typography variant="h6">
{translate('resources.plugin.sections.manifest')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className={classes.manifestBox} width="100%">
{manifestJson}
</Box>
</AccordionDetails>
</Accordion>
</Box>
)
}
const PluginShow = (props) => {
return (
<Show title={<PluginTitle />} actions={false} {...props}>
<SimpleShowLayout>
<PluginShowContent />
</SimpleShowLayout>
</Show>
)
}
export default PluginShow

9
ui/src/plugin/index.js Normal file
View File

@ -0,0 +1,9 @@
import { VscExtensions } from 'react-icons/vsc'
import PluginList from './PluginList'
import PluginShow from './PluginShow'
export default {
icon: VscExtensions,
list: PluginList,
show: PluginShow,
}

View File

@ -0,0 +1,68 @@
/**
* Validates a JSON string and returns validation result
* @param {string} value - The JSON string to validate
* @returns {{ valid: boolean, error: string|null, parsed: object|null }}
*/
export const validateJson = (value) => {
if (!value || value.trim() === '') {
return { valid: true, error: null, parsed: null }
}
try {
const parsed = JSON.parse(value)
// Ensure config is an object, not an array or primitive
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed)
) {
return {
valid: false,
error: 'Configuration must be a JSON object',
parsed: null,
}
}
return { valid: true, error: null, parsed }
} catch (e) {
// Try to provide helpful error messages
let error = 'Invalid JSON'
if (e instanceof SyntaxError) {
const message = e.message
// Extract position information if available
const positionMatch = message.match(/position (\d+)/)
if (positionMatch) {
const position = parseInt(positionMatch[1], 10)
const lines = value.substring(0, position).split('\n')
const line = lines.length
const column = lines[lines.length - 1].length + 1
error = `Invalid JSON at line ${line}, column ${column}`
} else if (message.includes('Unexpected end of JSON')) {
error = 'Incomplete JSON - check for missing brackets or quotes'
} else if (message.includes('Unexpected token')) {
error = 'Invalid JSON - unexpected character found'
}
}
return { valid: false, error, parsed: null }
}
}
/**
* Formats JSON string with proper indentation
* @param {string} value - The JSON string to format
* @returns {string} - Formatted JSON string or original if invalid
*/
export const formatJson = (value) => {
if (!value || value.trim() === '') {
return value
}
try {
const parsed = JSON.parse(value)
return JSON.stringify(parsed, null, 2)
} catch {
return value
}
}

View File

@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { validateJson, formatJson } from './jsonValidation'
describe('validateJson', () => {
it('returns valid for empty string', () => {
const result = validateJson('')
expect(result.valid).toBe(true)
expect(result.error).toBeNull()
expect(result.parsed).toBeNull()
})
it('returns valid for whitespace only', () => {
const result = validateJson(' ')
expect(result.valid).toBe(true)
expect(result.error).toBeNull()
})
it('returns valid for valid JSON object', () => {
const result = validateJson('{"key": "value"}')
expect(result.valid).toBe(true)
expect(result.error).toBeNull()
expect(result.parsed).toEqual({ key: 'value' })
})
it('returns valid for nested JSON object', () => {
const result = validateJson('{"outer": {"inner": 123}}')
expect(result.valid).toBe(true)
expect(result.parsed).toEqual({ outer: { inner: 123 } })
})
it('returns invalid for JSON array', () => {
const result = validateJson('[1, 2, 3]')
expect(result.valid).toBe(false)
expect(result.error).toBe('Configuration must be a JSON object')
})
it('returns invalid for JSON primitive string', () => {
const result = validateJson('"hello"')
expect(result.valid).toBe(false)
expect(result.error).toBe('Configuration must be a JSON object')
})
it('returns invalid for JSON primitive number', () => {
const result = validateJson('42')
expect(result.valid).toBe(false)
expect(result.error).toBe('Configuration must be a JSON object')
})
it('returns invalid for JSON null', () => {
const result = validateJson('null')
expect(result.valid).toBe(false)
expect(result.error).toBe('Configuration must be a JSON object')
})
it('returns invalid for malformed JSON', () => {
const result = validateJson('{"key": }')
expect(result.valid).toBe(false)
expect(result.error).toContain('Invalid JSON')
})
it('returns invalid for incomplete JSON', () => {
const result = validateJson('{"key": "value"')
expect(result.valid).toBe(false)
expect(result.error).toContain('Invalid JSON')
})
it('returns invalid for JSON with trailing comma', () => {
const result = validateJson('{"key": "value",}')
expect(result.valid).toBe(false)
expect(result.error).toContain('Invalid JSON')
})
})
describe('formatJson', () => {
it('returns empty string unchanged', () => {
expect(formatJson('')).toBe('')
})
it('returns whitespace unchanged', () => {
expect(formatJson(' ')).toBe(' ')
})
it('formats compact JSON with indentation', () => {
const result = formatJson('{"key":"value"}')
expect(result).toBe('{\n "key": "value"\n}')
})
it('formats nested JSON with proper indentation', () => {
const result = formatJson('{"outer":{"inner":123}}')
expect(result).toBe('{\n "outer": {\n "inner": 123\n }\n}')
})
it('returns invalid JSON unchanged', () => {
const invalid = '{"key": }'
expect(formatJson(invalid)).toBe(invalid)
})
})