From 156281174dea621e9af9754215f532a628a25945 Mon Sep 17 00:00:00 2001 From: PVinaches Date: Tue, 28 Jul 2026 16:15:55 +0100 Subject: [PATCH 1/3] feat: improve filter to match title and values --- .../ObjectField/ObjectFieldGrouping.test.tsx | 39 ++- .../ObjectField/ObjectFieldGrouping.tsx | 14 +- .../utils/get-filtered-properties.test.ts | 239 ++++++++++++++++++ src/form/utils/get-filtered-properties.ts | 94 ++++++- 4 files changed, 376 insertions(+), 10 deletions(-) diff --git a/src/form/fields/ObjectField/ObjectFieldGrouping.test.tsx b/src/form/fields/ObjectField/ObjectFieldGrouping.test.tsx index d3379ab..318048b 100644 --- a/src/form/fields/ObjectField/ObjectFieldGrouping.test.tsx +++ b/src/form/fields/ObjectField/ObjectFieldGrouping.test.tsx @@ -1,11 +1,11 @@ import { act, fireEvent, render } from '@testing-library/react'; import { FunctionComponent, PropsWithChildren } from 'react'; -import { KaotoSchemaDefinition } from '../../models'; import { FilteredFieldContext } from '../../providers/filtered-field.provider'; import { ROOT_PATH } from '../../utils'; import { SchemaProvider } from '../../providers/SchemaProvider'; import { FormWrapper } from '../../testing/FormWrapper'; import { ObjectFieldGrouping } from './ObjectFieldGrouping'; +import { JSONSchema4 } from 'json-schema'; describe('ObjectFieldGrouping', () => { const schema: JSONSchema4 = { @@ -111,4 +111,41 @@ describe('ObjectFieldGrouping', () => { {children} ); + + it('should strip spaces from the filter before matching', () => { + const wrapper = render( + + + , + { wrapper: formWrapper }, + ); + + const inputFields = wrapper.queryAllByRole('textbox'); + expect(inputFields).toHaveLength(1); + expect(inputFields[0]).toHaveAttribute('name', '#.correlationExpression'); + }); + + it('should mark required properties with the required indicator', () => { + const requiredSchema: JSONSchema4 = { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string', title: 'Id' }, + description: { type: 'string', title: 'Description' }, + }, + }; + + const { getByTestId } = render( + + + + + , + ); + + expect(getByTestId('#.id__field-wrapper').querySelector('.pf-v6-c-form__label-required')).toBeInTheDocument(); + expect(getByTestId('#.description__field-wrapper').querySelector('.pf-v6-c-form__label-required')).toBeNull(); + }); }); diff --git a/src/form/fields/ObjectField/ObjectFieldGrouping.tsx b/src/form/fields/ObjectField/ObjectFieldGrouping.tsx index 05f2ecb..85883fa 100644 --- a/src/form/fields/ObjectField/ObjectFieldGrouping.tsx +++ b/src/form/fields/ObjectField/ObjectFieldGrouping.tsx @@ -1,6 +1,7 @@ import { FunctionComponent, useContext, useMemo } from 'react'; import { FilteredFieldContext } from '../../providers/filtered-field.provider'; -import { getFieldGroups, getFilteredProperties } from '../../utils'; +import { ModelContext } from '../../providers/ModelProvider'; +import { getFieldGroups, getFilteredProperties, safeGetValue } from '../../utils'; import { SchemaContext, SchemaProvider } from '../../providers/SchemaProvider'; import { FieldProps } from '../../models/typings'; import { AnyOfField } from './AnyOfField'; @@ -12,12 +13,15 @@ const SPACE_REGEX = /\s/g; export const ObjectFieldGrouping: FunctionComponent = ({ propName }) => { const { schema } = useContext(SchemaContext); const { filteredFieldText } = useContext(FilteredFieldContext); + const { model } = useContext(ModelContext); - const groupedProperties = useMemo(() => { + const filteredProperties = useMemo(() => { const cleanQueryTerm = filteredFieldText.replace(SPACE_REGEX, '').toLowerCase(); - const filteredProperties = getFilteredProperties(schema.properties, cleanQueryTerm); - return getFieldGroups(filteredProperties); - }, [filteredFieldText, schema.properties]); + const modelSlice = safeGetValue(model, propName.replace('#.', '')) as Record | undefined; + return getFilteredProperties(schema.properties, cleanQueryTerm, undefined, modelSlice); + }, [filteredFieldText, schema.properties, model, propName]); + + const groupedProperties = useMemo(() => getFieldGroups(filteredProperties), [filteredProperties]); const requiredProperties = Array.isArray(schema.required) ? schema.required : []; diff --git a/src/form/utils/get-filtered-properties.test.ts b/src/form/utils/get-filtered-properties.test.ts index ccdca56..10aea30 100644 --- a/src/form/utils/get-filtered-properties.test.ts +++ b/src/form/utils/get-filtered-properties.test.ts @@ -1,3 +1,4 @@ +import { JSONSchema4 } from 'json-schema'; import { restSchemaProperties } from '../stubs/rest-schema-properties'; import { getFilteredProperties } from './get-filtered-properties'; @@ -18,4 +19,242 @@ describe('getFilteredProperties()', () => { ]); expect(filteredSchema).toMatchSnapshot(); }); + + it('should return the original properties reference when the filter is empty and no fields are omitted', () => { + const properties: JSONSchema4['properties'] = { + metadata: { + type: 'object', + properties: { + displayName: { type: 'string', title: 'Display Name' }, + }, + }, + }; + + const result = getFilteredProperties(properties, ''); + expect(result).toBe(properties); + }); + + it('should shallowly omit fields without recursively rebuilding nested schemas when the filter is empty', () => { + const properties: JSONSchema4['properties'] = { + metadata: { + type: 'object', + properties: { + displayName: { type: 'string', title: 'Display Name' }, + }, + }, + description: { type: 'string', title: 'Description' }, + }; + + const result = getFilteredProperties(properties, '', ['description']); + expect(result).toEqual({ metadata: properties.metadata }); + expect(result?.metadata).toBe(properties.metadata); + }); + + it('should match a property by its schema title when the key does not match', () => { + const properties: JSONSchema4['properties'] = { + icon: { + type: 'string', + title: 'Kamelet Icon', + description: 'The icon', + }, + name: { + type: 'string', + title: 'Name', + description: 'The name', + }, + }; + + const result = getFilteredProperties(properties, 'kamelet'); + expect(Object.keys(result!)).toEqual(['icon']); + }); + + it('should match a property by its current runtime value', () => { + const properties: JSONSchema4['properties'] = { + timerName: { type: 'string', title: 'Timer Name' }, + period: { type: 'string', title: 'Period' }, + }; + const model = { timerName: 'mySpecialTimer', period: '1000' }; + + const result = getFilteredProperties(properties, 'myspecialtimer', undefined, model); + expect(Object.keys(result!)).toEqual(['timerName']); + + const result2 = getFilteredProperties(properties, '1000', undefined, model); + expect(Object.keys(result2!)).toEqual(['period']); + }); + + it('should return an empty object when properties is undefined', () => { + const result = getFilteredProperties(undefined, 'anything'); + expect(result).toEqual({}); + }); + + it('should surface a nested object field when a child property matches', () => { + const properties: JSONSchema4['properties'] = { + metadata: { + type: 'object', + title: 'Metadata', + properties: { + displayName: { type: 'string', title: 'Display Name' }, + version: { type: 'string', title: 'Version' }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = getFilteredProperties(properties, 'display'); + expect(Object.keys(result!)).toEqual(['metadata']); + expect((result!['metadata'] as JSONSchema4).properties).toEqual({ + displayName: { type: 'string', title: 'Display Name' }, + }); + + const noMatch = getFilteredProperties(properties, 'zzz'); + expect(Object.keys(noMatch!)).toHaveLength(0); + }); + + it('should surface an array field when a null item is in the model', () => { + const properties: JSONSchema4['properties'] = { + items: { + type: 'array', + title: 'Items', + items: { + type: 'object', + properties: { + label: { type: 'string', title: 'Label' }, + }, + }, + }, + }; + const model = { items: [null, { label: 'hello' }] }; + + const result = getFilteredProperties(properties, 'hello', undefined, model as Record); + expect(Object.keys(result!)).toContain('items'); + }); + + it('should surface an array field whose item properties match by schema title', () => { + const properties: JSONSchema4['properties'] = { + kameletProperties: { + type: 'array', + title: 'Properties', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Property name' }, + }, + }, + }, + }; + + const result = getFilteredProperties(properties, 'property'); + expect(Object.keys(result!)).toContain('kameletProperties'); + }); + + it('should not surface an array field when no item property matches', () => { + const properties: JSONSchema4['properties'] = { + kameletProperties: { + type: 'array', + title: 'Properties', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Property name' }, + }, + }, + }, + }; + + const result = getFilteredProperties(properties, 'zzz'); + expect(Object.keys(result!)).toHaveLength(0); + }); + + it('should surface an array field when an item sub-property value matches the filter', () => { + const properties: JSONSchema4['properties'] = { + kameletProperties: { + type: 'array', + title: 'Properties', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Property name' }, + value: { type: 'string', title: 'Value' }, + }, + }, + }, + }; + const model = { kameletProperties: [{ name: 'Test', value: '' }] }; + + const result = getFilteredProperties(properties, 'test', undefined, model); + expect(Object.keys(result!)).toContain('kameletProperties'); + + const result2 = getFilteredProperties(properties, 'zzz', undefined, model); + expect(Object.keys(result2!)).toHaveLength(0); + }); + + it('should match a plain-string array field on its key name', () => { + const properties: JSONSchema4['properties'] = { + headers: { + type: 'array', + title: 'Headers', + items: { type: 'string' }, + }, + }; + + const result = getFilteredProperties(properties, 'header'); + expect(Object.keys(result!)).toContain('headers'); + + const result2 = getFilteredProperties(properties, 'zzz'); + expect(Object.keys(result2!)).toHaveLength(0); + }); + + it('should surface an object field when the container key matches and no child matches', () => { + const properties: JSONSchema4['properties'] = { + metadata: { + type: 'object', + title: 'Metadata', + properties: { + displayName: { type: 'string', title: 'Display Name' }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + // match by key + const byKey = getFilteredProperties(properties, 'metadata'); + expect(Object.keys(byKey!)).toEqual(['metadata']); + expect(byKey!['metadata']).toBe(properties!['metadata']); + + // match by title + const byTitle = getFilteredProperties(properties, 'metadat'); + expect(Object.keys(byTitle!)).toEqual(['metadata']); + + // no match + const noMatch = getFilteredProperties(properties, 'zzz'); + expect(Object.keys(noMatch!)).toHaveLength(0); + }); + + it('should surface an array field when the container key matches and no child matches', () => { + const properties: JSONSchema4['properties'] = { + kameletProperties: { + type: 'array', + title: 'Kamelet Properties', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name' }, + }, + }, + }, + }; + + // match by key + const byKey = getFilteredProperties(properties, 'kameletproperties'); + expect(Object.keys(byKey!)).toEqual(['kameletProperties']); + expect(byKey!['kameletProperties']).toBe(properties!['kameletProperties']); + + // match by title + const byTitle = getFilteredProperties(properties, 'kamelet prop'); + expect(Object.keys(byTitle!)).toEqual(['kameletProperties']); + + // no match + const noMatch = getFilteredProperties(properties, 'zzz'); + expect(Object.keys(noMatch!)).toHaveLength(0); + }); }); diff --git a/src/form/utils/get-filtered-properties.ts b/src/form/utils/get-filtered-properties.ts index 5bb8522..bb66c72 100644 --- a/src/form/utils/get-filtered-properties.ts +++ b/src/form/utils/get-filtered-properties.ts @@ -1,25 +1,111 @@ import { JSONSchema4 } from 'json-schema'; import { isDefined } from './is-defined'; +/** + * Returns the nested object property definition when any of its child properties match the filter. + * If the container's own key or title matches the filter, the full unfiltered definition is returned. + */ +function getFilteredObjectProperty( + definition: JSONSchema4, + filter: string, + property: string, + model?: Record, +): JSONSchema4 | undefined { + if ( + property.toLowerCase().includes(filter) || + (definition.title as string | undefined)?.toLowerCase().includes(filter) + ) { + return definition; + } + + const nestedModel = (model?.[property] ?? {}) as Record; + const subFilteredSchema = getFilteredProperties(definition['properties'], filter, undefined, nestedModel); + + return subFilteredSchema && Object.keys(subFilteredSchema).length > 0 + ? { ...definition, properties: subFilteredSchema } + : undefined; +} + +/** + * Returns the array property definition when its item schema or any item value matches the filter. + * If the container's own key or title matches the filter, the full unfiltered definition is returned. + */ +function getFilteredArrayProperty( + definition: JSONSchema4, + filter: string, + property: string, + model?: Record, +): JSONSchema4 | undefined { + if ( + property.toLowerCase().includes(filter) || + (definition.title as string | undefined)?.toLowerCase().includes(filter) + ) { + return definition; + } + + const itemsProperties = (definition.items as JSONSchema4).properties; + const schemaMatch = getFilteredProperties(itemsProperties, filter); + const schemaMatched = schemaMatch && Object.keys(schemaMatch).length > 0; + const modelValue = model?.[property]; + const valueMatched = + !schemaMatched && + Array.isArray(modelValue) && + modelValue.some((item) => { + const itemModel = (item ?? {}) as Record; + const sub = getFilteredProperties(itemsProperties, filter, undefined, itemModel); + return sub && Object.keys(sub).length > 0; + }); + + return schemaMatched || valueMatched ? definition : undefined; +} + /** * Extracts the schema recursively containing only the filtered properties. + * + * A property is included if any of the following match: + * - The property key name contains the filter + * - The schema `title` label contains the filter + * - The current runtime value (from `model`) contains the filter + * + * Object properties are delegated to `getFilteredObjectProperty()` and array + * properties with nested item `properties` are delegated to + * `getFilteredArrayProperty()`. */ export function getFilteredProperties( properties: JSONSchema4['properties'], filter: string, omitFields?: string[], + model?: Record, ): JSONSchema4['properties'] { if (!isDefined(properties)) return {}; + if (filter.length === 0) { + if (!omitFields?.length) return properties; + + return Object.fromEntries(Object.entries(properties).filter(([property]) => !omitFields.includes(property))); + } const filteredFormSchema = Object.entries(properties).reduce( (acc, [property, definition]) => { if (!omitFields?.includes(property)) { if (definition['type'] === 'object' && 'properties' in definition) { - const subFilteredSchema = getFilteredProperties(definition['properties'], filter); - if (subFilteredSchema && Object.keys(subFilteredSchema).length > 0) { - acc![property] = { ...definition, properties: subFilteredSchema }; + const filteredObjectProperty = getFilteredObjectProperty(definition, filter, property, model); + if (filteredObjectProperty) { + acc![property] = filteredObjectProperty; + } + } else if (definition['type'] === 'array' && isDefined((definition.items as JSONSchema4)?.properties)) { + const filteredArrayProperty = getFilteredArrayProperty(definition, filter, property, model); + if (filteredArrayProperty) { + acc![property] = filteredArrayProperty; } - } else if (property.toLowerCase().includes(filter)) { + } else if ( + property.toLowerCase().includes(filter) || + (definition.title as string | undefined)?.toLowerCase().includes(filter) || + (() => { + const val = model?.[property]; + if (typeof val !== 'string' && typeof val !== 'number' && typeof val !== 'boolean') return false; + return String(val).toLowerCase().includes(filter); + })() + ) { acc![property] = definition; } } From b40e2f84bbfb8f2a09fce8ec857a0948dccbc378 Mon Sep 17 00:00:00 2001 From: "Ricardo M." Date: Thu, 30 Jul 2026 15:38:51 +0200 Subject: [PATCH 2/3] refactor: simplify schema property filtering with class-based approach (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace functional implementation with SchemaPropertyFilter class for better code organization and maintainability. Changes: - Rename get-filtered-properties.ts → SchemaPropertyFilter.ts - Convert from functional to class-based static methods - Add type guards (hasNestedProperties, hasArrayItemProperties, isMatchablePrimitive) - Extract helper methods for better separation of concerns: - matchesKeyOrTitle() - centralized key/title matching - matchesModelValue() - value-based matching logic - getNestedModelSlice() - type-safe model extraction for objects - getArrayModelSlice() - type-safe model extraction for arrays - filterObjectProperty() - dedicated object filtering - filterArrayProperty() - dedicated array filtering - matchesPrimitiveProperty() - primitive property matching - Improve documentation with JSDoc comments - Enhance type safety throughout the implementation - Maintain all existing functionality and test coverage The new structure makes the code more maintainable by breaking down complex logic into focused, well-documented methods while preserving the original filtering behavior. --- .../ObjectField/ObjectFieldGrouping.tsx | 7 +- src/form/utils/SchemaPropertyFilter.test.ts | 504 ++++++++++++++++++ src/form/utils/SchemaPropertyFilter.ts | 300 +++++++++++ .../utils/get-filtered-properties.test.ts | 260 --------- src/form/utils/get-filtered-properties.ts | 119 ----- 5 files changed, 808 insertions(+), 382 deletions(-) create mode 100644 src/form/utils/SchemaPropertyFilter.test.ts create mode 100644 src/form/utils/SchemaPropertyFilter.ts delete mode 100644 src/form/utils/get-filtered-properties.test.ts delete mode 100644 src/form/utils/get-filtered-properties.ts diff --git a/src/form/fields/ObjectField/ObjectFieldGrouping.tsx b/src/form/fields/ObjectField/ObjectFieldGrouping.tsx index 85883fa..14fde8b 100644 --- a/src/form/fields/ObjectField/ObjectFieldGrouping.tsx +++ b/src/form/fields/ObjectField/ObjectFieldGrouping.tsx @@ -1,9 +1,10 @@ import { FunctionComponent, useContext, useMemo } from 'react'; +import { FieldProps } from '../../models/typings'; import { FilteredFieldContext } from '../../providers/filtered-field.provider'; import { ModelContext } from '../../providers/ModelProvider'; -import { getFieldGroups, getFilteredProperties, safeGetValue } from '../../utils'; import { SchemaContext, SchemaProvider } from '../../providers/SchemaProvider'; -import { FieldProps } from '../../models/typings'; +import { getFieldGroups, safeGetValue } from '../../utils'; +import { SchemaPropertyFilter } from '../../utils/SchemaPropertyFilter'; import { AnyOfField } from './AnyOfField'; import { GroupFields } from './GroupFields'; import { ObjectFieldInner } from './ObjectFieldInner'; @@ -18,7 +19,7 @@ export const ObjectFieldGrouping: FunctionComponent = ({ propName }) const filteredProperties = useMemo(() => { const cleanQueryTerm = filteredFieldText.replace(SPACE_REGEX, '').toLowerCase(); const modelSlice = safeGetValue(model, propName.replace('#.', '')) as Record | undefined; - return getFilteredProperties(schema.properties, cleanQueryTerm, undefined, modelSlice); + return SchemaPropertyFilter.filter(schema.properties, cleanQueryTerm, undefined, modelSlice); }, [filteredFieldText, schema.properties, model, propName]); const groupedProperties = useMemo(() => getFieldGroups(filteredProperties), [filteredProperties]); diff --git a/src/form/utils/SchemaPropertyFilter.test.ts b/src/form/utils/SchemaPropertyFilter.test.ts new file mode 100644 index 0000000..a5e8e3c --- /dev/null +++ b/src/form/utils/SchemaPropertyFilter.test.ts @@ -0,0 +1,504 @@ +import { JSONSchema4 } from 'json-schema'; +import { SchemaPropertyFilter } from './SchemaPropertyFilter'; + +describe('SchemaPropertyFilter', () => { + describe('filter - basic filtering', () => { + it('should return empty object when properties is undefined', () => { + const result = SchemaPropertyFilter.filter(undefined, 'test'); + + expect(result).toEqual({}); + }); + + it('should return original properties when filter is empty and no omit fields', () => { + const properties: JSONSchema4['properties'] = { + username: { type: 'string', title: 'Username' }, + email: { type: 'string', title: 'Email' }, + }; + + const result = SchemaPropertyFilter.filter(properties, ''); + + expect(result).toBe(properties); + }); + + it('should filter by property key name', () => { + const properties: JSONSchema4['properties'] = { + username: { type: 'string', title: 'Username' }, + email: { type: 'string', title: 'Email' }, + password: { type: 'string', title: 'Password' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'user'); + + expect(Object.keys(result!)).toEqual(['username']); + }); + + it('should filter by schema title', () => { + const properties: JSONSchema4['properties'] = { + icon: { type: 'string', title: 'Kamelet Icon' }, + name: { type: 'string', title: 'Name' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'kamelet'); + + expect(Object.keys(result!)).toEqual(['icon']); + }); + + it('should filter by model value - string', () => { + const properties: JSONSchema4['properties'] = { + timerName: { type: 'string', title: 'Timer Name' }, + period: { type: 'string', title: 'Period' }, + }; + const model = { timerName: 'mySpecialTimer', period: '1000' }; + + const result = SchemaPropertyFilter.filter(properties, 'myspecialtimer', undefined, model); + + expect(Object.keys(result!)).toEqual(['timerName']); + }); + + it('should filter by model value - number', () => { + const properties: JSONSchema4['properties'] = { + port: { type: 'number', title: 'Port' }, + timeout: { type: 'number', title: 'Timeout' }, + }; + const model = { port: 8080, timeout: 3000 }; + + const result = SchemaPropertyFilter.filter(properties, '8080', undefined, model); + + expect(Object.keys(result!)).toEqual(['port']); + }); + + it('should filter by model value - boolean', () => { + const properties: JSONSchema4['properties'] = { + enabled: { type: 'boolean', title: 'Enabled' }, + debug: { type: 'boolean', title: 'Debug' }, + }; + const model = { enabled: true, debug: false }; + + const result = SchemaPropertyFilter.filter(properties, 'true', undefined, model); + + expect(Object.keys(result!)).toEqual(['enabled']); + }); + + it('should return empty object when no properties match', () => { + const properties: JSONSchema4['properties'] = { + username: { type: 'string', title: 'Username' }, + email: { type: 'string', title: 'Email' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'zzz'); + + expect(Object.keys(result!)).toHaveLength(0); + }); + }); + + describe('filter - omit fields', () => { + it('should omit specified fields with empty filter', () => { + const properties: JSONSchema4['properties'] = { + username: { type: 'string', title: 'Username' }, + email: { type: 'string', title: 'Email' }, + password: { type: 'string', title: 'Password' }, + }; + + const result = SchemaPropertyFilter.filter(properties, '', ['password']); + + expect(Object.keys(result!)).toEqual(['username', 'email']); + }); + + it('should omit specified fields with active filter', () => { + const properties: JSONSchema4['properties'] = { + username: { type: 'string', title: 'Username' }, + email: { type: 'string', title: 'Email' }, + password: { type: 'string', title: 'Password' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'e', ['email']); + + expect(Object.keys(result!)).toEqual(['username', 'password']); + }); + }); + + describe('filter - nested object properties', () => { + it('should include object when container key matches', () => { + const properties: JSONSchema4['properties'] = { + metadata: { + type: 'object', + title: 'Metadata', + properties: { + displayName: { type: 'string', title: 'Display Name' }, + version: { type: 'string', title: 'Version' }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'metadata'); + + expect(Object.keys(result!)).toEqual(['metadata']); + expect(result!['metadata']).toBe(properties!['metadata']); + }); + + it('should include object when container title matches', () => { + const properties: JSONSchema4['properties'] = { + meta: { + type: 'object', + title: 'Metadata Information', + properties: { + displayName: { type: 'string', title: 'Display Name' }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'metadata'); + + expect(Object.keys(result!)).toEqual(['meta']); + }); + + it('should include object when nested property key matches', () => { + const properties: JSONSchema4['properties'] = { + metadata: { + type: 'object', + title: 'Metadata', + properties: { + displayName: { type: 'string', title: 'Display Name' }, + version: { type: 'string', title: 'Version' }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'display'); + + expect(Object.keys(result!)).toEqual(['metadata']); + expect((result!['metadata'] as JSONSchema4).properties).toEqual({ + displayName: { type: 'string', title: 'Display Name' }, + }); + }); + + it('should include object when nested property title matches', () => { + const properties: JSONSchema4['properties'] = { + metadata: { + type: 'object', + title: 'Metadata', + properties: { + name: { type: 'string', title: 'Display Name' }, + ver: { type: 'string', title: 'Version' }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'display'); + + expect(Object.keys(result!)).toEqual(['metadata']); + expect((result!['metadata'] as JSONSchema4).properties).toEqual({ + name: { type: 'string', title: 'Display Name' }, + }); + }); + + it('should include object when nested property value matches', () => { + const properties: JSONSchema4['properties'] = { + metadata: { + type: 'object', + title: 'Metadata', + properties: { + displayName: { type: 'string', title: 'Display Name' }, + version: { type: 'string', title: 'Version' }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + const model = { metadata: { displayName: 'MyApp', version: '1.0' } }; + + const result = SchemaPropertyFilter.filter(properties, 'myapp', undefined, model); + + expect(Object.keys(result!)).toEqual(['metadata']); + expect((result!['metadata'] as JSONSchema4).properties).toEqual({ + displayName: { type: 'string', title: 'Display Name' }, + }); + }); + + it('should exclude object when no nested properties match', () => { + const properties: JSONSchema4['properties'] = { + metadata: { + type: 'object', + title: 'Metadata', + properties: { + displayName: { type: 'string', title: 'Display Name' }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'zzz'); + + expect(Object.keys(result!)).toHaveLength(0); + }); + }); + + describe('filter - array properties', () => { + it('should include array when container key matches', () => { + const properties: JSONSchema4['properties'] = { + kameletProperties: { + type: 'array', + title: 'Properties', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name' }, + }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'kameletproperties'); + + expect(Object.keys(result!)).toEqual(['kameletProperties']); + expect(result!['kameletProperties']).toBe(properties!['kameletProperties']); + }); + + it('should include array when container title matches', () => { + const properties: JSONSchema4['properties'] = { + props: { + type: 'array', + title: 'Kamelet Properties', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name' }, + }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'kamelet'); + + expect(Object.keys(result!)).toEqual(['props']); + }); + + it('should include array when item property key matches', () => { + const properties: JSONSchema4['properties'] = { + kameletProperties: { + type: 'array', + title: 'Properties', + items: { + type: 'object', + properties: { + propertyName: { type: 'string', title: 'Name' }, + value: { type: 'string', title: 'Value' }, + }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'propertyname'); + + expect(Object.keys(result!)).toEqual(['kameletProperties']); + }); + + it('should include array when item property title matches', () => { + const properties: JSONSchema4['properties'] = { + kameletProperties: { + type: 'array', + title: 'Properties', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Property Name' }, + }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'property'); + + expect(Object.keys(result!)).toEqual(['kameletProperties']); + }); + + it('should include array when item value matches', () => { + const properties: JSONSchema4['properties'] = { + kameletProperties: { + type: 'array', + title: 'Properties', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name' }, + value: { type: 'string', title: 'Value' }, + }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + const model = { kameletProperties: [{ name: 'TestProperty', value: 'test-value' }] }; + + const result = SchemaPropertyFilter.filter(properties, 'testproperty', undefined, model); + + expect(Object.keys(result!)).toEqual(['kameletProperties']); + }); + + it('should handle array with null items in model', () => { + const properties: JSONSchema4['properties'] = { + items: { + type: 'array', + title: 'Items', + items: { + type: 'object', + properties: { + label: { type: 'string', title: 'Label' }, + }, + }, + }, + }; + const model = { items: [null, { label: 'hello' }] }; + + const result = SchemaPropertyFilter.filter(properties, 'hello', undefined, model); + + expect(Object.keys(result!)).toContain('items'); + }); + + it('should exclude array when no item properties match', () => { + const properties: JSONSchema4['properties'] = { + kameletProperties: { + type: 'array', + title: 'Properties', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name' }, + }, + }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'zzz'); + + expect(Object.keys(result!)).toHaveLength(0); + }); + + it('should handle plain string array by key match only', () => { + const properties: JSONSchema4['properties'] = { + headers: { + type: 'array', + title: 'Headers', + items: { type: 'string' }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'header'); + + expect(Object.keys(result!)).toEqual(['headers']); + }); + + it('should exclude plain string array when key does not match', () => { + const properties: JSONSchema4['properties'] = { + headers: { + type: 'array', + title: 'Headers', + items: { type: 'string' }, + }, + unrelated: { type: 'string', title: 'Unrelated' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'zzz'); + + expect(Object.keys(result!)).toHaveLength(0); + }); + }); + + describe('filter - case insensitivity', () => { + it('should match regardless of case in property key', () => { + const properties: JSONSchema4['properties'] = { + UserName: { type: 'string', title: 'Username' }, + email: { type: 'string', title: 'Email' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'username'); + + expect(Object.keys(result!)).toEqual(['UserName']); + }); + + it('should match regardless of case in schema title', () => { + const properties: JSONSchema4['properties'] = { + icon: { type: 'string', title: 'KAMELET ICON' }, + name: { type: 'string', title: 'Name' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'kamelet'); + + expect(Object.keys(result!)).toEqual(['icon']); + }); + + it('should match regardless of case in model value', () => { + const properties: JSONSchema4['properties'] = { + timerName: { type: 'string', title: 'Timer Name' }, + }; + const model = { timerName: 'MySpecialTimer' }; + + const result = SchemaPropertyFilter.filter(properties, 'myspecialtimer', undefined, model); + + expect(Object.keys(result!)).toEqual(['timerName']); + }); + }); + + describe('filter - complex scenarios', () => { + it('should handle deeply nested objects', () => { + const properties: JSONSchema4['properties'] = { + config: { + type: 'object', + title: 'Configuration', + properties: { + database: { + type: 'object', + title: 'Database', + properties: { + host: { type: 'string', title: 'Host' }, + port: { type: 'number', title: 'Port' }, + }, + }, + }, + }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'host'); + + expect(Object.keys(result!)).toEqual(['config']); + const configProps = (result!['config'] as JSONSchema4).properties!; + expect(Object.keys(configProps)).toEqual(['database']); + const dbProps = (configProps['database'] as JSONSchema4).properties!; + expect(Object.keys(dbProps)).toEqual(['host']); + }); + + it('should handle multiple matching properties', () => { + const properties: JSONSchema4['properties'] = { + username: { type: 'string', title: 'Username' }, + userEmail: { type: 'string', title: 'User Email' }, + userId: { type: 'number', title: 'User ID' }, + password: { type: 'string', title: 'Password' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'user'); + + expect(Object.keys(result!)).toEqual(['username', 'userEmail', 'userId']); + }); + + it('should combine omit and filter correctly', () => { + const properties: JSONSchema4['properties'] = { + username: { type: 'string', title: 'Username' }, + userEmail: { type: 'string', title: 'User Email' }, + userId: { type: 'number', title: 'User ID' }, + password: { type: 'string', title: 'Password' }, + }; + + const result = SchemaPropertyFilter.filter(properties, 'user', ['userId']); + + expect(Object.keys(result!)).toEqual(['username', 'userEmail']); + }); + }); +}); diff --git a/src/form/utils/SchemaPropertyFilter.ts b/src/form/utils/SchemaPropertyFilter.ts new file mode 100644 index 0000000..cab2c42 --- /dev/null +++ b/src/form/utils/SchemaPropertyFilter.ts @@ -0,0 +1,300 @@ +import { JSONSchema4 } from 'json-schema'; +import { isDefined } from './is-defined'; + +/** + * A utility class for filtering JSON Schema properties based on search criteria. + * + * Provides recursive filtering of schema properties by matching against: + * - Property key names + * - Schema title labels + * - Runtime model values + * + * @example + * ```typescript + * const filtered = SchemaPropertyFilter.filter( + * schema.properties, + * 'username', + * undefined, + * currentFormData + * ); + * ``` + */ +export class SchemaPropertyFilter { + /** + * Filters schema properties based on the provided criteria. + * + * @param properties - The schema properties to filter + * @param filter - The filter term (case-insensitive, spaces should be removed by caller) + * @param omitFields - Optional list of property keys to always exclude + * @param model - Optional current form data for value-based matching + * @returns Filtered properties object + */ + public static filter( + properties: JSONSchema4['properties'], + filter: string, + omitFields?: string[], + model?: Record, + ): JSONSchema4['properties'] { + // Early return for undefined properties + if (!isDefined(properties)) { + return {}; + } + + // Early return for empty filter (optimization) + if (filter.length === 0) { + if (!omitFields?.length) { + return properties; // Return original reference + } + // Only apply omit filter + return Object.fromEntries(Object.entries(properties).filter(([key]) => !omitFields.includes(key))); + } + + // Filter properties based on various criteria + const filteredProperties = Object.entries(properties).reduce( + (accumulator, [propertyKey, propertySchema]) => { + // Skip omitted fields + if (omitFields?.includes(propertyKey)) { + return accumulator; + } + + let matchedSchema: JSONSchema4 | undefined; + + // Handle object properties with nested properties + if (this.hasNestedProperties(propertySchema)) { + matchedSchema = this.filterObjectProperty(propertySchema, propertyKey, filter, model); + } + // Handle array properties with object items + else if (this.hasArrayItemProperties(propertySchema)) { + matchedSchema = this.filterArrayProperty(propertySchema, propertyKey, filter, model); + } + // Handle primitive properties + else if (this.matchesPrimitiveProperty(propertySchema, propertyKey, filter, model)) { + matchedSchema = propertySchema; + } + + // Add to accumulator if matched + if (matchedSchema) { + accumulator[propertyKey] = matchedSchema; + } + + return accumulator; + }, + {} as NonNullable, + ); + + return filteredProperties; + } + + /** + * Type guard to check if a schema definition has nested properties. + */ + private static hasNestedProperties( + schema: JSONSchema4, + ): schema is JSONSchema4 & { properties: NonNullable } { + return schema.type === 'object' && isDefined(schema.properties); + } + + /** + * Type guard to check if a schema definition is an array with object items that have properties. + */ + private static hasArrayItemProperties(schema: JSONSchema4): schema is JSONSchema4 & { + items: JSONSchema4 & { properties: NonNullable }; + } { + return ( + schema.type === 'array' && + isDefined(schema.items) && + !Array.isArray(schema.items) && + isDefined((schema.items as JSONSchema4).properties) + ); + } + + /** + * Type guard to check if a value is a primitive that can be stringified for matching. + */ + private static isMatchablePrimitive(value: unknown): value is string | number | boolean { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; + } + + /** + * Checks if a property key or schema title matches the filter term. + */ + private static matchesKeyOrTitle(propertyKey: string, schema: JSONSchema4, filter: string): boolean { + const keyMatches = propertyKey.toLowerCase().includes(filter); + const titleMatches = typeof schema.title === 'string' && schema.title.toLowerCase().includes(filter); + return keyMatches || titleMatches; + } + + /** + * Checks if a model value matches the filter term. + */ + private static matchesModelValue(modelValue: unknown, filter: string): boolean { + if (!this.isMatchablePrimitive(modelValue)) { + return false; + } + return String(modelValue).toLowerCase().includes(filter); + } + + /** + * Extracts the model slice for a nested property, ensuring type safety. + */ + private static getNestedModelSlice( + model: Record | undefined, + propertyKey: string, + ): Record | undefined { + if (!model) return undefined; + + const value = model[propertyKey]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + return value as Record; + } + + /** + * Extracts the array model slice for a property, ensuring type safety. + */ + private static getArrayModelSlice( + model: Record | undefined, + propertyKey: string, + ): unknown[] | undefined { + if (!model) return undefined; + + const value = model[propertyKey]; + if (!Array.isArray(value)) { + return undefined; + } + + return value; + } + + /** + * Filters nested object properties recursively. + * + * Returns the schema definition if: + * 1. The property key or title matches the filter (returns full unfiltered definition) + * 2. Any nested child property matches the filter (returns filtered definition) + * + * @param schema - The object schema definition to filter + * @param propertyKey - The key of this property in the parent schema + * @param filter - The lowercase filter term to match against + * @param model - Optional model data for value-based matching + * @returns Filtered schema definition or undefined if no matches + */ + private static filterObjectProperty( + schema: JSONSchema4, + propertyKey: string, + filter: string, + model?: Record, + ): JSONSchema4 | undefined { + // Stage 1: Check if the container itself matches (key or title) + if (this.matchesKeyOrTitle(propertyKey, schema, filter)) { + return schema; // Return full unfiltered definition + } + + // Stage 2: Recursively filter nested properties + if (!this.hasNestedProperties(schema)) { + return undefined; // No nested properties to filter + } + + const nestedModel = this.getNestedModelSlice(model, propertyKey); + const filteredNestedProperties = this.filter(schema.properties, filter, undefined, nestedModel); + + // Stage 3: Return filtered definition if any nested properties matched + if (filteredNestedProperties && Object.keys(filteredNestedProperties).length > 0) { + return { ...schema, properties: filteredNestedProperties }; + } + + return undefined; + } + + /** + * Filters array properties based on item schema or item values. + * + * Returns the schema definition if: + * 1. The property key or title matches the filter (returns full definition) + * 2. Any item property in the schema matches the filter + * 3. Any item value in the model matches the filter + * + * @param schema - The array schema definition to filter + * @param propertyKey - The key of this property in the parent schema + * @param filter - The lowercase filter term to match against + * @param model - Optional model data for value-based matching + * @returns The schema definition or undefined if no matches + */ + private static filterArrayProperty( + schema: JSONSchema4, + propertyKey: string, + filter: string, + model?: Record, + ): JSONSchema4 | undefined { + // Stage 1: Check if the container itself matches (key or title) + if (this.matchesKeyOrTitle(propertyKey, schema, filter)) { + return schema; // Return full definition + } + + // Stage 2: Check if this array has object items with properties + if (!this.hasArrayItemProperties(schema)) { + return undefined; // Can't filter arrays without item properties + } + + const itemProperties = schema.items.properties; + + // Stage 3: Check if any item property in the schema matches + const schemaFilteredProperties = this.filter(itemProperties, filter); + const hasSchemaMatch = schemaFilteredProperties && Object.keys(schemaFilteredProperties).length > 0; + + if (hasSchemaMatch) { + return schema; // Schema-level match found + } + + // Stage 4: Check if any item value in the model matches + const arrayModel = this.getArrayModelSlice(model, propertyKey); + if (!arrayModel) { + return undefined; // No model data to check + } + + const hasValueMatch = arrayModel.some((item) => { + // Skip null/undefined items + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return false; + } + + const itemModel = item as Record; + const itemFilteredProperties = this.filter(itemProperties, filter, undefined, itemModel); + + return itemFilteredProperties && Object.keys(itemFilteredProperties).length > 0; + }); + + return hasValueMatch ? schema : undefined; + } + + /** + * Filters a single property based on key, title, or model value. + * + * @param schema - The property schema definition + * @param propertyKey - The key of this property + * @param filter - The lowercase filter term to match against + * @param model - Optional model data for value-based matching + * @returns True if the property matches the filter + */ + private static matchesPrimitiveProperty( + schema: JSONSchema4, + propertyKey: string, + filter: string, + model?: Record, + ): boolean { + // Check key or title match + if (this.matchesKeyOrTitle(propertyKey, schema, filter)) { + return true; + } + + // Check model value match + if (model) { + const modelValue = model[propertyKey]; + return this.matchesModelValue(modelValue, filter); + } + + return false; + } +} diff --git a/src/form/utils/get-filtered-properties.test.ts b/src/form/utils/get-filtered-properties.test.ts deleted file mode 100644 index 10aea30..0000000 --- a/src/form/utils/get-filtered-properties.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { JSONSchema4 } from 'json-schema'; -import { restSchemaProperties } from '../stubs/rest-schema-properties'; -import { getFilteredProperties } from './get-filtered-properties'; - -describe('getFilteredProperties()', () => { - it('should return only the filtered properties', () => { - const filteredSchema = getFilteredProperties(restSchemaProperties, 'des'); - expect(filteredSchema).toMatchSnapshot(); - }); - - it('should return only the un-omitted properties', () => { - const filteredSchema = getFilteredProperties(restSchemaProperties, '', [ - 'get', - 'post', - 'put', - 'delete', - 'patch', - 'patch', - ]); - expect(filteredSchema).toMatchSnapshot(); - }); - - it('should return the original properties reference when the filter is empty and no fields are omitted', () => { - const properties: JSONSchema4['properties'] = { - metadata: { - type: 'object', - properties: { - displayName: { type: 'string', title: 'Display Name' }, - }, - }, - }; - - const result = getFilteredProperties(properties, ''); - expect(result).toBe(properties); - }); - - it('should shallowly omit fields without recursively rebuilding nested schemas when the filter is empty', () => { - const properties: JSONSchema4['properties'] = { - metadata: { - type: 'object', - properties: { - displayName: { type: 'string', title: 'Display Name' }, - }, - }, - description: { type: 'string', title: 'Description' }, - }; - - const result = getFilteredProperties(properties, '', ['description']); - expect(result).toEqual({ metadata: properties.metadata }); - expect(result?.metadata).toBe(properties.metadata); - }); - - it('should match a property by its schema title when the key does not match', () => { - const properties: JSONSchema4['properties'] = { - icon: { - type: 'string', - title: 'Kamelet Icon', - description: 'The icon', - }, - name: { - type: 'string', - title: 'Name', - description: 'The name', - }, - }; - - const result = getFilteredProperties(properties, 'kamelet'); - expect(Object.keys(result!)).toEqual(['icon']); - }); - - it('should match a property by its current runtime value', () => { - const properties: JSONSchema4['properties'] = { - timerName: { type: 'string', title: 'Timer Name' }, - period: { type: 'string', title: 'Period' }, - }; - const model = { timerName: 'mySpecialTimer', period: '1000' }; - - const result = getFilteredProperties(properties, 'myspecialtimer', undefined, model); - expect(Object.keys(result!)).toEqual(['timerName']); - - const result2 = getFilteredProperties(properties, '1000', undefined, model); - expect(Object.keys(result2!)).toEqual(['period']); - }); - - it('should return an empty object when properties is undefined', () => { - const result = getFilteredProperties(undefined, 'anything'); - expect(result).toEqual({}); - }); - - it('should surface a nested object field when a child property matches', () => { - const properties: JSONSchema4['properties'] = { - metadata: { - type: 'object', - title: 'Metadata', - properties: { - displayName: { type: 'string', title: 'Display Name' }, - version: { type: 'string', title: 'Version' }, - }, - }, - unrelated: { type: 'string', title: 'Unrelated' }, - }; - - const result = getFilteredProperties(properties, 'display'); - expect(Object.keys(result!)).toEqual(['metadata']); - expect((result!['metadata'] as JSONSchema4).properties).toEqual({ - displayName: { type: 'string', title: 'Display Name' }, - }); - - const noMatch = getFilteredProperties(properties, 'zzz'); - expect(Object.keys(noMatch!)).toHaveLength(0); - }); - - it('should surface an array field when a null item is in the model', () => { - const properties: JSONSchema4['properties'] = { - items: { - type: 'array', - title: 'Items', - items: { - type: 'object', - properties: { - label: { type: 'string', title: 'Label' }, - }, - }, - }, - }; - const model = { items: [null, { label: 'hello' }] }; - - const result = getFilteredProperties(properties, 'hello', undefined, model as Record); - expect(Object.keys(result!)).toContain('items'); - }); - - it('should surface an array field whose item properties match by schema title', () => { - const properties: JSONSchema4['properties'] = { - kameletProperties: { - type: 'array', - title: 'Properties', - items: { - type: 'object', - properties: { - name: { type: 'string', title: 'Property name' }, - }, - }, - }, - }; - - const result = getFilteredProperties(properties, 'property'); - expect(Object.keys(result!)).toContain('kameletProperties'); - }); - - it('should not surface an array field when no item property matches', () => { - const properties: JSONSchema4['properties'] = { - kameletProperties: { - type: 'array', - title: 'Properties', - items: { - type: 'object', - properties: { - name: { type: 'string', title: 'Property name' }, - }, - }, - }, - }; - - const result = getFilteredProperties(properties, 'zzz'); - expect(Object.keys(result!)).toHaveLength(0); - }); - - it('should surface an array field when an item sub-property value matches the filter', () => { - const properties: JSONSchema4['properties'] = { - kameletProperties: { - type: 'array', - title: 'Properties', - items: { - type: 'object', - properties: { - name: { type: 'string', title: 'Property name' }, - value: { type: 'string', title: 'Value' }, - }, - }, - }, - }; - const model = { kameletProperties: [{ name: 'Test', value: '' }] }; - - const result = getFilteredProperties(properties, 'test', undefined, model); - expect(Object.keys(result!)).toContain('kameletProperties'); - - const result2 = getFilteredProperties(properties, 'zzz', undefined, model); - expect(Object.keys(result2!)).toHaveLength(0); - }); - - it('should match a plain-string array field on its key name', () => { - const properties: JSONSchema4['properties'] = { - headers: { - type: 'array', - title: 'Headers', - items: { type: 'string' }, - }, - }; - - const result = getFilteredProperties(properties, 'header'); - expect(Object.keys(result!)).toContain('headers'); - - const result2 = getFilteredProperties(properties, 'zzz'); - expect(Object.keys(result2!)).toHaveLength(0); - }); - - it('should surface an object field when the container key matches and no child matches', () => { - const properties: JSONSchema4['properties'] = { - metadata: { - type: 'object', - title: 'Metadata', - properties: { - displayName: { type: 'string', title: 'Display Name' }, - }, - }, - unrelated: { type: 'string', title: 'Unrelated' }, - }; - - // match by key - const byKey = getFilteredProperties(properties, 'metadata'); - expect(Object.keys(byKey!)).toEqual(['metadata']); - expect(byKey!['metadata']).toBe(properties!['metadata']); - - // match by title - const byTitle = getFilteredProperties(properties, 'metadat'); - expect(Object.keys(byTitle!)).toEqual(['metadata']); - - // no match - const noMatch = getFilteredProperties(properties, 'zzz'); - expect(Object.keys(noMatch!)).toHaveLength(0); - }); - - it('should surface an array field when the container key matches and no child matches', () => { - const properties: JSONSchema4['properties'] = { - kameletProperties: { - type: 'array', - title: 'Kamelet Properties', - items: { - type: 'object', - properties: { - name: { type: 'string', title: 'Name' }, - }, - }, - }, - }; - - // match by key - const byKey = getFilteredProperties(properties, 'kameletproperties'); - expect(Object.keys(byKey!)).toEqual(['kameletProperties']); - expect(byKey!['kameletProperties']).toBe(properties!['kameletProperties']); - - // match by title - const byTitle = getFilteredProperties(properties, 'kamelet prop'); - expect(Object.keys(byTitle!)).toEqual(['kameletProperties']); - - // no match - const noMatch = getFilteredProperties(properties, 'zzz'); - expect(Object.keys(noMatch!)).toHaveLength(0); - }); -}); diff --git a/src/form/utils/get-filtered-properties.ts b/src/form/utils/get-filtered-properties.ts deleted file mode 100644 index bb66c72..0000000 --- a/src/form/utils/get-filtered-properties.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { JSONSchema4 } from 'json-schema'; -import { isDefined } from './is-defined'; - -/** - * Returns the nested object property definition when any of its child properties match the filter. - * If the container's own key or title matches the filter, the full unfiltered definition is returned. - */ -function getFilteredObjectProperty( - definition: JSONSchema4, - filter: string, - property: string, - model?: Record, -): JSONSchema4 | undefined { - if ( - property.toLowerCase().includes(filter) || - (definition.title as string | undefined)?.toLowerCase().includes(filter) - ) { - return definition; - } - - const nestedModel = (model?.[property] ?? {}) as Record; - const subFilteredSchema = getFilteredProperties(definition['properties'], filter, undefined, nestedModel); - - return subFilteredSchema && Object.keys(subFilteredSchema).length > 0 - ? { ...definition, properties: subFilteredSchema } - : undefined; -} - -/** - * Returns the array property definition when its item schema or any item value matches the filter. - * If the container's own key or title matches the filter, the full unfiltered definition is returned. - */ -function getFilteredArrayProperty( - definition: JSONSchema4, - filter: string, - property: string, - model?: Record, -): JSONSchema4 | undefined { - if ( - property.toLowerCase().includes(filter) || - (definition.title as string | undefined)?.toLowerCase().includes(filter) - ) { - return definition; - } - - const itemsProperties = (definition.items as JSONSchema4).properties; - const schemaMatch = getFilteredProperties(itemsProperties, filter); - const schemaMatched = schemaMatch && Object.keys(schemaMatch).length > 0; - const modelValue = model?.[property]; - const valueMatched = - !schemaMatched && - Array.isArray(modelValue) && - modelValue.some((item) => { - const itemModel = (item ?? {}) as Record; - const sub = getFilteredProperties(itemsProperties, filter, undefined, itemModel); - return sub && Object.keys(sub).length > 0; - }); - - return schemaMatched || valueMatched ? definition : undefined; -} - -/** - * Extracts the schema recursively containing only the filtered properties. - * - * A property is included if any of the following match: - * - The property key name contains the filter - * - The schema `title` label contains the filter - * - The current runtime value (from `model`) contains the filter - * - * Object properties are delegated to `getFilteredObjectProperty()` and array - * properties with nested item `properties` are delegated to - * `getFilteredArrayProperty()`. - */ -export function getFilteredProperties( - properties: JSONSchema4['properties'], - filter: string, - omitFields?: string[], - model?: Record, -): JSONSchema4['properties'] { - if (!isDefined(properties)) return {}; - if (filter.length === 0) { - if (!omitFields?.length) return properties; - - return Object.fromEntries(Object.entries(properties).filter(([property]) => !omitFields.includes(property))); - } - - const filteredFormSchema = Object.entries(properties).reduce( - (acc, [property, definition]) => { - if (!omitFields?.includes(property)) { - if (definition['type'] === 'object' && 'properties' in definition) { - const filteredObjectProperty = getFilteredObjectProperty(definition, filter, property, model); - if (filteredObjectProperty) { - acc![property] = filteredObjectProperty; - } - } else if (definition['type'] === 'array' && isDefined((definition.items as JSONSchema4)?.properties)) { - const filteredArrayProperty = getFilteredArrayProperty(definition, filter, property, model); - if (filteredArrayProperty) { - acc![property] = filteredArrayProperty; - } - } else if ( - property.toLowerCase().includes(filter) || - (definition.title as string | undefined)?.toLowerCase().includes(filter) || - (() => { - const val = model?.[property]; - if (typeof val !== 'string' && typeof val !== 'number' && typeof val !== 'boolean') return false; - return String(val).toLowerCase().includes(filter); - })() - ) { - acc![property] = definition; - } - } - - return acc; - }, - {} as JSONSchema4['properties'], - ); - - return filteredFormSchema; -} From b6edc1df0c395c75bb70bf6e714166953a64e914 Mon Sep 17 00:00:00 2001 From: PVinaches Date: Thu, 30 Jul 2026 15:30:43 +0100 Subject: [PATCH 3/3] fix: clean export, snap, fixed expectation in test --- src/form/utils/SchemaPropertyFilter.test.ts | 2 +- .../get-filtered-properties.test.ts.snap | 486 ------------------ src/form/utils/index.ts | 1 - 3 files changed, 1 insertion(+), 488 deletions(-) delete mode 100644 src/form/utils/__snapshots__/get-filtered-properties.test.ts.snap diff --git a/src/form/utils/SchemaPropertyFilter.test.ts b/src/form/utils/SchemaPropertyFilter.test.ts index a5e8e3c..19a773f 100644 --- a/src/form/utils/SchemaPropertyFilter.test.ts +++ b/src/form/utils/SchemaPropertyFilter.test.ts @@ -113,7 +113,7 @@ describe('SchemaPropertyFilter', () => { const result = SchemaPropertyFilter.filter(properties, 'e', ['email']); - expect(Object.keys(result!)).toEqual(['username', 'password']); + expect(Object.keys(result!)).toEqual(['username']); }); }); diff --git a/src/form/utils/__snapshots__/get-filtered-properties.test.ts.snap b/src/form/utils/__snapshots__/get-filtered-properties.test.ts.snap deleted file mode 100644 index 79cd27e..0000000 --- a/src/form/utils/__snapshots__/get-filtered-properties.test.ts.snap +++ /dev/null @@ -1,486 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`getFilteredProperties() should return only the filtered properties 1`] = ` -{ - "description": { - "description": "Sets the description of this node", - "title": "Description", - "type": "string", - }, - "openApi": { - "additionalProperties": false, - "description": "To use OpenApi as contract-first with Camel Rest DSL.", - "properties": { - "description": { - "description": "Sets the description of this node", - "title": "Description", - "type": "string", - }, - }, - "required": [ - "specification", - ], - "title": "Open Api", - "type": "object", - }, - "securityDefinitions": { - "additionalProperties": false, - "description": "To configure rest security definitions.", - "properties": { - "apiKey": { - "additionalProperties": false, - "description": "Rest security basic auth definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - }, - "required": [ - "key", - "name", - ], - "title": "Api Key", - "type": "object", - }, - "basicAuth": { - "additionalProperties": false, - "description": "Rest security basic auth definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - }, - "required": [ - "key", - ], - "title": "Basic Auth", - "type": "object", - }, - "bearer": { - "additionalProperties": false, - "description": "Rest security bearer token authentication definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - }, - "required": [ - "key", - ], - "title": "Bearer Token", - "type": "object", - }, - "mutualTLS": { - "additionalProperties": false, - "description": "Rest security mutual TLS authentication definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - }, - "required": [ - "key", - ], - "title": "Mutual TLS", - "type": "object", - }, - "oauth2": { - "additionalProperties": false, - "description": "Rest security OAuth2 definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - }, - "required": [ - "key", - ], - "title": "Oauth2", - "type": "object", - }, - "openIdConnect": { - "additionalProperties": false, - "description": "Rest security OpenID Connect definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - }, - "required": [ - "key", - "url", - ], - "title": "Open Id Connect", - "type": "object", - }, - }, - "title": "Rest Security Definitions", - "type": "object", - }, -} -`; - -exports[`getFilteredProperties() should return only the un-omitted properties 1`] = ` -{ - "apiDocs": { - "default": true, - "description": "Whether to include or exclude this rest operation in API documentation. This option will override what may be configured on a parent level. The default value is true.", - "title": "Api Docs", - "type": "boolean", - }, - "bindingMode": { - "default": "off", - "description": "Sets the binding mode to use. This option will override what may be configured on a parent level The default value is auto", - "enum": [ - "off", - "auto", - "json", - "xml", - "json_xml", - ], - "title": "Binding Mode", - "type": "string", - }, - "clientRequestValidation": { - "default": false, - "description": "Whether to enable validation of the client request to check: 1) Content-Type header matches what the Rest DSL consumes; returns HTTP Status 415 if validation error. 2) Accept header matches what the Rest DSL produces; returns HTTP Status 406 if validation error. 3) Missing required data (query parameters, HTTP headers, body); returns HTTP Status 400 if validation error. 4) Parsing error of the message body (JSon, XML or Auto binding mode must be enabled); returns HTTP Status 400 if validation error.", - "title": "Client Request Validation", - "type": "boolean", - }, - "consumes": { - "description": "To define the content type what the REST service consumes (accept as input), such as application/xml or application/json. This option will override what may be configured on a parent level", - "title": "Consumes", - "type": "string", - }, - "description": { - "description": "Sets the description of this node", - "title": "Description", - "type": "string", - }, - "disabled": { - "default": false, - "description": "Whether to disable this REST service from the route during build time. Once an REST service has been disabled then it cannot be enabled later at runtime.", - "title": "Disabled", - "type": "boolean", - }, - "enableCORS": { - "default": false, - "description": "Whether to enable CORS headers in the HTTP response. This option will override what may be configured on a parent level The default value is false.", - "title": "Enable CORS", - "type": "boolean", - }, - "enableNoContentResponse": { - "default": false, - "description": "Whether to return HTTP 204 with an empty body when a response contains an empty JSON object or XML root object. The default value is false.", - "title": "Enable No Content Response", - "type": "boolean", - }, - "head": { - "items": { - "$ref": "#/definitions/org.apache.camel.model.rest.HeadDefinition", - }, - "type": "array", - }, - "id": { - "description": "Sets the id of this node", - "title": "Id", - "type": "string", - }, - "openApi": { - "additionalProperties": false, - "description": "To use OpenApi as contract-first with Camel Rest DSL.", - "properties": { - "description": { - "description": "Sets the description of this node", - "title": "Description", - "type": "string", - }, - "disabled": { - "description": "Whether to disable all the REST services from the OpenAPI contract from the route during build time. Once an REST service has been disabled then it cannot be enabled later at runtime.", - "title": "Disabled", - "type": "boolean", - }, - "id": { - "description": "Sets the id of this node", - "title": "Id", - "type": "string", - }, - "missingOperation": { - "default": "fail", - "description": "Whether to fail, ignore or return a mock response for OpenAPI operations that are not mapped to a corresponding route.", - "enum": [ - "fail", - "ignore", - "mock", - ], - "title": "Missing Operation", - "type": "string", - }, - "mockIncludePattern": { - "default": "classpath:camel-mock/**", - "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma.", - "title": "Mock Include Pattern", - "type": "string", - }, - "routeId": { - "description": "Sets the id of the route", - "title": "Route Id", - "type": "string", - }, - "specification": { - "description": "Path to the OpenApi specification file.", - "title": "Specification", - "type": "string", - }, - }, - "required": [ - "specification", - ], - "title": "Open Api", - "type": "object", - }, - "path": { - "description": "Path of the rest service, such as /foo", - "title": "Path", - "type": "string", - }, - "produces": { - "description": "To define the content type what the REST service produces (uses for output), such as application/xml or application/json This option will override what may be configured on a parent level", - "title": "Produces", - "type": "string", - }, - "securityDefinitions": { - "additionalProperties": false, - "description": "To configure rest security definitions.", - "properties": { - "apiKey": { - "additionalProperties": false, - "description": "Rest security basic auth definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - "inCookie": { - "description": "To use a cookie as the location of the API key.", - "title": "In Cookie", - "type": "boolean", - }, - "inHeader": { - "description": "To use header as the location of the API key.", - "title": "In Header", - "type": "boolean", - }, - "inQuery": { - "description": "To use query parameter as the location of the API key.", - "title": "In Query", - "type": "boolean", - }, - "key": { - "description": "Key used to refer to this security definition", - "title": "Key", - "type": "string", - }, - "name": { - "description": "The name of the header or query parameter to be used.", - "title": "Name", - "type": "string", - }, - }, - "required": [ - "key", - "name", - ], - "title": "Api Key", - "type": "object", - }, - "basicAuth": { - "additionalProperties": false, - "description": "Rest security basic auth definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - "key": { - "description": "Key used to refer to this security definition", - "title": "Key", - "type": "string", - }, - }, - "required": [ - "key", - ], - "title": "Basic Auth", - "type": "object", - }, - "bearer": { - "additionalProperties": false, - "description": "Rest security bearer token authentication definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - "format": { - "description": "A hint to the client to identify how the bearer token is formatted.", - "title": "Format", - "type": "string", - }, - "key": { - "description": "Key used to refer to this security definition", - "title": "Key", - "type": "string", - }, - }, - "required": [ - "key", - ], - "title": "Bearer Token", - "type": "object", - }, - "mutualTLS": { - "additionalProperties": false, - "description": "Rest security mutual TLS authentication definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - "key": { - "description": "Key used to refer to this security definition", - "title": "Key", - "type": "string", - }, - }, - "required": [ - "key", - ], - "title": "Mutual TLS", - "type": "object", - }, - "oauth2": { - "additionalProperties": false, - "description": "Rest security OAuth2 definition", - "properties": { - "authorizationUrl": { - "description": "The authorization URL to be used for this flow. This SHOULD be in the form of a URL. Required for implicit and access code flows", - "title": "Authorization Url", - "type": "string", - }, - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - "flow": { - "description": "The flow used by the OAuth2 security scheme. Valid values are implicit, password, application or accessCode.", - "enum": [ - "implicit", - "password", - "application", - "clientCredentials", - "accessCode", - "authorizationCode", - ], - "title": "Flow", - "type": "string", - }, - "key": { - "description": "Key used to refer to this security definition", - "title": "Key", - "type": "string", - }, - "refreshUrl": { - "description": "The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL.", - "title": "Refresh Url", - "type": "string", - }, - "scopes": { - "description": "The available scopes for an OAuth2 security scheme", - "items": { - "$ref": "#/definitions/org.apache.camel.model.rest.RestPropertyDefinition", - }, - "title": "Scopes", - "type": "array", - }, - "tokenUrl": { - "description": "The token URL to be used for this flow. This SHOULD be in the form of a URL. Required for password, application, and access code flows.", - "title": "Token Url", - "type": "string", - }, - }, - "required": [ - "key", - ], - "title": "Oauth2", - "type": "object", - }, - "openIdConnect": { - "additionalProperties": false, - "description": "Rest security OpenID Connect definition", - "properties": { - "description": { - "description": "A short description for security scheme.", - "title": "Description", - "type": "string", - }, - "key": { - "description": "Key used to refer to this security definition", - "title": "Key", - "type": "string", - }, - "url": { - "description": "OpenId Connect URL to discover OAuth2 configuration values.", - "title": "Url", - "type": "string", - }, - }, - "required": [ - "key", - "url", - ], - "title": "Open Id Connect", - "type": "object", - }, - }, - "title": "Rest Security Definitions", - "type": "object", - }, - "securityRequirements": { - "description": "Sets the security requirement(s) for all endpoints.", - "items": { - "$ref": "#/definitions/org.apache.camel.model.rest.SecurityDefinition", - }, - "title": "Security Requirements", - "type": "array", - }, - "skipBindingOnErrorCode": { - "default": false, - "description": "Whether to skip binding on output if there is a custom HTTP error code header. This allows to build custom error messages that do not bind to json / xml etc, as success messages otherwise will do. This option will override what may be configured on a parent level", - "title": "Skip Binding On Error Code", - "type": "boolean", - }, - "tag": { - "description": "To configure a special tag for the operations within this rest definition.", - "title": "Tag", - "type": "string", - }, -} -`; diff --git a/src/form/utils/index.ts b/src/form/utils/index.ts index 1fbb627..71c54c6 100644 --- a/src/form/utils/index.ts +++ b/src/form/utils/index.ts @@ -3,7 +3,6 @@ export * from './camel-random-id'; export * from './capitalize-string'; export * from './get-applied-schema-index'; export * from './get-field-groups'; -export * from './get-filtered-properties'; export * from './get-item-from-schema'; export * from './get-oneof-schema-list'; export * from './get-tagged-field-from-string';