diff --git a/test/e2e-cypress/e2e/bugs/5460.cy.js b/test/e2e-cypress/e2e/bugs/5460.cy.js new file mode 100644 index 00000000000..b1dbdd1e321 --- /dev/null +++ b/test/e2e-cypress/e2e/bugs/5460.cy.js @@ -0,0 +1,92 @@ +/** + * @prettier + */ +// https://github.com/swagger-api/swagger-ui/issues/5460 + +describe("#5460: Displayed example not updated when switching between media types", () => { + const mediaTypeJson = "application/json" + const mediaTypeXml = "application/xml" + const mediaTypePlain = "text/plain" + + beforeEach(() => { + cy.visit("/?url=/documents/bugs/5460.yaml") + .get("#operations-default-post_foo") + .click() + cy.get(".opblock-section-request-body .content-type").as("selectMediaType") + }) + + it("should update the displayed Example Value when switching from application/json (bar_json) to application/xml", () => { + cy.get("@selectMediaType").should("have.value", mediaTypeJson) + cy.get(".opblock-section-request-body .examples-select-element").select( + "bar_json" + ) + cy.get(".opblock-section-request-body .body-param__example").should( + "contain.text", + "bar" + ) + + cy.get("@selectMediaType").select(mediaTypeXml) + cy.get(".opblock-section-request-body .body-param__example") + .should("contain.text", "<") + .should("not.contain.text", '"bar"') + }) + + it("should display the text/plain example after switching from application/json (bar_json) to text/plain", () => { + cy.get("@selectMediaType").should("have.value", mediaTypeJson) + cy.get(".opblock-section-request-body .examples-select-element").select( + "bar_json" + ) + cy.get(".opblock-section-request-body .body-param__example").should( + "contain.text", + "bar" + ) + + cy.get("@selectMediaType").select(mediaTypePlain) + cy.get(".opblock-section-request-body .body-param__example") + .should("contain.text", "Hello, world!") + .should("not.contain.text", '"bar"') + }) + + it("should reset the Examples dropdown to the first example of the new media type when the previous active example does not exist", () => { + cy.get(".opblock-section-request-body .examples-select-element").select( + "bar_json" + ) + cy.get(".opblock-section-request-body .examples-select-element").should( + "have.value", + "bar_json" + ) + + cy.get("@selectMediaType").select(mediaTypeXml) + cy.get(".opblock-section-request-body .examples-select-element").should( + "have.value", + "foo_xml" + ) + }) + + it("should follow the full repro path: json/bar_json -> xml -> text/plain and display each media type's example", () => { + // Step 3: select bar_json under application/json + cy.get("@selectMediaType").should("have.value", mediaTypeJson) + cy.get(".opblock-section-request-body .examples-select-element").select( + "bar_json" + ) + cy.get(".opblock-section-request-body .body-param__example").should( + "contain.text", + "bar" + ) + + // Step 4: switch to application/xml; Example Value pane must update + cy.get("@selectMediaType").select(mediaTypeXml) + cy.get(".opblock-section-request-body .body-param__example").should( + "contain.text", + "<" + ) + + // Step 5: switch to text/plain; Example Value pane must update again + cy.get("@selectMediaType").select(mediaTypePlain) + cy.get(".opblock-section-request-body .body-param__example") + .should("contain.text", "Hello, world!") + .should("not.contain.text", '"bar"') + .should("not.contain.text", "") + .should("not.contain.text", "") + }) +}) diff --git a/test/e2e-cypress/static/documents/bugs/5460.yaml b/test/e2e-cypress/static/documents/bugs/5460.yaml new file mode 100644 index 00000000000..ebab3c92c8d --- /dev/null +++ b/test/e2e-cypress/static/documents/bugs/5460.yaml @@ -0,0 +1,36 @@ +openapi: 3.0.2 +info: + title: test + version: 0.0.0 +paths: + /foo: + post: + requestBody: + description: A JSON object, XML object, or text string containing foo data + required: true + content: + application/json: + schema: + type: object + examples: + foo_json: + value: + foo: bar + bar_json: + value: + bar: foo + text/plain: + schema: + type: string + example: Hello, world! + application/xml: + schema: + type: object + examples: + foo_xml: + value: bar + bar_xml: + value: foo + responses: + 201: + description: Created diff --git a/test/unit/components/examples-select.jsx b/test/unit/components/examples-select.jsx new file mode 100644 index 00000000000..512b82dee17 --- /dev/null +++ b/test/unit/components/examples-select.jsx @@ -0,0 +1,131 @@ +/* eslint-disable camelcase */ +/** + * @prettier + */ +import React from "react" +import { OrderedMap, fromJS } from "immutable" +import { mount } from "enzyme" + +import ExamplesSelect from "core/components/examples-select" + +describe("", () => { + // Regression coverage for https://github.com/swagger-api/swagger-ui/issues/5460 + // When the user switches media type, `examples` changes from under the + // component. If the previously-selected example key is not part of the new + // `examples`, the component must auto-snap to the first example of the new + // set and notify its parent via a synthetic `onSelect`. + describe("auto-snap when examples change and currentExampleKey is stale", () => { + it("fires a synthetic onSelect with the first new example key", () => { + const jsonExamples = fromJS({ + foo_json: { value: { foo: "bar" } }, + bar_json: { value: { bar: "foo" } }, + }) + const xmlExamples = fromJS({ + foo_xml: { value: "bar" }, + bar_xml: { value: "foo" }, + }) + const onSelect = jest.fn() + + const wrapper = mount( + + ) + + // ignore the synthetic onSelect fired from componentDidMount + onSelect.mockClear() + + wrapper.setProps({ examples: xmlExamples }) + + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith("foo_xml", { + isSyntheticChange: true, + }) + }) + + it("does not snap when the previous key exists in the new examples", () => { + const examplesA = fromJS({ + shared: { value: "A-shared" }, + only_a: { value: "A-only" }, + }) + const examplesB = fromJS({ + shared: { value: "B-shared" }, + only_b: { value: "B-only" }, + }) + const onSelect = jest.fn() + + const wrapper = mount( + + ) + + onSelect.mockClear() + wrapper.setProps({ examples: examplesB }) + + expect(onSelect).not.toHaveBeenCalled() + }) + + it("fires no auto-snap when the underlying examples reference does not change", () => { + const examples = fromJS({ + a: { value: "A" }, + b: { value: "B" }, + }) + const onSelect = jest.fn() + + const wrapper = mount( + + ) + + onSelect.mockClear() + // identical reference; should not trigger the auto-snap branch + wrapper.setProps({ examples }) + + expect(onSelect).not.toHaveBeenCalled() + }) + }) + + describe("componentDidMount", () => { + it("fires a synthetic onSelect with the first example key on mount", () => { + const examples = fromJS({ + first_key: { value: "first" }, + second_key: { value: "second" }, + }) + const onSelect = jest.fn() + + mount( + + ) + + expect(onSelect).toHaveBeenCalledWith("first_key", { + isSyntheticChange: true, + }) + }) + + it("does not throw when examples is empty", () => { + const onSelect = jest.fn() + + expect(() => { + mount( + + ) + }).not.toThrow() + }) + }) +}) diff --git a/test/unit/core/plugins/oas3/state-integration-5460.js b/test/unit/core/plugins/oas3/state-integration-5460.js new file mode 100644 index 00000000000..5c8554b4abd --- /dev/null +++ b/test/unit/core/plugins/oas3/state-integration-5460.js @@ -0,0 +1,155 @@ +/** + * @prettier + */ +// Regression coverage for https://github.com/swagger-api/swagger-ui/issues/5460 +// +// The bug: switching media-types did not refresh the displayed Example Value +// when the active example key from the previous media type no longer existed +// in the new one. This is a state-shape test that documents the contract +// between media-type and active-example state: the active-example key is not +// cleared by `setRequestContentType`, so the UI layer is responsible for +// fixing up the stale key when the underlying `examples` change. See +// `ExamplesSelect.UNSAFE_componentWillReceiveProps`. + +import { fromJS } from "immutable" + +import reducers from "core/plugins/oas3/reducers" +import { + activeExamplesMember, + requestContentType, +} from "core/plugins/oas3/selectors" +import { + setActiveExamplesMember, + setRequestContentType, +} from "core/plugins/oas3/actions" + +const oas3System = { + getSystem() { + return { + specSelectors: { + specJson: () => fromJS({ openapi: "3.0.2" }), + isOAS3: () => true, + }, + } + }, +} + +describe("#5460 state integration: media-type switch and active example member", () => { + it("should record the active example member for the request body", () => { + let state = fromJS({}) + + state = reducers["oas3_set_request_content_type"]( + state, + setRequestContentType({ + value: "application/json", + pathMethod: ["/foo", "post"], + }) + ) + state = reducers["oas3_set_active_examples_member"]( + state, + setActiveExamplesMember({ + name: "bar_json", + pathMethod: ["/foo", "post"], + contextType: "requestBody", + contextName: "requestBody", + }) + ) + + expect(requestContentType(state, "/foo", "post")(oas3System)).toEqual( + "application/json" + ) + expect( + activeExamplesMember( + state, + "/foo", + "post", + "requestBody", + "requestBody" + )(oas3System) + ).toEqual("bar_json") + }) + + it("should NOT clear the active example member when only the request content type changes", () => { + // This documents the existing state-shape: setRequestContentType does not + // touch the active-example key. The UI layer is responsible for + // reconciling the stale key when the examples Map changes. + let state = fromJS({}) + + state = reducers["oas3_set_request_content_type"]( + state, + setRequestContentType({ + value: "application/json", + pathMethod: ["/foo", "post"], + }) + ) + state = reducers["oas3_set_active_examples_member"]( + state, + setActiveExamplesMember({ + name: "bar_json", + pathMethod: ["/foo", "post"], + contextType: "requestBody", + contextName: "requestBody", + }) + ) + // user switches to application/xml + state = reducers["oas3_set_request_content_type"]( + state, + setRequestContentType({ + value: "application/xml", + pathMethod: ["/foo", "post"], + }) + ) + + expect(requestContentType(state, "/foo", "post")(oas3System)).toEqual( + "application/xml" + ) + // active example key is intentionally retained at the reducer level + expect( + activeExamplesMember( + state, + "/foo", + "post", + "requestBody", + "requestBody" + )(oas3System) + ).toEqual("bar_json") + }) + + it("should update the active example member after the synthetic onSelect from the UI", () => { + // Simulates what `ExamplesSelect.UNSAFE_componentWillReceiveProps` does + // when the previous active example key is no longer in the new examples + // Map: it fires `_onSelect(firstExampleKey, { isSyntheticChange: true })`, + // which dispatches `setActiveExamplesMember` for the new media type. + let state = fromJS({}) + + state = reducers["oas3_set_active_examples_member"]( + state, + setActiveExamplesMember({ + name: "bar_json", + pathMethod: ["/foo", "post"], + contextType: "requestBody", + contextName: "requestBody", + }) + ) + // media-type switched; UI synthetically updates active example + state = reducers["oas3_set_active_examples_member"]( + state, + setActiveExamplesMember({ + name: "foo_xml", + pathMethod: ["/foo", "post"], + contextType: "requestBody", + contextName: "requestBody", + }) + ) + + expect( + activeExamplesMember( + state, + "/foo", + "post", + "requestBody", + "requestBody" + )(oas3System) + ).toEqual("foo_xml") + }) +})