diff --git a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts index 4f910c6f..6c4c0726 100644 --- a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts +++ b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts @@ -84,6 +84,9 @@ test('should CRUD plugin metadata with all fields', async ({ page }) => { ); await pluginEditor.blur(); await expect(pluginEditor.getByText('{')).toBeVisible(); + await expect( + addPluginDialog.getByText('Fix JSON syntax before saving.') + ).toBeVisible(); await addPluginDialog.getByRole('button', { name: 'Add Plugin' }).click(); const jsonErrorAlert = addPluginDialog.getByRole('alert').filter({ hasText: /Invalid JSON:|JSON format is not valid/, diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx index 9626beb4..4eab6afc 100644 --- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx +++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx @@ -16,8 +16,8 @@ */ import { Alert, Button, Drawer, message, Space, Tabs, Tooltip, Typography } from 'antd'; import { isEmpty, isNil } from 'rambdax'; -import { useCallback, useEffect, useState } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { FormSubmitBtn } from '@/components/form/Btn'; import { FormItemEditor } from '@/components/form/Editor'; @@ -143,6 +143,8 @@ const getEditableConfig = ( return mode === 'add' ? applySchemaDefaults(schema, base) : base; }; +const MAX_LIVE_ISSUES = 5; + export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => { const { opened, onSave, onClose, plugin, mode, schema } = props; const { name, config } = plugin; @@ -160,6 +162,10 @@ export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => { disabled: mode === 'view', defaultValues: { config: toConfigStr(getEditableConfig(schema, config, mode)) }, }); + const jsonConfigText = useWatch({ + control: methods.control, + name: 'config', + }); const handleClose = () => { onClose(); methods.reset(); @@ -296,6 +302,38 @@ export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => { setFormValue(nextValue); setSaveError(null); }, [config, methods, mode, schema]); + const jsonValidation = useMemo(() => { + if (activeTab !== 'json' || mode === 'view') return null; + + let parsed: unknown; + try { + parsed = JSON.parse(jsonConfigText || '{}'); + } catch (error) { + return { + message: 'Fix JSON syntax before saving.', + issues: [error instanceof Error ? error.message : String(error)], + }; + } + + if (!isRecord(parsed)) { + return { + message: 'Plugin config must be a JSON object.', + issues: ['The top-level JSON value is not an object.'], + }; + } + + const issues = [ + ...validateSchemaValue(schema as JSONSchema | undefined, parsed), + ...validateAIGatewayConfig(name, parsed), + ...validatePluginCompatibility(name, parsed), + ]; + if (issues.length === 0) return null; + + return { + message: `${issues.length} plugin config issue${issues.length === 1 ? '' : 's'}`, + issues, + }; + }, [activeTab, jsonConfigText, mode, name, schema]); const saveErrorActions = activeTab === 'json' && mode !== 'view' ? ( @@ -465,6 +503,27 @@ export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => { )} + {jsonValidation && ( + + {jsonValidation.issues.slice(0, MAX_LIVE_ISSUES).map((issue) => ( +
  • {issue}
  • + ))} + {jsonValidation.issues.length > MAX_LIVE_ISSUES && ( +
  • + {jsonValidation.issues.length - MAX_LIVE_ISSUES} more issue + {jsonValidation.issues.length - MAX_LIVE_ISSUES === 1 ? '' : 's'} +
  • + )} + + } + style={{ marginBottom: 8 }} + /> + )} {mode !== 'view' && saveError && (