From 5b785b1bcd696f74965b8ac722c1389f45e84859 Mon Sep 17 00:00:00 2001 From: Scott J Dickerson Date: Fri, 7 Nov 2025 17:18:02 -0500 Subject: [PATCH 1/3] :bug: Update Cloud Foundry discover applications filter Resolves: https://issues.redhat.com/browse/MTA-6326 Resolves: #2709 CloudFoundry schema update: https://github.com/konveyor/operator/pull/484 Update the discover application wizard CloudFoundry source platform filter input and review to support organizations, spaces, and names. Added the ability to validate that two JSON schemas are functionally equivalent. This is used to validate that the Cloud Foundry schema is functionally equivalent to the schema used to build the Cloud Foundry forms. If a source platform is kind of "cloudfoundry", and the filter schema pulled from hub is functionally equivalent to the schema the components are based on, then show the custom components. If the schema is not equivalent, then show the generic schema defined fields components. This will most likely give the user a code editor to input a json document to define the filter. Signed-off-by: Scott J Dickerson --- client/config/jest.config.ts | 2 +- client/package.json | 4 +- .../filter-input-cloudfoundry.tsx | 113 +- .../discover-import-wizard/filter-input.tsx | 11 +- .../review-input-cloudfoundry.tsx | 28 +- .../discover-import-wizard/review.tsx | 11 +- .../validate-cloudfoundry-schema.tsx | 42 + .../schema-defined-fields/utils.test.tsx | 320 +--- .../schema-defined-fields/utils.tsx | 49 - .../step-capture-parameters.tsx | 7 +- client/src/app/utils/json-schema.test.ts | 1314 +++++++++++++++++ client/src/app/utils/json-schema.ts | 132 ++ cspell.json | 2 +- package-lock.json | 12 + 14 files changed, 1600 insertions(+), 447 deletions(-) create mode 100644 client/src/app/components/discover-import-wizard/validate-cloudfoundry-schema.tsx create mode 100644 client/src/app/utils/json-schema.test.ts create mode 100644 client/src/app/utils/json-schema.ts diff --git a/client/config/jest.config.ts b/client/config/jest.config.ts index c46728b6b..c649e4658 100644 --- a/client/config/jest.config.ts +++ b/client/config/jest.config.ts @@ -45,7 +45,7 @@ const config: JestConfigWithTsJest = { "^.+\\.[cm]?[jt]sx?$": "ts-jest", }, transformIgnorePatterns: [ - "node_modules/(?!(keycloak-js|react-error-boundary)/)", // process esm only modules + "node_modules/(?!(keycloak-js|react-error-boundary|lodash-es)/)", // process esm only modules ], // Code to set up the testing framework before each test file in the suite is executed diff --git a/client/package.json b/client/package.json index 949a01878..9bf55b90d 100644 --- a/client/package.json +++ b/client/package.json @@ -44,6 +44,7 @@ "immer": "^10.2.0", "js-yaml": "^4.1.0", "keycloak-js": "^26.1.0", + "lodash-es": "^4.17.21", "monaco-editor": "^0.52.2", "radash": "^12.1.0", "react": "^18.3.1", @@ -68,6 +69,7 @@ "@types/file-saver": "^2.0.7", "@types/jest": "^29.5.4", "@types/js-yaml": "^4.0.9", + "@types/lodash-es": "^4.17.12", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@types/react-measure": "^2.0.12", @@ -80,10 +82,10 @@ "css-minimizer-webpack-plugin": "^7.0.2", "fork-ts-checker-webpack-plugin": "^9.1.0", "html-webpack-plugin": "^5.6.3", + "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-resolve": "^29.7.0", "jest-watch-typeahead": "^2.2.2", - "jest": "^29.7.0", "mini-css-extract-plugin": "^2.8.1", "monaco-editor-webpack-plugin": "^7.1.1", "msw": "^1.2.3", diff --git a/client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx b/client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx index 690089454..5ecc71d51 100644 --- a/client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx +++ b/client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx @@ -61,38 +61,10 @@ const RemoveButton = ({ ); }; -/* -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "names": { - "description": "Application names. Each may be a glob expression.", - "items": { - "minLength": 1, - "type": "string" - }, - "minItems": 0, - "type": "array" - }, - "spaces": { - "description": "Space names.", - "items": { - "minLength": 1, - "type": "string" - }, - "minItems": 1, - "type": "array" - } - }, - "required": ["spaces"], - "title": "CloudFoundry Discover Filter", - "type": "object" -} -*/ - interface FormValues { - names: { value: string }[]; + organizations: { value: string }[]; spaces: { value: string }[]; + names: { value: string }[]; } const stringsToFormValue = (values?: string[]) => @@ -108,40 +80,40 @@ export interface FilterInputCloudFoundryProps { } /** - * Inputs for CloudFoundry discover applications filter. This is based on the json schema. + * Inputs for CloudFoundry discover applications filter. + * + * This is based on the JSON schema for the Cloud Foundry discovery filter as defined in + * {@link ./validate-cloudfoundry-schema.tsx}. */ export const FilterInputCloudFoundry: React.FC< FilterInputCloudFoundryProps > = ({ id, values, onDocumentChanged }) => { const validationSchema = yup.object().shape({ - names: yup - .array() - .of( - yup.object().shape({ - value: yup - .string() - .min(1, "Name must be at least 1 character") - .trim(), - }) - ) - .min(0), - spaces: yup - .array() - .of( - yup.object().shape({ - value: yup - .string() - .min(1, "Space must be at least 1 character") - .trim(), - }) - ) - .min(1), + organizations: yup.array().of( + yup.object().shape({ + value: yup + .string() + .min(1, "Organization name must be at least 1 character") + .trim(), + }) + ), + spaces: yup.array().of( + yup.object().shape({ + value: yup.string().min(1, "Space must be at least 1 character").trim(), + }) + ), + names: yup.array().of( + yup.object().shape({ + value: yup.string().min(1, "Name must be at least 1 character").trim(), + }) + ), }); const form = useForm({ defaultValues: { - names: stringsToFormValue(values?.names as string[]), + organizations: stringsToFormValue(values?.organizations as string[]), spaces: stringsToFormValue(values?.spaces as string[]), + names: stringsToFormValue(values?.names as string[]), }, resolver: yupResolver(validationSchema), mode: "all", @@ -154,8 +126,9 @@ export const FilterInputCloudFoundry: React.FC< formState: { values: true }, callback: ({ values }) => { const asDocument = { - names: formValueToStrings(values.names), + organizations: formValueToStrings(values.organizations), spaces: formValueToStrings(values.spaces), + names: formValueToStrings(values.names), }; onDocumentChanged(asDocument); }, @@ -168,12 +141,13 @@ export const FilterInputCloudFoundry: React.FC< @@ -185,8 +159,21 @@ export const FilterInputCloudFoundry: React.FC< fieldName="spaces" addLabel="Add a space" removeLabel="Remove this space" - emptyMessage="No spaces specified (at least one space is required)" - isRequired={true} + emptyMessage="No spaces specified" + isRequired={false} + /> + + + + diff --git a/client/src/app/components/discover-import-wizard/filter-input.tsx b/client/src/app/components/discover-import-wizard/filter-input.tsx index 0d0df1733..f7e091494 100644 --- a/client/src/app/components/discover-import-wizard/filter-input.tsx +++ b/client/src/app/components/discover-import-wizard/filter-input.tsx @@ -14,6 +14,7 @@ import { useFetchPlatformDiscoveryFilterSchema } from "@app/queries/schemas"; import { wrapAsEvent } from "@app/utils/utils"; import { FilterInputCloudFoundry } from "./filter-input-cloudfoundry"; +import { validateCloudFoundrySchema } from "./validate-cloudfoundry-schema"; interface FiltersFormValues { filterRequired: boolean; @@ -106,6 +107,14 @@ export const FilterInput: React.FC<{ useFilterStateChangeHandler(form, onFiltersChanged); + const useCloudFoundryFilterInput = React.useMemo(() => { + return ( + platform.kind === "cloudfoundry" && + filtersSchema && + validateCloudFoundrySchema(filtersSchema.definition) + ); + }, [platform.kind, filtersSchema]); + return (
@@ -140,7 +149,7 @@ export const FilterInput: React.FC<{ })} fieldId="document" renderInput={({ field: { value, name, onChange } }) => - platform.kind === "cloudfoundry" ? ( + useCloudFoundryFilterInput ? ( ; } - const names = (values.names as string[]) ?? []; + const organizations = (values.organizations as string[]) ?? []; const spaces = (values.spaces as string[]) ?? []; + const names = (values.names as string[]) ?? []; return ( - Names + Organizations - {names.length === 0 ? ( - + {organizations.length === 0 ? ( + ) : ( - {names.map((name) => ( - {name} + {organizations.map((organization) => ( + {organization} ))} )} @@ -60,6 +61,21 @@ export const ReviewInputCloudFoundry: React.FC< )} + + + Names + + {names.length === 0 ? ( + + ) : ( + + {names.map((name) => ( + {name} + ))} + + )} + + ); }; diff --git a/client/src/app/components/discover-import-wizard/review.tsx b/client/src/app/components/discover-import-wizard/review.tsx index 652496897..da502f7c6 100644 --- a/client/src/app/components/discover-import-wizard/review.tsx +++ b/client/src/app/components/discover-import-wizard/review.tsx @@ -16,6 +16,7 @@ import { usePlatformKindList } from "@app/hooks/usePlatformKindList"; import { FilterState } from "./filter-input"; import { ReviewInputCloudFoundry } from "./review-input-cloudfoundry"; +import { validateCloudFoundrySchema } from "./validate-cloudfoundry-schema"; export const Review: React.FC<{ platform: SourcePlatform; @@ -27,6 +28,14 @@ export const Review: React.FC<{ const showFilters = filters.filterRequired && filters.schema && filters.document; + const useCloudFoundryReview = React.useMemo(() => { + return ( + platform.kind === "cloudfoundry" && + filters.schema && + validateCloudFoundrySchema(filters.schema.definition) + ); + }, [platform.kind, filters.schema]); + return (
@@ -82,7 +91,7 @@ export const Review: React.FC<{ padding: "16px", }} > - {platform.kind === "cloudfoundry" ? ( + {useCloudFoundryReview ? ( { it("should return a yup schema", () => { @@ -172,317 +168,3 @@ describe("isComplexSchema", () => { expect(isComplexSchema(schema)).toBe(true); }); }); - -describe("combineSchemas", () => { - it("returns undefined when input is undefined", () => { - expect(combineSchemas(undefined)).toBeUndefined(); - }); - - it("returns undefined when input is null", () => { - expect(combineSchemas(null as any)).toBeUndefined(); - }); - - it("returns a base schema when given an empty array", () => { - const result = combineSchemas([]); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: {}, - required: [], - }); - }); - - it("combines properties from a single object schema", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }); - }); - - it("combines properties from multiple object schemas", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }, - { - type: "object", - properties: { - email: { type: "string" }, - active: { type: "boolean" }, - }, - required: ["email"], - }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - email: { type: "string" }, - active: { type: "boolean" }, - }, - required: ["name", "email"], - }); - }); - - it("overwrites properties when there are duplicates (later ones win)", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - name: { type: "string", minLength: 1 }, - age: { type: "number" }, - }, - }, - { - type: "object", - properties: { - name: { type: "string", minLength: 5, maxLength: 50 }, - email: { type: "string" }, - }, - }, - ]; - - const result = combineSchemas(schemas); - expect(result?.properties?.name).toEqual({ - type: "string", - minLength: 5, - maxLength: 50, - }); - expect(result?.properties?.age).toEqual({ type: "number" }); - expect(result?.properties?.email).toEqual({ type: "string" }); - }); - - it("combines required fields uniquely (no duplicates)", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { name: { type: "string" } }, - required: ["name", "age"], - }, - { - type: "object", - properties: { email: { type: "string" } }, - required: ["email", "name"], // "name" is duplicate - }, - { - type: "object", - properties: { active: { type: "boolean" } }, - required: ["active"], - }, - ]; - - const result = combineSchemas(schemas); - expect(result?.required).toEqual(["name", "age", "email", "active"]); - }); - - it("ignores non-object schemas", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "string", // This should be ignored - }, - { - type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], - }, - { - type: "array", // This should be ignored - items: { type: "string" }, - }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], - }); - }); - - it("handles schemas without properties", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - required: ["name"], - }, - { - type: "object", - properties: { - email: { type: "string" }, - }, - }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - email: { type: "string" }, - }, - required: ["name"], - }); - }); - - it("handles schemas without required fields", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - name: { type: "string" }, - }, - }, - { - type: "object", - properties: { - email: { type: "string" }, - }, - required: ["email"], - }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - name: { type: "string" }, - email: { type: "string" }, - }, - required: ["email"], - }); - }); - - it("handles complex nested object properties", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - user: { - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }, - }, - required: ["user"], - }, - { - type: "object", - properties: { - settings: { - type: "object", - properties: { - theme: { type: "string" }, - notifications: { type: "boolean" }, - }, - }, - }, - }, - ]; - - const result = combineSchemas(schemas); - expect(result?.properties?.user).toEqual({ - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }); - expect(result?.properties?.settings).toEqual({ - type: "object", - properties: { - theme: { type: "string" }, - notifications: { type: "boolean" }, - }, - }); - expect(result?.required).toEqual(["user"]); - }); - - it("handles schemas with array properties", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - tags: { - type: "array", - items: { type: "string" }, - }, - name: { type: "string" }, - }, - required: ["name"], - }, - { - type: "object", - properties: { - scores: { - type: "array", - items: { type: "number" }, - }, - }, - }, - ]; - - const result = combineSchemas(schemas); - expect(result?.properties?.tags).toEqual({ - type: "array", - items: { type: "string" }, - }); - expect(result?.properties?.scores).toEqual({ - type: "array", - items: { type: "number" }, - }); - expect(result?.properties?.name).toEqual({ type: "string" }); - expect(result?.required).toEqual(["name"]); - }); - - it("returns base schema when all schemas are non-object types", () => { - const schemas: JsonSchemaObject[] = [ - { type: "string" }, - { type: "number" }, - { type: "boolean" }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: {}, - required: [], - }); - }); -}); diff --git a/client/src/app/components/schema-defined-fields/utils.tsx b/client/src/app/components/schema-defined-fields/utils.tsx index b06161295..f7630601b 100644 --- a/client/src/app/components/schema-defined-fields/utils.tsx +++ b/client/src/app/components/schema-defined-fields/utils.tsx @@ -1,6 +1,5 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { TFunction } from "i18next"; -import { unique } from "radash"; import * as yup from "yup"; import { JsonSchemaObject } from "@app/api/models"; @@ -167,51 +166,3 @@ export const isComplexSchema = (schema: JsonSchemaObject): boolean => { return false; }; - -export const isSchemaEmpty = (schema?: JsonSchemaObject): boolean => { - if (!schema) return true; - - if (schema.type === "object") { - return Object.keys(schema.properties ?? {}).length === 0; - } - - if (schema.type === "array") { - return isSchemaEmpty(schema.items); - } - - return ["string", "number", "boolean", "integer"].includes(schema.type); -}; - -/** - * Combines multiple schemas into a single schema. Only supports schemas with a root type of "object". - */ -export const combineSchemas = ( - schemas?: JsonSchemaObject[] -): JsonSchemaObject | undefined => { - if (!schemas) return undefined; - - const baseSchema: JsonSchemaObject = { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: {}, - required: [], - }; - - const combinedSchema = schemas.reduce((acc, schema) => { - if (schema.type === "object") { - // add all properties to the base schema, overwriting any existing properties - if (schema.properties) { - Object.entries(schema.properties).forEach(([key, value]) => { - acc.properties![key] = value; - }); - } - // uniquely add all required properties to the base schema - if (schema.required) { - acc.required = unique([...(acc.required ?? []), ...schema.required]); - } - } - return acc; - }, baseSchema); - - return combinedSchema; -}; diff --git a/client/src/app/pages/applications/generate-assets-wizard/step-capture-parameters.tsx b/client/src/app/pages/applications/generate-assets-wizard/step-capture-parameters.tsx index d3c047c40..49340db6d 100644 --- a/client/src/app/pages/applications/generate-assets-wizard/step-capture-parameters.tsx +++ b/client/src/app/pages/applications/generate-assets-wizard/step-capture-parameters.tsx @@ -17,12 +17,9 @@ import { import { JsonDocument, JsonSchemaObject, TargetProfile } from "@app/api/models"; import { HookFormPFGroupController } from "@app/components/HookFormPFFields"; import { SchemaDefinedField } from "@app/components/schema-defined-fields"; -import { - combineSchemas, - isSchemaEmpty, - jsonSchemaToYupSchema, -} from "@app/components/schema-defined-fields/utils"; +import { jsonSchemaToYupSchema } from "@app/components/schema-defined-fields/utils"; import { useFetchGenerators } from "@app/queries/generators"; +import { combineSchemas, isSchemaEmpty } from "@app/utils/json-schema"; import { wrapAsEvent } from "@app/utils/utils"; export interface ParameterState { diff --git a/client/src/app/utils/json-schema.test.ts b/client/src/app/utils/json-schema.test.ts new file mode 100644 index 000000000..59c015a43 --- /dev/null +++ b/client/src/app/utils/json-schema.test.ts @@ -0,0 +1,1314 @@ +import { JsonSchemaObject } from "@app/api/models"; + +import { + combineSchemas, + isEquivalentSchema, + stripAnnotations, + validatorGenerator, +} from "./json-schema"; + +describe("stripAnnotations", () => { + it("returns primitives as-is", () => { + expect(stripAnnotations("test")).toBe("test"); + expect(stripAnnotations(42)).toBe(42); + expect(stripAnnotations(true)).toBe(true); + expect(stripAnnotations(false)).toBe(false); + }); + + it("returns null as-is", () => { + expect(stripAnnotations(null)).toBe(null); + }); + + it("returns undefined as-is", () => { + expect(stripAnnotations(undefined)).toBe(undefined); + }); + + it("strips annotation keywords from a simple object", () => { + const schema = { + type: "string", + title: "User Name", + description: "The name of the user", + minLength: 1, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "string", + minLength: 1, + }); + }); + + it("strips all annotation keywords", () => { + const schema = { + type: "object", + title: "Test", + description: "A test object", + default: { foo: "bar" }, + examples: [{ foo: "baz" }], + $comment: "This is a comment", + deprecated: true, + readOnly: true, + writeOnly: false, + properties: { + name: { type: "string" }, + }, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + }); + }); + + it("preserves $schema keyword", () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + title: "Should be removed", + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + }); + }); + + it("recursively strips annotations from nested objects", () => { + const schema = { + type: "object", + title: "User", + properties: { + name: { + type: "string", + title: "Name", + description: "User's name", + minLength: 1, + }, + profile: { + type: "object", + description: "User profile", + properties: { + age: { + type: "number", + title: "Age", + minimum: 0, + }, + }, + }, + }, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + profile: { + type: "object", + properties: { + age: { + type: "number", + minimum: 0, + }, + }, + }, + }, + }); + }); + + it("strips annotations from array items", () => { + const schema = { + type: "array", + title: "Tags", + description: "List of tags", + items: { + type: "string", + title: "Tag", + description: "A single tag", + minLength: 1, + }, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "array", + items: { + type: "string", + minLength: 1, + }, + }); + }); + + it("handles arrays of schemas", () => { + const schemas = [ + { + type: "string", + title: "First", + minLength: 1, + }, + { + type: "number", + description: "Second", + minimum: 0, + }, + ]; + + const result = stripAnnotations(schemas); + expect(result).toEqual([ + { + type: "string", + minLength: 1, + }, + { + type: "number", + minimum: 0, + }, + ]); + }); + + it("handles empty objects", () => { + const schema = {}; + const result = stripAnnotations(schema); + expect(result).toEqual({}); + }); + + it("handles empty arrays", () => { + const schema: JsonSchemaObject[] = []; + const result = stripAnnotations(schema); + expect(result).toEqual([]); + }); + + it("preserves required array", () => { + const schema = { + type: "object", + title: "User", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name", "email"], + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name", "email"], + }); + }); + + it("preserves validation keywords like minLength, maxLength, pattern", () => { + const schema = { + type: "string", + title: "Email", + description: "User's email address", + minLength: 5, + maxLength: 100, + pattern: "^[a-z]+@[a-z]+\\.[a-z]+$", + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "string", + minLength: 5, + maxLength: 100, + pattern: "^[a-z]+@[a-z]+\\.[a-z]+$", + }); + }); + + it("preserves numeric validation keywords", () => { + const schema = { + type: "number", + title: "Age", + minimum: 0, + maximum: 120, + multipleOf: 1, + exclusiveMinimum: false, + exclusiveMaximum: true, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "number", + minimum: 0, + maximum: 120, + multipleOf: 1, + exclusiveMinimum: false, + exclusiveMaximum: true, + }); + }); + + it("handles complex nested structures with mixed arrays and objects", () => { + const schema = { + type: "object", + title: "Complex Schema", + properties: { + users: { + type: "array", + description: "List of users", + items: { + type: "object", + title: "User", + properties: { + name: { + type: "string", + description: "Name", + minLength: 1, + }, + tags: { + type: "array", + description: "Tags", + items: { + type: "string", + title: "Tag", + }, + }, + }, + }, + }, + }, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "object", + properties: { + users: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + tags: { + type: "array", + items: { + type: "string", + }, + }, + }, + }, + }, + }, + }); + }); + + it("does not mutate the original schema", () => { + const original = { + type: "string", + title: "Test", + description: "A test", + minLength: 1, + }; + const originalCopy = { ...original }; + + stripAnnotations(original); + + expect(original).toEqual(originalCopy); + }); + + it("handles schemas with only annotation keywords", () => { + const schema = { + title: "Only Annotations", + description: "This has only annotations", + examples: ["example1", "example2"], + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({}); + }); +}); + +describe("combineSchemas", () => { + it("returns undefined when input is undefined", () => { + expect(combineSchemas(undefined)).toBeUndefined(); + }); + + it("returns undefined when input is null", () => { + expect(combineSchemas(null as any)).toBeUndefined(); + }); + + it("returns a base schema when given an empty array", () => { + const result = combineSchemas([]); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }); + }); + + it("combines properties from a single object schema", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }); + }); + + it("combines properties from multiple object schemas", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }, + { + type: "object", + properties: { + email: { type: "string" }, + active: { type: "boolean" }, + }, + required: ["email"], + }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + email: { type: "string" }, + active: { type: "boolean" }, + }, + required: ["name", "email"], + }); + }); + + it("overwrites properties when there are duplicates (later ones win)", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + age: { type: "number" }, + }, + }, + { + type: "object", + properties: { + name: { type: "string", minLength: 5, maxLength: 50 }, + email: { type: "string" }, + }, + }, + ]; + + const result = combineSchemas(schemas); + expect(result?.properties?.name).toEqual({ + type: "string", + minLength: 5, + maxLength: 50, + }); + expect(result?.properties?.age).toEqual({ type: "number" }); + expect(result?.properties?.email).toEqual({ type: "string" }); + }); + + it("combines required fields uniquely (no duplicates)", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { name: { type: "string" } }, + required: ["name", "age"], + }, + { + type: "object", + properties: { email: { type: "string" } }, + required: ["email", "name"], // "name" is duplicate + }, + { + type: "object", + properties: { active: { type: "boolean" } }, + required: ["active"], + }, + ]; + + const result = combineSchemas(schemas); + expect(result?.required).toEqual(["name", "age", "email", "active"]); + }); + + it("ignores non-object schemas", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "string", // This should be ignored + }, + { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + { + type: "array", // This should be ignored + items: { type: "string" }, + }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }); + }); + + it("handles schemas without properties", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + required: ["name"], + }, + { + type: "object", + properties: { + email: { type: "string" }, + }, + }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + email: { type: "string" }, + }, + required: ["name"], + }); + }); + + it("handles schemas without required fields", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + { + type: "object", + properties: { + email: { type: "string" }, + }, + required: ["email"], + }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["email"], + }); + }); + + it("handles complex nested object properties", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }, + }, + required: ["user"], + }, + { + type: "object", + properties: { + settings: { + type: "object", + properties: { + theme: { type: "string" }, + notifications: { type: "boolean" }, + }, + }, + }, + }, + ]; + + const result = combineSchemas(schemas); + expect(result?.properties?.user).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }); + expect(result?.properties?.settings).toEqual({ + type: "object", + properties: { + theme: { type: "string" }, + notifications: { type: "boolean" }, + }, + }); + expect(result?.required).toEqual(["user"]); + }); + + it("handles schemas with array properties", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + tags: { + type: "array", + items: { type: "string" }, + }, + name: { type: "string" }, + }, + required: ["name"], + }, + { + type: "object", + properties: { + scores: { + type: "array", + items: { type: "number" }, + }, + }, + }, + ]; + + const result = combineSchemas(schemas); + expect(result?.properties?.tags).toEqual({ + type: "array", + items: { type: "string" }, + }); + expect(result?.properties?.scores).toEqual({ + type: "array", + items: { type: "number" }, + }); + expect(result?.properties?.name).toEqual({ type: "string" }); + expect(result?.required).toEqual(["name"]); + }); + + it("returns base schema when all schemas are non-object types", () => { + const schemas: JsonSchemaObject[] = [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }); + }); +}); + +describe("isEquivalentSchema", () => { + it("returns true for identical schemas", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + age: { type: "number", minimum: 0 }, + }, + required: ["name"], + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + age: { type: "number", minimum: 0 }, + }, + required: ["name"], + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); + + it("returns true for schemas that differ only in annotations", () => { + const schemaA: JsonSchemaObject = { + type: "object", + title: "User Schema", + description: "A schema for users", + properties: { + name: { + type: "string", + title: "Name", + description: "User's name", + minLength: 1, + }, + }, + }; + const schemaB: JsonSchemaObject = { + type: "object", + title: "Person Schema", + description: "A schema for persons", + properties: { + name: { + type: "string", + title: "Full Name", + description: "Person's full name", + minLength: 1, + }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); + + it("returns false for schemas with different types", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { name: { type: "string" } }, + }; + const schemaB: JsonSchemaObject = { + type: "array", + items: { type: "string" }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("returns false for schemas with different properties", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("returns false for schemas with different validation rules", () => { + const schemaA: JsonSchemaObject = { + type: "string", + minLength: 1, + maxLength: 10, + }; + const schemaB: JsonSchemaObject = { + type: "string", + minLength: 1, + maxLength: 20, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("returns false for schemas with different required fields", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name"], + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name", "email"], + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("returns true when property order differs", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + email: { type: "string" }, + }, + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + email: { type: "string" }, + name: { type: "string" }, + age: { type: "number" }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); + + it("returns true for nested schemas differing only in annotations", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + title: "User Info", + properties: { + name: { type: "string", description: "Name field" }, + }, + }, + }, + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + title: "User Data", + properties: { + name: { type: "string", description: "Full name field" }, + }, + }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); + + it("returns false for nested schemas with different structure", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }, + }, + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("returns true for array schemas differing only in annotations", () => { + const schemaA: JsonSchemaObject = { + type: "array", + title: "Tags", + items: { + type: "string", + description: "A tag", + minLength: 1, + }, + }; + const schemaB: JsonSchemaObject = { + type: "array", + title: "Labels", + items: { + type: "string", + description: "A label", + minLength: 1, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); + + it("returns false for array schemas with different item types", () => { + const schemaA: JsonSchemaObject = { + type: "array", + items: { type: "string" }, + }; + const schemaB: JsonSchemaObject = { + type: "array", + items: { type: "number" }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("handles complex Cloud Foundry schema with annotations", () => { + const schemaA: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + title: "Cloud Foundry Filter", + properties: { + organizations: { + title: "Organizations", + description: "Organization names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + spaces: { + title: "Spaces", + description: "Space names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + names: { + title: "Application Names", + description: "Application names. Each may be a glob expression.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + }, + }; + + const schemaB: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + organizations: { + description: "Org names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + spaces: { + description: "Space names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + names: { + description: "App names. Glob expressions allowed.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); +}); + +describe("validatorGenerator", () => { + it("generates a validator that returns true for equivalent schemas", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + expect(validator(testSchema)).toBe(true); + }); + + it("generates a validator that ignores annotations in base schema", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + title: "User", + description: "A user schema", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + expect(validator(testSchema)).toBe(true); + }); + + it("generates a validator that ignores annotations in test schema", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + title: "Person", + description: "A person schema", + properties: { + name: { + type: "string", + title: "Name", + description: "Person's name", + minLength: 1, + }, + }, + }; + + expect(validator(testSchema)).toBe(true); + }); + + it("generates a validator that returns false for non-equivalent schemas", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 5 }, + }, + }; + + expect(validator(testSchema)).toBe(false); + }); + + it("generates a validator that returns false for different types", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { name: { type: "string" } }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "array", + items: { type: "string" }, + }; + + expect(validator(testSchema)).toBe(false); + }); + + it("generates a validator that returns false for missing properties", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + }, + }; + + expect(validator(testSchema)).toBe(false); + }); + + it("generates a validator that returns false for extra properties", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }; + + expect(validator(testSchema)).toBe(false); + }); + + it("generates a validator that returns false for different required fields", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name"], + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name", "email"], + }; + + expect(validator(testSchema)).toBe(false); + }); + + it("generates a reusable validator for multiple comparisons", () => { + const baseSchema: JsonSchemaObject = { + type: "string", + minLength: 1, + maxLength: 100, + }; + + const validator = validatorGenerator(baseSchema); + + const validSchema1: JsonSchemaObject = { + type: "string", + title: "Name", + minLength: 1, + maxLength: 100, + }; + + const validSchema2: JsonSchemaObject = { + type: "string", + description: "A string value", + minLength: 1, + maxLength: 100, + }; + + const invalidSchema: JsonSchemaObject = { + type: "string", + minLength: 1, + maxLength: 50, + }; + + expect(validator(validSchema1)).toBe(true); + expect(validator(validSchema2)).toBe(true); + expect(validator(invalidSchema)).toBe(false); + }); + + it("generates a validator for Cloud Foundry schema", () => { + const baseSchema: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + organizations: { + description: "Organization names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + spaces: { + description: "Space names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + names: { + description: "Application names. Each may be a glob expression.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const validSchema: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + title: "CF Filter", + properties: { + organizations: { + title: "Orgs", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + spaces: { + title: "Spaces", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + names: { + title: "Apps", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + }, + }; + + const invalidSchema: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + organizations: { + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + spaces: { + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + // Missing 'names' property + }, + }; + + expect(validator(validSchema)).toBe(true); + expect(validator(invalidSchema)).toBe(false); + }); + + it("generates a validator that handles nested schemas", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const validSchema: JsonSchemaObject = { + type: "object", + title: "Container", + properties: { + user: { + type: "object", + title: "User Info", + properties: { + name: { type: "string", description: "Name" }, + age: { type: "number", description: "Age" }, + }, + }, + }, + }; + + const invalidSchema: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, // Different property + }, + }, + }, + }; + + expect(validator(validSchema)).toBe(true); + expect(validator(invalidSchema)).toBe(false); + }); +}); diff --git a/client/src/app/utils/json-schema.ts b/client/src/app/utils/json-schema.ts new file mode 100644 index 000000000..ddb210302 --- /dev/null +++ b/client/src/app/utils/json-schema.ts @@ -0,0 +1,132 @@ +import { isEqual } from "lodash-es"; +import { unique } from "radash"; + +import { JsonSchemaObject } from "@app/api/models"; + +/** + * Check if the given schema is functionally empty. + */ +export const isSchemaEmpty = (schema?: JsonSchemaObject): boolean => { + if (!schema) return true; + + if (schema.type === "object") { + return Object.keys(schema.properties ?? {}).length === 0; + } + + if (schema.type === "array") { + return isSchemaEmpty(schema.items); + } + + return ["string", "number", "boolean", "integer"].includes(schema.type); +}; + +/** + * Combines multiple schemas into a single schema. Only supports schemas with a root type of "object". + */ +export const combineSchemas = ( + schemas?: JsonSchemaObject[] +): JsonSchemaObject | undefined => { + if (!schemas) return undefined; + + const baseSchema: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }; + + const combinedSchema = schemas.reduce((acc, schema) => { + if (schema.type === "object") { + // add all properties to the base schema, overwriting any existing properties + if (schema.properties) { + Object.entries(schema.properties).forEach(([key, value]) => { + acc.properties![key] = value; + }); + } + // uniquely add all required properties to the base schema + if (schema.required) { + acc.required = unique([...(acc.required ?? []), ...schema.required]); + } + } + return acc; + }, baseSchema); + + return combinedSchema; +}; + +/** + * A Set of all JSON Schema annotation keywords. + * These keywords describe the schema but do not enforce validation rules. + * We will strip these before comparing for functional equivalence. + */ +const ANNOTATION_KEYWORDS = new Set([ + "title", + "description", + "default", + "examples", + "$comment", + "deprecated", + "readOnly", + "writeOnly", + // '$schema' is debatable, but for "functional equivalence" of a given + // schema instance, it's often best to compare it too. +]); + +/** + * Recursively strips annotation keywords from a JSON schema object. + * @param schema The schema (or sub-schema) to strip. + * @returns A new object containing only the functional keywords. + */ +export function stripAnnotations(schema: T): T { + // Base case: not an object or null, return as-is + if (typeof schema !== "object" || schema === null) { + return schema; + } + + // Handle arrays by recursively stripping each element + if (Array.isArray(schema)) { + return schema.map(stripAnnotations) as T; + } + + // Handle objects by building a new object without annotation keys + const strippedSchema: Record = {}; + for (const key in schema) { + if (Object.prototype.hasOwnProperty.call(schema, key)) { + if (!ANNOTATION_KEYWORDS.has(key)) { + // This is a functional key. Recurse on its value. + strippedSchema[key] = stripAnnotations(schema[key]); + } + } + } + + return strippedSchema as T; +} + +/** + * Checks if an incoming JSON schema is functionally equivalent to + * another JSON schema. + */ +export const isEquivalentSchema = ( + schemaA: JsonSchemaObject, + schemaB: JsonSchemaObject +): boolean => { + // 1. Strip annotations from the incoming schema + const functionalSchemaA = stripAnnotations(schemaA); + const functionalSchemaB = stripAnnotations(schemaB); + + // 2. Deep-compare the two functional schemas. + // `isEqual` handles deep comparison and is not sensitive to key order. + return isEqual(functionalSchemaA, functionalSchemaB); +}; + +/** + * Generates a validator function that checks if an incoming JSON schema is functionally equivalent to + * a static base schema. + */ +export const validatorGenerator = (baseSchema: JsonSchemaObject) => { + const functionalBaseSchema = stripAnnotations(baseSchema); + return (schema: JsonSchemaObject) => { + const functionalSchema = stripAnnotations(schema); + return isEqual(functionalBaseSchema, functionalSchema); + }; +}; diff --git a/cspell.json b/cspell.json index c0f5a570c..3476f3ddd 100644 --- a/cspell.json +++ b/cspell.json @@ -2,6 +2,7 @@ "language": "en", "words": [ "cancelation", + "cloudfoundry", "colour", "coreutils", "iconed", @@ -10,7 +11,6 @@ "radash", "ruleset", "rulesets", - "skopeo", "taskgroup", "taskgroups", "toastr", diff --git a/package-lock.json b/package-lock.json index 6e4668215..e1cf01e0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "immer": "^10.2.0", "js-yaml": "^4.1.0", "keycloak-js": "^26.1.0", + "lodash-es": "^4.17.21", "monaco-editor": "^0.52.2", "radash": "^12.1.0", "react": "^18.3.1", @@ -111,6 +112,7 @@ "@types/file-saver": "^2.0.7", "@types/jest": "^29.5.4", "@types/js-yaml": "^4.0.9", + "@types/lodash-es": "^4.17.12", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@types/react-measure": "^2.0.12", @@ -5518,6 +5520,16 @@ "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", "license": "MIT" }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", From 5f8d83c866768f543a100372289620c6aeb442de Mon Sep 17 00:00:00 2001 From: Scott J Dickerson Date: Fri, 7 Nov 2025 17:39:08 -0500 Subject: [PATCH 2/3] add useCloudFoundryCheck hook Signed-off-by: Scott J Dickerson --- .../discover-import-wizard/filter-input.tsx | 13 +++++------- .../discover-import-wizard/review.tsx | 10 ++------- .../validate-cloudfoundry-schema.tsx | 21 ++++++++++++++++++- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/client/src/app/components/discover-import-wizard/filter-input.tsx b/client/src/app/components/discover-import-wizard/filter-input.tsx index f7e091494..68a390bca 100644 --- a/client/src/app/components/discover-import-wizard/filter-input.tsx +++ b/client/src/app/components/discover-import-wizard/filter-input.tsx @@ -14,7 +14,7 @@ import { useFetchPlatformDiscoveryFilterSchema } from "@app/queries/schemas"; import { wrapAsEvent } from "@app/utils/utils"; import { FilterInputCloudFoundry } from "./filter-input-cloudfoundry"; -import { validateCloudFoundrySchema } from "./validate-cloudfoundry-schema"; +import { useCloudFoundryCheck } from "./validate-cloudfoundry-schema"; interface FiltersFormValues { filterRequired: boolean; @@ -107,13 +107,10 @@ export const FilterInput: React.FC<{ useFilterStateChangeHandler(form, onFiltersChanged); - const useCloudFoundryFilterInput = React.useMemo(() => { - return ( - platform.kind === "cloudfoundry" && - filtersSchema && - validateCloudFoundrySchema(filtersSchema.definition) - ); - }, [platform.kind, filtersSchema]); + const useCloudFoundryFilterInput = useCloudFoundryCheck( + platform, + filtersSchema + ); return (
diff --git a/client/src/app/components/discover-import-wizard/review.tsx b/client/src/app/components/discover-import-wizard/review.tsx index da502f7c6..f27f91733 100644 --- a/client/src/app/components/discover-import-wizard/review.tsx +++ b/client/src/app/components/discover-import-wizard/review.tsx @@ -16,7 +16,7 @@ import { usePlatformKindList } from "@app/hooks/usePlatformKindList"; import { FilterState } from "./filter-input"; import { ReviewInputCloudFoundry } from "./review-input-cloudfoundry"; -import { validateCloudFoundrySchema } from "./validate-cloudfoundry-schema"; +import { useCloudFoundryCheck } from "./validate-cloudfoundry-schema"; export const Review: React.FC<{ platform: SourcePlatform; @@ -28,13 +28,7 @@ export const Review: React.FC<{ const showFilters = filters.filterRequired && filters.schema && filters.document; - const useCloudFoundryReview = React.useMemo(() => { - return ( - platform.kind === "cloudfoundry" && - filters.schema && - validateCloudFoundrySchema(filters.schema.definition) - ); - }, [platform.kind, filters.schema]); + const useCloudFoundryReview = useCloudFoundryCheck(platform, filters.schema); return (
diff --git a/client/src/app/components/discover-import-wizard/validate-cloudfoundry-schema.tsx b/client/src/app/components/discover-import-wizard/validate-cloudfoundry-schema.tsx index bf41c1e67..ab2e359cb 100644 --- a/client/src/app/components/discover-import-wizard/validate-cloudfoundry-schema.tsx +++ b/client/src/app/components/discover-import-wizard/validate-cloudfoundry-schema.tsx @@ -1,4 +1,10 @@ -import { JsonSchemaObject } from "@app/api/models"; +import * as React from "react"; + +import { + JsonSchemaObject, + SourcePlatform, + TargetedSchema, +} from "@app/api/models"; import { validatorGenerator } from "@app/utils/json-schema"; /** @@ -40,3 +46,16 @@ const SUPPORTED_SCHEMA: JsonSchemaObject = { * to build CloudFoundry forms. */ export const validateCloudFoundrySchema = validatorGenerator(SUPPORTED_SCHEMA); + +export const useCloudFoundryCheck = ( + platform: SourcePlatform, + schema?: TargetedSchema +) => { + return React.useMemo(() => { + return ( + platform.kind === "cloudfoundry" && + schema && + validateCloudFoundrySchema(schema.definition) + ); + }, [platform.kind, schema]); +}; From 0735bfc7d4633cf0d31dce543379fc8b230ba992 Mon Sep 17 00:00:00 2001 From: Scott J Dickerson Date: Fri, 7 Nov 2025 17:59:01 -0500 Subject: [PATCH 3/3] coderabbit useful nitpick Signed-off-by: Scott J Dickerson --- .../app/components/discover-import-wizard/filter-input.tsx | 4 ++-- .../src/app/components/discover-import-wizard/review.tsx | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/src/app/components/discover-import-wizard/filter-input.tsx b/client/src/app/components/discover-import-wizard/filter-input.tsx index 68a390bca..e325a37c5 100644 --- a/client/src/app/components/discover-import-wizard/filter-input.tsx +++ b/client/src/app/components/discover-import-wizard/filter-input.tsx @@ -107,7 +107,7 @@ export const FilterInput: React.FC<{ useFilterStateChangeHandler(form, onFiltersChanged); - const useCloudFoundryFilterInput = useCloudFoundryCheck( + const shouldUseCloudFoundryInput = useCloudFoundryCheck( platform, filtersSchema ); @@ -146,7 +146,7 @@ export const FilterInput: React.FC<{ })} fieldId="document" renderInput={({ field: { value, name, onChange } }) => - useCloudFoundryFilterInput ? ( + shouldUseCloudFoundryInput ? ( @@ -85,7 +88,7 @@ export const Review: React.FC<{ padding: "16px", }} > - {useCloudFoundryReview ? ( + {shouldUseCloudFoundryReview ? (