mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
feat(plugins UI): add PluginList and PluginShow components with plugin management functionality
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
690785120a
commit
2cc29793a6
@ -14,6 +14,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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.
|
// adminContext returns a context with admin privileges for DB operations.
|
||||||
func adminContext(ctx context.Context) context.Context {
|
func adminContext(ctx context.Context) context.Context {
|
||||||
return request.WithUser(ctx, model.User{IsAdmin: true})
|
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)
|
log.Info(m.ctx, "Unloaded plugin", "plugin", name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -49,9 +50,11 @@ type PluginUpdateRequest struct {
|
|||||||
|
|
||||||
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
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)
|
plugin, err := repo.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, rest.ErrPermissionDenied) {
|
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)
|
http.Error(w, "Plugin not found", http.StatusNotFound)
|
||||||
return
|
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)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -70,38 +73,82 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Parse update request
|
// Parse update request
|
||||||
var req PluginUpdateRequest
|
var req PluginUpdateRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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)
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply updates
|
// If manager is configured, use it to properly load/unload plugins
|
||||||
if req.Enabled != nil {
|
// Otherwise, fall back to direct DB operations (e.g., in tests)
|
||||||
plugin.Enabled = *req.Enabled
|
if manager.IsConfigured() {
|
||||||
}
|
// Handle config update first (if provided)
|
||||||
if req.Config != nil {
|
if req.Config != nil {
|
||||||
// Validate JSON if not empty
|
// Validate JSON if not empty
|
||||||
if *req.Config != "" && !isValidJSON(*req.Config) {
|
if *req.Config != "" && !isValidJSON(*req.Config) {
|
||||||
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
plugin.Config = *req.Config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save
|
// Refresh and return updated plugin
|
||||||
if err := repo.Put(plugin); err != nil {
|
plugin, err = repo.Get(id)
|
||||||
if errors.Is(err, rest.ErrPermissionDenied) {
|
if err != nil {
|
||||||
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
|
log.Error(ctx, "Error getting updated plugin", "id", id, err)
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Error(r.Context(), "Error updating plugin", "id", id, err)
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err := json.NewEncoder(w).Encode(plugin); err != nil {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -74,6 +74,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
|||||||
"defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat,
|
"defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat,
|
||||||
"separator": string(os.PathSeparator),
|
"separator": string(os.PathSeparator),
|
||||||
"enableInspect": conf.Server.Inspect.Enabled,
|
"enableInspect": conf.Server.Inspect.Enabled,
|
||||||
|
"pluginsEnabled": conf.Server.Plugins.Enabled,
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||||
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL)
|
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL)
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import playlist from './playlist'
|
|||||||
import radio from './radio'
|
import radio from './radio'
|
||||||
import share from './share'
|
import share from './share'
|
||||||
import library from './library'
|
import library from './library'
|
||||||
|
import plugin from './plugin'
|
||||||
import { Player } from './audioplayer'
|
import { Player } from './audioplayer'
|
||||||
import customRoutes from './routes'
|
import customRoutes from './routes'
|
||||||
import {
|
import {
|
||||||
@ -139,6 +140,13 @@ const Admin = (props) => {
|
|||||||
options={{ subMenu: 'settings' }}
|
options={{ subMenu: 'settings' }}
|
||||||
/>
|
/>
|
||||||
) : null,
|
) : null,
|
||||||
|
permissions === 'admin' && config.pluginsEnabled ? (
|
||||||
|
<Resource
|
||||||
|
name="plugin"
|
||||||
|
{...plugin}
|
||||||
|
options={{ subMenu: 'settings' }}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
|
||||||
<Resource name="translation" />,
|
<Resource name="translation" />,
|
||||||
<Resource name="genre" />,
|
<Resource name="genre" />,
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const defaultConfig = {
|
|||||||
publicBaseUrl: '/share',
|
publicBaseUrl: '/share',
|
||||||
separator: '/',
|
separator: '/',
|
||||||
enableInspect: true,
|
enableInspect: true,
|
||||||
|
pluginsEnabled: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
let config
|
let config
|
||||||
|
|||||||
@ -330,6 +330,47 @@
|
|||||||
"scanInProgress": "Scan in progress...",
|
"scanInProgress": "Scan in progress...",
|
||||||
"noLibrariesAssigned": "No libraries assigned to this user"
|
"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": {
|
"ra": {
|
||||||
|
|||||||
223
ui/src/plugin/PluginList.jsx
Normal file
223
ui/src/plugin/PluginList.jsx
Normal 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
|
||||||
73
ui/src/plugin/PluginList.test.jsx
Normal file
73
ui/src/plugin/PluginList.test.jsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
349
ui/src/plugin/PluginShow.jsx
Normal file
349
ui/src/plugin/PluginShow.jsx
Normal 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
9
ui/src/plugin/index.js
Normal 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,
|
||||||
|
}
|
||||||
68
ui/src/plugin/jsonValidation.js
Normal file
68
ui/src/plugin/jsonValidation.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
97
ui/src/plugin/jsonValidation.test.js
Normal file
97
ui/src/plugin/jsonValidation.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user