Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion e2e/tests/resource-required-templates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
});
});
159 changes: 150 additions & 9 deletions src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -99,28 +101,35 @@ const isSchemaDefaultCompatible = (

const applySchemaDefaults = (
schema: object | undefined,
config: Record<string, unknown> | undefined
config: Record<string, unknown> | undefined,
rootSchema?: JSONSchema
): Record<string, unknown> => {
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<string, unknown>);
base[key] = applySchemaDefaults(
propSchema,
base[key] as Record<string, unknown>,
root
);
}
if (
base[key] === undefined &&
Expand All @@ -134,13 +143,143 @@ const applySchemaDefaults = (
return base;
};

const collectTemplateRequiredFields = (
schema: JSONSchema,
value: Record<string, unknown>,
rootSchema: JSONSchema,
required: Set<string>
) => {
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);
}
Comment on lines +154 to +169

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';
Comment on lines +232 to +233

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor minLength when generating required strings

When an APISIX or custom plugin schema marks a string field as required with minLength greater than 5, this branch seeds it with the fixed string value. The add-mode JSON template then fails the drawer's own validateSchemaValue check and cannot be saved until the user edits a field that was supposed to be prefilled; generate a string at least minLength characters long or otherwise respect the constraint.

Useful? React with 👍 / 👎.

}
return '';
};

const buildSchemaTemplate = (
schema: object | undefined,
config: Record<string, unknown> | undefined,
rootSchema?: JSONSchema
): Record<string, unknown> => {
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<string, unknown> | undefined,
mode: PluginCardListProps['mode']
): Record<string, unknown> => {
const base = isRecord(config) ? { ...config } : {};
return mode === 'add' ? applySchemaDefaults(schema, base) : base;
return mode === 'add' ? buildSchemaTemplate(schema, base) : base;
};

const MAX_LIVE_ISSUES = 5;
Expand Down Expand Up @@ -263,7 +402,9 @@ export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => {
);

const applyTemplate = (template: Record<string, unknown>) => {
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');
Expand Down
4 changes: 3 additions & 1 deletion src/components/schema-form/schemaValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down