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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/components/Schema/ObjectSchema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<PropertiesTable>
{showTitle && <PropertiesTableCaption>{title}</PropertiesTableCaption>}
<tbody>
Expand Down Expand Up @@ -83,5 +93,19 @@ export const ObjectSchema = observer(
</tbody>
</PropertiesTable>
);

if (!showStandaloneDiscriminator) {
return propertiesTable;
}

return (
<>
<DiscriminatorDropdown
parent={discriminator!.parentSchema}
enumValues={discriminator!.parentSchema.oneOf?.map(s => s.title) ?? []}
/>
{propertiesTable}
</>
);
},
);
56 changes: 56 additions & 0 deletions src/components/__tests__/DiscriminatorDropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(
<ObjectSchema
schema={schema.oneOf![0]}
discriminator={{
fieldName: schema.discriminatorProp,
parentSchema: schema,
}}
/>,
);
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(
<ObjectSchema
schema={schema.oneOf![0]}
discriminator={{
fieldName: schema.discriminatorProp,
parentSchema: schema,
}}
/>,
);
// the inline dropdown lives inside a <Field> render prop and is not a direct child here
expect(schemaView.find(DiscriminatorDropdown)).toHaveLength(0);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
}