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 && (