Skip to content
Open
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
5 changes: 5 additions & 0 deletions workspaces/dcm/.changeset/form-validation-enhancements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-dcm': patch
---

Catalog Item: validate duplicate paths, block adding empty field rows, live-validate JSON in schema modal, surface invalid default_value / validation_schema inline. Policy: add structural Rego validation (package declaration + selected_provider reference) and monospace font to the code editor. Catalog Instance: enforce required/min/max constraints from validation_schema on user-value fields and enable submit-button gating.
2 changes: 2 additions & 0 deletions workspaces/dcm/plugins/dcm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@material-ui/lab": "4.0.0-alpha.61",
"@red-hat-developer-hub/backstage-plugin-dcm-common": "workspace:^",
"js-yaml": "^4.1.1",
"react-syntax-highlighter": "^15.4.5",
"react-use": "^17.2.4",
"yup": "^1.7.1"
},
Expand All @@ -60,6 +61,7 @@
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.0.0",
"@types/js-yaml": "^4.0.9",
"@types/react-syntax-highlighter": "^15",
"msw": "^1.0.0",
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export function CatalogItemInstancesTabContent() {
onCancel={crud.handleCloseCreate}
submitLabel="Create"
submitting={crud.createSubmitting}
disabled={false}
disabled={!isInstanceFormValid(crud.createForm)}
/>
}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import {
Box,
Divider,
Expand Down Expand Up @@ -43,6 +43,7 @@ import {
buildUserValueRows,
InstanceForm,
validateInstanceForm,
validateUserValues,
} from '../instanceFormTypes';
import type { UserValueRow } from '../instanceFormTypes';

Expand All @@ -59,6 +60,7 @@ export type InstanceFormFieldsProps = Readonly<{

function fieldHelperText(row: UserValueRow): string {
const parts: string[] = [`path: ${row.path}`];
if (row.required) parts.push('required');
if (row.schemaType) parts.push(`type: ${row.schemaType}`);
if (row.schemaMin !== undefined) parts.push(`min: ${row.schemaMin}`);
if (row.schemaMax !== undefined) parts.push(`max: ${row.schemaMax}`);
Expand All @@ -74,6 +76,13 @@ export function InstanceFormFields({
}: InstanceFormFieldsProps) {
const classes = useStyles();
const errors = useMemo(() => validateInstanceForm(form), [form]);
const userValueErrors = useMemo(
() => validateUserValues(form.user_values),
[form.user_values],
);
const [userValuesTouched, setUserValuesTouched] = useState<
Record<number, boolean>
>({});
const selectedItem = catalogItems.find(ci => ci.uid === form.catalog_item_id);

const handleCatalogItemChange = (id: string) => {
Expand All @@ -84,6 +93,7 @@ export function InstanceFormFields({
user_values: buildUserValueRows(item),
}));
setTouched(prev => ({ ...prev, catalog_item_id: true }));
setUserValuesTouched({});
};

const setUserValue = (index: number, value: string) =>
Expand All @@ -93,6 +103,9 @@ export function InstanceFormFields({
return { ...prev, user_values: updated };
});

const touchUserValue = (index: number) =>
setUserValuesTouched(prev => ({ ...prev, [index]: true }));

return (
<Box display="flex" flexDirection="column" gridGap={16}>
<TextField
Expand Down Expand Up @@ -189,7 +202,12 @@ export function InstanceFormFields({
</Typography>
</Typography>
{form.user_values.map((row, i) => {
const helperText = fieldHelperText(row);
const isTouched = Boolean(userValuesTouched[i]);
const fieldError = isTouched ? userValueErrors[i] : undefined;
const label = row.required
? `${row.displayName} *`
: row.displayName;
const helperText = fieldError ?? fieldHelperText(row);

/* Enum field → Select */
if (row.enumValues && row.enumValues.length > 0) {
Expand All @@ -199,12 +217,17 @@ export function InstanceFormFields({
variant="outlined"
size="small"
fullWidth
error={Boolean(fieldError)}
>
<InputLabel shrink>{row.displayName}</InputLabel>
<InputLabel shrink>{label}</InputLabel>
<Select
value={row.value}
onChange={e => setUserValue(i, e.target.value as string)}
input={<OutlinedInput notched label={row.displayName} />}
onChange={e => {
setUserValue(i, e.target.value as string);
touchUserValue(i);
}}
onBlur={() => touchUserValue(i)}
input={<OutlinedInput notched label={label} />}
>
{row.enumValues.map(v => (
<MenuItem key={v} value={v}>
Expand All @@ -222,10 +245,12 @@ export function InstanceFormFields({
return (
<TextField
key={row.path}
label={row.displayName}
label={label}
helperText={helperText}
error={Boolean(fieldError)}
value={row.value}
onChange={e => setUserValue(i, e.target.value)}
onBlur={() => touchUserValue(i)}
fullWidth
variant="outlined"
size="small"
Expand All @@ -243,10 +268,12 @@ export function InstanceFormFields({
return (
<TextField
key={row.path}
label={row.displayName}
label={label}
helperText={helperText}
error={Boolean(fieldError)}
value={row.value}
onChange={e => setUserValue(i, e.target.value)}
onBlur={() => touchUserValue(i)}
fullWidth
variant="outlined"
size="small"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
UserValue,
} from '@red-hat-developer-hub/backstage-plugin-dcm-common';
import { createYupValidator } from '../../utils/createYupValidator';
import { pickNumericBound } from '../../utils/schemaUtils';

/** One user-supplied field value, with string value for form binding. */
export type UserValueRow = {
Expand All @@ -40,6 +41,8 @@ export type UserValueRow = {
schemaMin?: number;
/** Inclusive upper bound (from validation_schema.maximum). */
schemaMax?: number;
/** Whether the field must be filled (from validation_schema.required). */
required?: boolean;
};

export type InstanceForm = {
Expand All @@ -66,12 +69,14 @@ const instanceSchema = yup.object({
),
});

export const { validate: validateInstanceForm, isValid: isInstanceFormValid } =
createYupValidator<InstanceForm>(instanceSchema, f => ({
display_name: f.display_name,
catalog_item_id: f.catalog_item_id,
api_version: f.api_version,
}));
export const {
validate: validateInstanceForm,
isValid: isInstanceScalarValid,
} = createYupValidator<InstanceForm>(instanceSchema, f => ({
display_name: f.display_name,
catalog_item_id: f.catalog_item_id,
api_version: f.api_version,
}));

export function emptyInstanceForm(): InstanceForm {
return {
Expand All @@ -97,19 +102,68 @@ function defaultToString(val: unknown): string {
/** Extract typed schema metadata from a {@link FieldConfiguration}. */
function extractSchemaInfo(
field: FieldConfiguration,
): Pick<UserValueRow, 'schemaType' | 'enumValues' | 'schemaMin' | 'schemaMax'> {
): Pick<
UserValueRow,
'schemaType' | 'enumValues' | 'schemaMin' | 'schemaMax' | 'required'
> {
const schema = field.validation_schema ?? {};
const schemaType = typeof schema.type === 'string' ? schema.type : undefined;
const enumValues =
Array.isArray(schema.enum) &&
schema.enum.every(v => typeof v === 'string' || typeof v === 'number')
? (schema.enum as (string | number)[]).map(String)
: undefined;
const schemaMin =
typeof schema.minimum === 'number' ? schema.minimum : undefined;
const schemaMax =
typeof schema.maximum === 'number' ? schema.maximum : undefined;
return { schemaType, enumValues, schemaMin, schemaMax };
const schemaMin = pickNumericBound(schema, 'minimum', 'min');
const schemaMax = pickNumericBound(schema, 'maximum', 'max');
const required = schema.required === true;
return { schemaType, enumValues, schemaMin, schemaMax, required };
}

/**
* Validates user-value rows against their schema constraints.
* Returns a record keyed by row index; only rows with errors are included.
*/
export function validateUserValues(
rows: UserValueRow[],
): Record<number, string> {
const errors: Record<number, string> = {};

rows.forEach((row, i) => {
const v = row.value.trim();

if (row.required && v === '') {
errors[i] = 'This field is required';
return;
}

if (
v !== '' &&
(row.schemaType === 'integer' || row.schemaType === 'number')
) {
const n = Number(v);
if (Number.isNaN(n)) {
errors[i] = 'Must be a valid number';
return;
}
if (row.schemaMin !== undefined && n < row.schemaMin) {
errors[i] = `Must be at least ${row.schemaMin}`;
return;
}
if (row.schemaMax !== undefined && n > row.schemaMax) {
errors[i] = `Must be at most ${row.schemaMax}`;
return;
}
}
});

return errors;
}

export function isInstanceFormValid(f: InstanceForm): boolean {
return (
isInstanceScalarValid(f) &&
Object.keys(validateUserValues(f.user_values)).length === 0
);
}

/** Build UserValueRows from the selected catalog item's editable fields. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export function CatalogItemsTabContent() {
<Drawer
anchor="right"
open={open}
onClose={submitting ? undefined : onClose}
onClose={submitting || Boolean(error) ? undefined : onClose}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't the user trapped in when an error is rendered? The user would need to use the X button first, then close. Sounds weird but it might be he intention

Copy link
Copy Markdown
Contributor Author

@asmasarw asmasarw Jun 4, 2026

Choose a reason for hiding this comment

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

We have two scenarios:

  • User clicks on "X" -> Drawer closes always unless they're submitting an api request
  • User has an error and he click anywhere in the screen -> Drawer keeps open

And this is totally fine - we need to keep drawer open so user can fix the issues, unless he decided to go away and close it -> by pressing X icon.

>
<Box className={classes.drawer}>
<Box className={classes.drawerHeader}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
FieldConfiguration,
} from '@red-hat-developer-hub/backstage-plugin-dcm-common';
import { createYupValidator } from '../../utils/createYupValidator';
import { pickNumericBound } from '../../utils/schemaUtils';

export type FieldRow = {
/** Stable client-side identifier used as React list key. Never sent to the API. */
Expand Down Expand Up @@ -71,8 +72,110 @@
return f.fields.some(row => row.path.trim() !== '');
}

/** Per-row validation errors for a {@link FieldRow}. */
export type FieldRowErrors = {
path?: string;
default_value?: string;
validation_schema?: string;
};

/** Returns true if a string looks like intended JSON (and should therefore be valid JSON). */
function looksLikeJson(s: string): boolean {
return s.startsWith('{') || s.startsWith('[') || s.startsWith('"');
}

/**
* Validates all field rows for:
* - Duplicate paths (only non-empty paths are checked)
* - `default_value` that looks like JSON but fails to parse
* - `validation_schema` that is non-empty but not a valid JSON object
*
* Returns a record keyed by row index; only rows with errors are included.
*/

export function validateFieldRows(
fields: FieldRow[],
): Record<number, FieldRowErrors> {
const result: Record<number, FieldRowErrors> = {};
const seenPaths = new Map<string, number>();

fields.forEach((row, i) => {

Check failure on line 102 in workspaces/dcm/plugins/dcm/src/pages/catalog-items/catalogItemFormTypes.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 26 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ6Hs-Xb1czv4hSJ-MNd&open=AZ6Hs-Xb1czv4hSJ-MNd&pullRequest=3265
const rowErrors: FieldRowErrors = {};
const trimmedPath = row.path.trim();

if (trimmedPath !== '') {
if (seenPaths.has(trimmedPath)) {
rowErrors.path = 'Duplicate path — paths must be unique';
} else {
seenPaths.set(trimmedPath, i);
}
}

const defaultTrimmed = row.default_value.trim();
if (defaultTrimmed && looksLikeJson(defaultTrimmed)) {
try {
JSON.parse(defaultTrimmed);
} catch {
rowErrors.default_value =
'Invalid JSON — fix the syntax or use a plain string value';
}
}

const schemaTrimmed = row.validation_schema.trim();
let schemaMin: number | undefined;
let schemaMax: number | undefined;
if (schemaTrimmed) {
try {
const parsed = JSON.parse(schemaTrimmed);
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed)
) {
rowErrors.validation_schema =
'Must be a JSON object — e.g. {"type":"integer"}';
} else {
schemaMin = pickNumericBound(parsed, 'minimum', 'min');
schemaMax = pickNumericBound(parsed, 'maximum', 'max');
if (
schemaMin !== undefined &&
schemaMax !== undefined &&
schemaMin > schemaMax
) {
rowErrors.validation_schema = `minimum (${schemaMin}) must not exceed maximum (${schemaMax})`;
}
}
} catch {
rowErrors.validation_schema = 'Invalid JSON syntax';
}
}

const defaultNum = Number(defaultTrimmed);
if (
!rowErrors.default_value &&
!rowErrors.validation_schema &&
defaultTrimmed &&
Number.isFinite(defaultNum)
) {
if (schemaMin !== undefined && defaultNum < schemaMin) {
rowErrors.default_value = `Default value (${defaultNum}) is below the schema minimum (${schemaMin})`;
} else if (schemaMax !== undefined && defaultNum > schemaMax) {
rowErrors.default_value = `Default value (${defaultNum}) exceeds the schema maximum (${schemaMax})`;
}
}

if (Object.keys(rowErrors).length > 0) {
result[i] = rowErrors;
}
});

return result;
}

export function isCatalogItemFormValid(f: CatalogItemForm): boolean {
return Object.keys(validateScalar(f)).length === 0 && hasValidFields(f);
if (Object.keys(validateScalar(f)).length !== 0) return false;
if (!hasValidFields(f)) return false;
return Object.keys(validateFieldRows(f.fields)).length === 0;
}

export function emptyFieldRow(): FieldRow {
Expand Down
Loading
Loading