diff --git a/e2e/tests/resource-required-templates.spec.ts b/e2e/tests/resource-required-templates.spec.ts index e797d055..50d39acb 100644 --- a/e2e/tests/resource-required-templates.spec.ts +++ b/e2e/tests/resource-required-templates.spec.ts @@ -15,7 +15,7 @@ * limitations under the License. */ import { test } from '@e2e/utils/test'; -import { uiSelectByLabel } from '@e2e/utils/ui'; +import { uiGetMonacoEditor, uiSelectByLabel } from '@e2e/utils/ui'; import { expect, type Locator, type Page } from '@playwright/test'; const requiredFields = (page: Page) => @@ -132,3 +132,49 @@ test('API Console switches required-only templates with the resource', async ({ ) ); }); + +test('plugin add JSON prefills required fields from APISIX schema', async ({ + page, +}) => { + await page.goto('/ui/plugin_configs/add'); + await expect(page.getByRole('heading', { name: 'Add Plugin Config' })).toBeVisible(); + + await page.getByRole('button', { name: 'Add Plugin' }).click(); + const selectPluginsDialog = page.getByRole('dialog', { + name: 'Add Plugin', + exact: true, + }); + await selectPluginsDialog + .getByPlaceholder('Search by name, capability, or description') + .fill('limit-count'); + await selectPluginsDialog + .getByTestId('plugin-limit-count') + .getByRole('button', { name: 'Add' }) + .click(); + + const addPluginDialog = page.getByRole('dialog', { + name: 'Add Plugin: limit-count', + }); + await addPluginDialog.getByRole('tab', { name: 'JSON' }).click(); + const pluginEditor = await uiGetMonacoEditor(page, addPluginDialog, false); + + await expect + .poll(async () => { + try { + const config = JSON.parse(await readMonacoValue(page, pluginEditor)) as Record< + string, + unknown + >; + return { + count: config.count, + time_window: config.time_window, + }; + } catch { + return null; + } + }) + .toEqual({ + count: 1, + time_window: 1, + }); +}); diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx index 4eab6afc..a74eac57 100644 --- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx +++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx @@ -23,9 +23,11 @@ import { FormSubmitBtn } from '@/components/form/Btn'; import { FormItemEditor } from '@/components/form/Editor'; import { SchemaForm } from '@/components/schema-form/SchemaForm'; import { + getActiveRequiredFields, getResolvedSchema, getSchemaProperties, type JSONSchema, + schemaType, validateSchemaValue, } from '@/components/schema-form/schemaValidation'; import IconContentCopy from '~icons/material-symbols/content-copy'; @@ -99,28 +101,35 @@ const isSchemaDefaultCompatible = ( const applySchemaDefaults = ( schema: object | undefined, - config: Record | undefined + config: Record | undefined, + rootSchema?: JSONSchema ): Record => { const base = isRecord(config) ? { ...config } : {}; if (!schema || !isRecord(schema)) return base; const typedSchema = schema as JSONSchema; + const root = rootSchema ?? typedSchema; for (const [key, propSchema] of Object.entries(typedSchema.properties ?? {})) { if (!isRecord(propSchema)) continue; + const resolvedPropSchema = getResolvedSchema(propSchema, root); if ( base[key] === undefined && - 'default' in propSchema && - isSchemaDefaultCompatible(propSchema, propSchema.default) + 'default' in resolvedPropSchema && + isSchemaDefaultCompatible(resolvedPropSchema, resolvedPropSchema.default) ) { - base[key] = cloneDefault(propSchema.default); + base[key] = cloneDefault(resolvedPropSchema.default); } } - const properties = getSchemaProperties(typedSchema, typedSchema, base); + const properties = getSchemaProperties(typedSchema, root, base); for (const [key, rawPropSchema] of Object.entries(properties)) { - const propSchema = getResolvedSchema(rawPropSchema, typedSchema); + const propSchema = getResolvedSchema(rawPropSchema, root); if (isRecord(base[key]) && isRecord(propSchema.properties)) { - base[key] = applySchemaDefaults(propSchema, base[key] as Record); + base[key] = applySchemaDefaults( + propSchema, + base[key] as Record, + root + ); } if ( base[key] === undefined && @@ -134,13 +143,143 @@ const applySchemaDefaults = ( return base; }; +const collectTemplateRequiredFields = ( + schema: JSONSchema, + value: Record, + rootSchema: JSONSchema, + required: Set +) => { + const resolvedSchema = getResolvedSchema(schema, rootSchema); + + const matchingVariants = [ + ...(resolvedSchema.oneOf ?? []), + ...(resolvedSchema.anyOf ?? []), + ].filter( + (variant) => validateSchemaValue(variant, value, '', rootSchema).length === 0 + ); + const unionVariants = [ + ...(resolvedSchema.oneOf ?? []), + ...(resolvedSchema.anyOf ?? []), + ]; + if (matchingVariants.length === 0 && unionVariants[0]) { + for (const key of getActiveRequiredFields(unionVariants[0], value, rootSchema)) { + required.add(key); + } + collectTemplateRequiredFields(unionVariants[0], value, rootSchema, required); + } + + for (const variant of resolvedSchema.allOf ?? []) { + collectTemplateRequiredFields(variant, value, rootSchema, required); + } +}; + +const placeholderForSchema = ( + schema: JSONSchema, + rootSchema: JSONSchema +): unknown => { + const resolvedSchema = getResolvedSchema(schema, rootSchema); + + if ( + 'default' in resolvedSchema && + isSchemaDefaultCompatible(resolvedSchema, resolvedSchema.default) + ) { + return cloneDefault(resolvedSchema.default); + } + if ('const' in resolvedSchema) return cloneDefault(resolvedSchema.const); + if (resolvedSchema.enum?.length) return cloneDefault(resolvedSchema.enum[0]); + + const firstVariant = resolvedSchema.oneOf?.[0] ?? resolvedSchema.anyOf?.[0]; + if (!schemaType(resolvedSchema) && firstVariant) { + return placeholderForSchema(firstVariant, rootSchema); + } + + const type = schemaType(resolvedSchema); + const hasResolvedProperties = + Object.keys(resolvedSchema.properties ?? {}).length > 0; + if (type === 'object' || hasResolvedProperties) { + return buildSchemaTemplate(resolvedSchema, {}, rootSchema); + } + if (type === 'array') { + if (resolvedSchema.minItems && resolvedSchema.minItems > 0 && resolvedSchema.items) { + return [placeholderForSchema(resolvedSchema.items, rootSchema)]; + } + return []; + } + if (type === 'integer') { + return resolvedSchema.minimum ?? ( + resolvedSchema.exclusiveMinimum !== undefined + ? Math.floor(resolvedSchema.exclusiveMinimum) + 1 + : 0 + ); + } + if (type === 'number') { + return resolvedSchema.minimum ?? ( + resolvedSchema.exclusiveMinimum !== undefined + ? resolvedSchema.exclusiveMinimum + 1 + : 0 + ); + } + if (type === 'boolean') return false; + if (type === 'null') return null; + if (resolvedSchema.format === 'uri' || resolvedSchema.format === 'uri-reference') { + return 'https://example.com'; + } + if (resolvedSchema.format === 'hostname') return 'example.com'; + if (resolvedSchema.format === 'ipv4') return '127.0.0.1'; + if (resolvedSchema.format === 'ipv6') return '::1'; + if (resolvedSchema.format === 'email') return 'user@example.com'; + if (resolvedSchema.format === 'date-time') return '2026-01-01T00:00:00Z'; + if (type === 'string' && resolvedSchema.minLength && resolvedSchema.minLength > 0) { + return 'value'; + } + return ''; +}; + +const buildSchemaTemplate = ( + schema: object | undefined, + config: Record | undefined, + rootSchema?: JSONSchema +): Record => { + const base = applySchemaDefaults(schema, config, rootSchema); + if (!schema || !isRecord(schema)) return base; + + const sourceSchema = schema as JSONSchema; + const root = rootSchema ?? sourceSchema; + const typedSchema = getResolvedSchema(sourceSchema, root); + const properties = getSchemaProperties(typedSchema, root, base); + const required = new Set([ + ...(typedSchema.required ?? []), + ...getActiveRequiredFields(typedSchema, base, root), + ]); + collectTemplateRequiredFields(typedSchema, base, root, required); + const requiredKeys = [...required]; + + for (const key of requiredKeys) { + if (base[key] !== undefined) continue; + const propSchema = properties[key] ?? typedSchema.properties?.[key]; + base[key] = propSchema ? placeholderForSchema(propSchema, root) : ''; + } + + for (const [key, value] of Object.entries(base)) { + const propSchema = properties[key] ?? typedSchema.properties?.[key]; + if (isRecord(value) && propSchema) { + const resolvedPropSchema = getResolvedSchema(propSchema, root); + if (resolvedPropSchema.properties) { + base[key] = buildSchemaTemplate(resolvedPropSchema, value, root); + } + } + } + + return base; +}; + const getEditableConfig = ( schema: object | undefined, config: Record | undefined, mode: PluginCardListProps['mode'] ): Record => { const base = isRecord(config) ? { ...config } : {}; - return mode === 'add' ? applySchemaDefaults(schema, base) : base; + return mode === 'add' ? buildSchemaTemplate(schema, base) : base; }; const MAX_LIVE_ISSUES = 5; @@ -263,7 +402,9 @@ export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => { ); const applyTemplate = (template: Record) => { - const nextValue = applySchemaDefaults(schema, template); + const nextValue = mode === 'add' + ? buildSchemaTemplate(schema, template) + : applySchemaDefaults(schema, template); setFormValue(nextValue); methods.setValue('config', toConfigStr(nextValue)); setActiveTab(canUseForm ? 'form' : 'json'); diff --git a/src/components/schema-form/schemaValidation.ts b/src/components/schema-form/schemaValidation.ts index 84000ee3..773f6122 100644 --- a/src/components/schema-form/schemaValidation.ts +++ b/src/components/schema-form/schemaValidation.ts @@ -66,7 +66,9 @@ export const schemaType = (schema: JSONSchema): string | undefined => Array.isArray(schema.type) ? schema.type[0] : schema.type ?? - (schema.properties ? 'object' : schema.items ? 'array' : undefined); + (schema.properties && Object.keys(schema.properties).length > 0 + ? 'object' + : schema.items ? 'array' : undefined); const matchesType = (value: unknown, type: string): boolean => { if (type === 'object') return isRecord(value);