From 55474403bdcac11607790f94016e1a1c0ab3f8ff Mon Sep 17 00:00:00 2001 From: Tim Abil Date: Thu, 18 Jun 2026 18:25:18 -0400 Subject: [PATCH] fix: render discriminator selector when variants omit the discriminator property When a schema uses oneOf + discriminator but the variant schemas don't declare the discriminator property themselves (no allOf inheritance from a parent that does), the selector was mounted only as a side effect of a field row whose name matched the discriminator property. With no such field, nothing rendered the selector, so only the first variant was shown. ObjectSchema now renders the DiscriminatorDropdown standalone when no matching field exists. Specs that declare the property keep the existing inline behaviour, so the common path is unchanged. --- src/components/Schema/ObjectSchema.tsx | 26 ++++++++- .../__tests__/DiscriminatorDropdown.test.tsx | 56 +++++++++++++++++++ .../fixtures/oneof-discriminator-no-prop.json | 39 +++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/components/__tests__/fixtures/oneof-discriminator-no-prop.json diff --git a/src/components/Schema/ObjectSchema.tsx b/src/components/Schema/ObjectSchema.tsx index b59ea989f5..ed7c822679 100644 --- a/src/components/Schema/ObjectSchema.tsx +++ b/src/components/Schema/ObjectSchema.tsx @@ -49,7 +49,17 @@ export const ObjectSchema = observer( const expandByDefault = (expandSingleSchemaField && filteredFields.length === 1) || schemasExpansionLevel >= level!; - return ( + // The discriminator dropdown is normally attached to the field row whose name + // matches the discriminator property. When the variant schemas don't declare that + // property (e.g. a `oneOf` + `discriminator` written without `allOf` inheritance, as + // commonly emitted by code generators), no field matches and the selector would + // silently disappear, leaving only the first variant visible. In that case render the + // dropdown standalone so the polymorphic variants stay switchable. + const hasDiscriminatorField = + !!discriminator && filteredFields.some(field => field.name === discriminator.fieldName); + const showStandaloneDiscriminator = !!discriminator && !hasDiscriminatorField; + + const propertiesTable = ( {showTitle && {title}} @@ -83,5 +93,19 @@ export const ObjectSchema = observer( ); + + if (!showStandaloneDiscriminator) { + return propertiesTable; + } + + return ( + <> + s.title) ?? []} + /> + {propertiesTable} + + ); }, ); diff --git a/src/components/__tests__/DiscriminatorDropdown.test.tsx b/src/components/__tests__/DiscriminatorDropdown.test.tsx index 1a625b94b7..c35d91cb98 100644 --- a/src/components/__tests__/DiscriminatorDropdown.test.tsx +++ b/src/components/__tests__/DiscriminatorDropdown.test.tsx @@ -8,9 +8,11 @@ import * as React from 'react'; import { filterPropsDeep } from '../../utils/test-utils'; import { ObjectSchema, Schema } from '../'; +import { DiscriminatorDropdown } from '../Schema/DiscriminatorDropdown'; import { OpenAPIParser, SchemaModel } from '../../services'; import { RedocNormalizedOptions } from '../../services/RedocNormalizedOptions'; import * as simpleDiscriminatorFixture from './fixtures/simple-discriminator.json'; +import * as oneOfDiscriminatorNoPropFixture from './fixtures/oneof-discriminator-no-prop.json'; const options = new RedocNormalizedOptions({}); describe('Components', () => { @@ -52,6 +54,60 @@ describe('Components', () => { ); expect(filterPropsDeep(toJson(schemaView), ['field.schema.options'])).toMatchSnapshot(); }); + + it('should still render the dropdown when variants do not declare the discriminator property', () => { + // oneOf + discriminator where the variant schemas (CardPaymentInput, CashPaymentInput) + // neither declare `__typename` nor inherit it via allOf. Previously the selector was + // attached only to a matching field row, so it silently disappeared and only the first + // variant was shown. The dropdown must now render standalone. + const parser = new OpenAPIParser(oneOfDiscriminatorNoPropFixture, undefined, options); + const schema = new SchemaModel( + parser, + { $ref: '#/components/schemas/PaymentInput' }, + '#/components/schemas/PaymentInput', + options, + ); + + expect(schema.discriminatorProp).toEqual('__typename'); + expect(schema.oneOf).toHaveLength(2); + // sanity: the active variant genuinely has no field named like the discriminator + expect(schema.oneOf![0].fields?.some(f => f.name === schema.discriminatorProp)).toBe(false); + + const schemaView = shallow( + , + ); + expect(schemaView.find(DiscriminatorDropdown)).toHaveLength(1); + }); + + it('should not render a standalone dropdown when the variant declares the discriminator property', () => { + // Regression guard for the standard allOf-inheritance pattern: the discriminator field + // exists on the variant, so the selector stays inline on that field row and no extra + // standalone dropdown is added at the ObjectSchema level. + const parser = new OpenAPIParser(simpleDiscriminatorFixture, undefined, options); + const schema = new SchemaModel( + parser, + { $ref: '#/components/schemas/Pet' }, + '#/components/schemas/Pet', + options, + ); + const schemaView = shallow( + , + ); + // the inline dropdown lives inside a render prop and is not a direct child here + expect(schemaView.find(DiscriminatorDropdown)).toHaveLength(0); + }); }); }); }); diff --git a/src/components/__tests__/fixtures/oneof-discriminator-no-prop.json b/src/components/__tests__/fixtures/oneof-discriminator-no-prop.json new file mode 100644 index 0000000000..a9c3c0d207 --- /dev/null +++ b/src/components/__tests__/fixtures/oneof-discriminator-no-prop.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.1.0", + "components": { + "schemas": { + "PaymentInput": { + "type": "object", + "properties": { + "amount": { "type": "integer" } + }, + "oneOf": [ + { "$ref": "#/components/schemas/CardPaymentInput" }, + { "$ref": "#/components/schemas/CashPaymentInput" } + ], + "required": ["amount"], + "discriminator": { + "propertyName": "__typename", + "mapping": { + "CardPayment": "#/components/schemas/CardPaymentInput", + "CashPayment": "#/components/schemas/CashPaymentInput" + } + } + }, + "CardPaymentInput": { + "type": "object", + "properties": { + "last4": { "type": "string" } + }, + "required": ["last4"] + }, + "CashPaymentInput": { + "type": "object", + "properties": { + "tendered": { "type": "integer" } + }, + "required": ["tendered"] + } + } + } +}