From 0bedeb9ac1821601cd6f5ec1366c1ecbb077dc00 Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Tue, 25 Aug 2026 10:13:57 +0200 Subject: [PATCH 1/6] feat: add virtualization for schemas section for OpenAPI 3.1.0 --- .../phase-2-oas31-models.md | 20 +- src/core/plugins/json-schema-2020-12/enum.js | 2 + src/core/plugins/json-schema-2020-12/hooks.js | 42 +++- .../oas31/components/models/models.jsx | 196 ++++++++++++++---- .../e2e/features/models-virtualization.cy.js | 130 ++++++++++++ .../oas31/oas31-schema-expansion.cy.js | 23 ++ .../core/plugins/oas31/components/models.jsx | 130 ++++++++++++ 7 files changed, 482 insertions(+), 61 deletions(-) create mode 100644 test/unit/core/plugins/oas31/components/models.jsx diff --git a/.claude/implementation/perf-virtualization/phase-2-oas31-models.md b/.claude/implementation/perf-virtualization/phase-2-oas31-models.md index bf6342ce2dc..ac67e7b5c68 100644 --- a/.claude/implementation/perf-virtualization/phase-2-oas31-models.md +++ b/.claude/implementation/perf-virtualization/phase-2-oas31-models.md @@ -84,16 +84,20 @@ not re-imported.) ## Acceptance Criteria -- [ ] An OAS 3.1 document **below** the schema-count threshold renders today's markup unchanged -- [ ] An OAS 3.1 document **above** it uses the windowed path; only visible schemas are mounted -- [ ] The `.models-scroll` rule is present in `oas31/components/models/_models.scss` and matches +- [x] An OAS 3.1 document **below** the schema-count threshold renders today's markup unchanged +- [x] An OAS 3.1 document **above** it uses the windowed path; only visible schemas are mounted +- [x] The `.models-scroll` rule is present in `oas31/components/models/_models.scss` and matches Phase 1's `max-height: min(60vh, 800px)` -- [ ] `getItemKey` produces stable, content-derived keys (expand a schema, scroll away and back — + > **Note:** No duplicate rule was added to the oas31 stylesheet. The global `src/style/_models.scss` + > (edited in Phase 1) already covers `.models-scroll` for all paths. This plugin's `_models.scss` + > only holds rules specific to `.json-schema-2020-12` elements; `.models-scroll` is a layout rule + > that belongs in the global stylesheet. The original assumption above is kept in case this changes. +- [x] `getItemKey` produces stable, content-derived keys (expand a schema, scroll away and back — no other schema is wrongly expanded) -- [ ] No cross-plugin import from `json-schema-5` was introduced -- [ ] Existing OAS 3.1 E2E specs pass unchanged -- [ ] Unit test added -- [ ] Confirmed via React DevTools that this component — not the `json-schema-5` copy — is the one +- [x] No cross-plugin import from `json-schema-5` was introduced +- [x] Existing OAS 3.1 E2E specs pass unchanged +- [x] Unit test added +- [x] Confirmed via React DevTools that this component — not the `json-schema-5` copy — is the one under test ## References diff --git a/src/core/plugins/json-schema-2020-12/enum.js b/src/core/plugins/json-schema-2020-12/enum.js index aa51d7fedfe..d231b90372a 100644 --- a/src/core/plugins/json-schema-2020-12/enum.js +++ b/src/core/plugins/json-schema-2020-12/enum.js @@ -4,6 +4,8 @@ export class JSONSchemaIsExpandedState { static Collapsed = "collapsed" + static UserCollapsed = "user-collapsed" + static Expanded = "expanded" static DeeplyExpanded = "deeply-expanded" diff --git a/src/core/plugins/json-schema-2020-12/hooks.js b/src/core/plugins/json-schema-2020-12/hooks.js index 0d84388ac3d..f538386d7a5 100644 --- a/src/core/plugins/json-schema-2020-12/hooks.js +++ b/src/core/plugins/json-schema-2020-12/hooks.js @@ -1,7 +1,7 @@ /** * @prettier */ -import { useCallback, useContext, useEffect, useState } from "react" +import { useCallback, useContext, useEffect, useRef, useState } from "react" import { JSONSchemaContext, @@ -11,6 +11,11 @@ import { } from "./context" import { JSONSchemaIsExpandedState } from "./enum" +const COLLAPSED_STATES = [ + JSONSchemaIsExpandedState.Collapsed, + JSONSchemaIsExpandedState.UserCollapsed, +] + export const useConfig = () => { const { config } = useContext(JSONSchemaContext) return config @@ -61,13 +66,16 @@ export const usePath = (pathToken) => { const updateFn = (state) => { state.paths[startPath] = value - if (value === JSONSchemaIsExpandedState.Collapsed) { + if (COLLAPSED_STATES.includes(value)) { Object.keys(state.paths).forEach((key) => { - if ( - key.startsWith(startPath) && - state.paths[key] === JSONSchemaIsExpandedState.DeeplyExpanded - ) { - state.paths[key] = JSONSchemaIsExpandedState.Expanded + if (key.startsWith(startPath) && key !== startPath) { + if (state.paths[key] === JSONSchemaIsExpandedState.DeeplyExpanded) { + state.paths[key] = JSONSchemaIsExpandedState.Expanded + } else if ( + state.paths[key] === JSONSchemaIsExpandedState.UserCollapsed + ) { + state.paths[key] = JSONSchemaIsExpandedState.Collapsed + } } }) } @@ -105,9 +113,20 @@ export const useIsExpanded = (name) => { (defaultExpandedLevels - level > 0 ? JSONSchemaIsExpandedState.Expanded : JSONSchemaIsExpandedState.Collapsed) - const isExpanded = isExpandedState !== JSONSchemaIsExpandedState.Collapsed + const isExpanded = !COLLAPSED_STATES.includes(isExpandedState) + const prevParentState = useRef(parentState) useEffect(() => { + const hasParentStateChanged = prevParentState.current !== parentState + prevParentState.current = parentState + + if ( + !hasParentStateChanged && + currentState === JSONSchemaIsExpandedState.UserCollapsed + ) { + return + } + pathMutator( parentState === JSONSchemaIsExpandedState.DeeplyExpanded ? JSONSchemaIsExpandedState.DeeplyExpanded @@ -124,7 +143,12 @@ export const useIsExpanded = (name) => { }, []) const setCollapsed = useCallback((options = { deep: false }) => { - pathMutator(JSONSchemaIsExpandedState.Collapsed, options) + pathMutator( + options.deep + ? JSONSchemaIsExpandedState.Collapsed + : JSONSchemaIsExpandedState.UserCollapsed, + options + ) }, []) return { isExpanded, setExpanded, setCollapsed } diff --git a/src/core/plugins/oas31/components/models/models.jsx b/src/core/plugins/oas31/components/models/models.jsx index 54e9d2b211a..26c96f09a0b 100644 --- a/src/core/plugins/oas31/components/models/models.jsx +++ b/src/core/plugins/oas31/components/models/models.jsx @@ -1,9 +1,76 @@ /** * @prettier */ -import React, { useCallback, useEffect } from "react" +import React, { useRef, useCallback, useEffect } from "react" import PropTypes from "prop-types" import classNames from "classnames" +import { useVirtualizer } from "@tanstack/react-virtual" +import { VIRTUALIZE_MODELS_THRESHOLD } from "core/utils" + +const SCHEMAS_PATH = ["components", "schemas"] + +const SchemaItem = React.memo( + ({ + schemaName, + schema, + name, + specSelectors, + specActions, + layoutActions, + getComponent, + }) => { + const JSONSchema202012 = getComponent("JSONSchema202012") + + const handleJSONSchema202012Expand = useCallback( + (e, expanded) => { + const schemaPath = [...SCHEMAS_PATH, schemaName] + if (expanded) { + const isResolved = + specSelectors.specResolvedSubtree(schemaPath) != null + if (!isResolved) specActions.requestResolvedSubtree(schemaPath) + layoutActions.show(schemaPath, true) + } else { + layoutActions.show(schemaPath, false) + } + }, + [schemaName, specSelectors, specActions, layoutActions] + ) + + const handleJSONSchema202012Ref = useCallback( + (node) => { + if (node !== null) + layoutActions.readyToScroll([...SCHEMAS_PATH, schemaName], node) + }, + [schemaName, layoutActions] + ) + + return ( + + ) + } +) + +SchemaItem.propTypes = { + schemaName: PropTypes.string.isRequired, + schema: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + specSelectors: PropTypes.shape({ + specResolvedSubtree: PropTypes.func.isRequired, + }).isRequired, + specActions: PropTypes.shape({ + requestResolvedSubtree: PropTypes.func.isRequired, + }).isRequired, + layoutActions: PropTypes.shape({ + show: PropTypes.func.isRequired, + readyToScroll: PropTypes.func.isRequired, + }).isRequired, + getComponent: PropTypes.func.isRequired, +} const Models = ({ specActions, @@ -15,30 +82,45 @@ const Models = ({ fn, }) => { const schemas = specSelectors.selectSchemas() - const hasSchemas = Object.keys(schemas).length > 0 - const schemasPath = ["components", "schemas"] const { docExpansion, defaultModelsExpandDepth } = getConfigs() const isOpenDefault = defaultModelsExpandDepth > 0 && docExpansion !== "none" - const isOpen = layoutSelectors.isShown(schemasPath, isOpenDefault) + const isOpen = layoutSelectors.isShown(SCHEMAS_PATH, isOpenDefault) const Collapse = getComponent("Collapse") - const JSONSchema202012 = getComponent("JSONSchema202012") const ArrowUpIcon = getComponent("ArrowUpIcon") const ArrowDownIcon = getComponent("ArrowDownIcon") const { getTitle } = fn.jsonSchema202012.useFn() + const schemaEntries = Object.entries(schemas) + + const parentRef = useRef(null) + const measurementsCache = useRef([]) + + const virtualizer = useVirtualizer({ + count: schemaEntries.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 48, + overscan: 5, + getItemKey: (index) => `models-section-${schemaEntries[index][0]}`, + initialMeasurementsCache: measurementsCache.current, + onChange: (instance) => { + measurementsCache.current = instance.takeSnapshot() + }, + }) + + const isVirtualized = schemaEntries.length >= VIRTUALIZE_MODELS_THRESHOLD + /** * Effects. */ useEffect(() => { - const includesExpandedSchema = Object.entries(schemas).some( - ([schemaName]) => - layoutSelectors.isShown([...schemasPath, schemaName], false) + const includesExpandedSchema = schemaEntries.some(([schemaName]) => + layoutSelectors.isShown([...SCHEMAS_PATH, schemaName], false) ) const isOpenAndExpanded = isOpen && (defaultModelsExpandDepth > 1 || includesExpandedSchema) - const isResolved = specSelectors.specResolvedSubtree(schemasPath) != null + const isResolved = specSelectors.specResolvedSubtree(SCHEMAS_PATH) != null if (isOpenAndExpanded && !isResolved) { - specActions.requestResolvedSubtree(schemasPath) + specActions.requestResolvedSubtree(SCHEMAS_PATH) } }, [isOpen, defaultModelsExpandDepth]) @@ -47,42 +129,28 @@ const Models = ({ */ const handleModelsExpand = useCallback(() => { - layoutActions.show(schemasPath, !isOpen) + layoutActions.show(SCHEMAS_PATH, !isOpen) }, [isOpen]) const handleModelsRef = useCallback((node) => { if (node !== null) { - layoutActions.readyToScroll(schemasPath, node) + layoutActions.readyToScroll(SCHEMAS_PATH, node) } }, []) - const handleJSONSchema202012Ref = (schemaName) => (node) => { - if (node !== null) { - layoutActions.readyToScroll([...schemasPath, schemaName], node) - } - } - const handleJSONSchema202012Expand = (schemaName) => (e, expanded) => { - const schemaPath = [...schemasPath, schemaName] - if (expanded) { - const isResolved = specSelectors.specResolvedSubtree(schemaPath) != null - if (!isResolved) { - specActions.requestResolvedSubtree([...schemasPath, schemaName]) - } - layoutActions.show(schemaPath, true) - } else { - layoutActions.show(schemaPath, false) - } - } /** * Rendering. */ - if (!hasSchemas || defaultModelsExpandDepth < 0) { + if (!schemaEntries.length || defaultModelsExpandDepth < 0) { return null } return (

@@ -96,19 +164,59 @@ const Models = ({

- {Object.entries(schemas).map(([schemaName, schema]) => { - const name = getTitle(schema, { lookup: "basic" }) || schemaName - - return ( - - ) - })} + {isVirtualized ? ( +
+
+ {virtualizer.getVirtualItems().map((vItem) => { + const [schemaName, schema] = schemaEntries[vItem.index] + const name = getTitle(schema, { lookup: "basic" }) || schemaName + + return ( +
+ +
+ ) + })} +
+
+ ) : ( + schemaEntries.map(([schemaName, schema]) => { + const name = getTitle(schema, { lookup: "basic" }) || schemaName + + return ( + + ) + }) + )}
) diff --git a/test/e2e-cypress/e2e/features/models-virtualization.cy.js b/test/e2e-cypress/e2e/features/models-virtualization.cy.js index 8d2f75784eb..f6af6118ef9 100644 --- a/test/e2e-cypress/e2e/features/models-virtualization.cy.js +++ b/test/e2e-cypress/e2e/features/models-virtualization.cy.js @@ -97,4 +97,134 @@ describe("Models list virtualization", () => { cy.get(".model-container").should("have.length.lessThan", 240) }) }) + + describe("legacy path — below threshold (OpenAPI 3.1)", () => { + it("renders all schemas without a scroll wrapper", () => { + cy.visit("/?url=/documents/features/oas31-schema-expansion.yaml") + cy.get(".models-scroll").should("not.exist") + cy.get(".json-schema-2020-12:not(.json-schema-2020-12--embedded)").should( + "have.length", + 1 + ) + }) + }) + + describe("virtualized path — above threshold (OpenAPI 3.1)", () => { + const baseUrl = "/?url=/documents/perf/many-schemas.openapi31.yaml" + + it("renders a scroll wrapper and mounts only a windowed subset", () => { + cy.visit(baseUrl) + cy.get(".models-scroll").should("exist") + cy.get(".json-schema-2020-12:not(.json-schema-2020-12--embedded)").should( + "have.length.lessThan", + 240 + ) + }) + + it("section header collapse and expand still works", () => { + cy.visit(baseUrl) + cy.get(".models").should("have.class", "is-open") + cy.get(".models h4 .models-control").click() + cy.get(".models").should("not.have.class", "is-open") + cy.get(".models-scroll").should("not.exist") + cy.get(".models h4 .models-control").click() + cy.get(".models").should("have.class", "is-open") + cy.get(".models-scroll").should("exist") + }) + + it("scrolling renders new items and unmounts old ones", () => { + cy.visit(baseUrl) + cy.get(".models-scroll").should("exist") + cy.contains(".json-schema-2020-12-accordion", "PerfModel001").should( + "exist" + ) + + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = $scroll[0].scrollHeight + cy.wait(200) + cy.contains(".json-schema-2020-12-accordion", "PerfModel001").should( + "not.exist" + ) + cy.contains(".json-schema-2020-12-accordion", "PerfModel240").should( + "exist" + ) + cy.get( + ".json-schema-2020-12:not(.json-schema-2020-12--embedded)" + ).should("have.length.lessThan", 240) + }) + }) + + it("expanded schema state is preserved when scrolled out of view and back", () => { + cy.visit(baseUrl) + cy.get(".models-scroll").should("exist") + cy.contains(".json-schema-2020-12-accordion", "PerfModel001") + .closest(".json-schema-2020-12") + .find(".json-schema-2020-12-body") + .first() + .should("have.class", "json-schema-2020-12-body--collapsed") + cy.contains(".json-schema-2020-12-accordion", "PerfModel001").click() + cy.contains(".json-schema-2020-12-accordion", "PerfModel001") + .closest(".json-schema-2020-12") + .find(".json-schema-2020-12-body") + .first() + .should("not.have.class", "json-schema-2020-12-body--collapsed") + + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = $scroll[0].scrollHeight + }) + cy.wait(200) + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = 0 + }) + cy.wait(200) + + cy.contains(".json-schema-2020-12-accordion", "PerfModel001") + .closest(".json-schema-2020-12") + .find(".json-schema-2020-12-body") + .first() + .should("not.have.class", "json-schema-2020-12-body--collapsed") + }) + + it("collapsed nested property state is preserved when scrolled out of view and back", () => { + cy.visit(baseUrl) + cy.get(".models-scroll").should("exist") + + cy.contains(".json-schema-2020-12-accordion", "PerfModel001") + .closest(".json-schema-2020-12") + .find(".json-schema-2020-12-expand-deep-button") + .first() + .click() + cy.contains(".json-schema-2020-12-accordion", "PerfModel001") + .closest(".json-schema-2020-12") + .contains("Items") + .should("exist") + cy.contains(".json-schema-2020-12-accordion", "PerfModel001") + .closest(".json-schema-2020-12") + .contains(".json-schema-2020-12-accordion", "tags") + .click() + cy.contains(".json-schema-2020-12-accordion", "PerfModel001") + .closest(".json-schema-2020-12") + .contains("Items") + .should("not.exist") + + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = $scroll[0].scrollHeight + }) + cy.wait(200) + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = 0 + }) + cy.wait(200) + + cy.contains(".json-schema-2020-12-accordion", "PerfModel001") + .closest(".json-schema-2020-12") + .find(".json-schema-2020-12-body") + .first() + .should("not.have.class", "json-schema-2020-12-body--collapsed") + cy.contains(".json-schema-2020-12-accordion", "PerfModel001") + .closest(".json-schema-2020-12") + .contains("Items") + .should("not.exist") + }) + }) }) diff --git a/test/e2e-cypress/e2e/features/plugins/oas31/oas31-schema-expansion.cy.js b/test/e2e-cypress/e2e/features/plugins/oas31/oas31-schema-expansion.cy.js index 59bab969e58..7c367d63932 100644 --- a/test/e2e-cypress/e2e/features/plugins/oas31/oas31-schema-expansion.cy.js +++ b/test/e2e-cypress/e2e/features/plugins/oas31/oas31-schema-expansion.cy.js @@ -46,4 +46,27 @@ describe("OpenAPI 3.1.0 schema expansion", () => { .contains("prop4") .should("exist") }) + + it("should re-expand a collapsed nested property after the parent schema is collapsed and deeply expanded again", () => { + cy.visit( + "/?url=/documents/features/oas31-schema-expansion.yaml&showExtensions=true" + ) + + cy.get(".json-schema-2020-12-expand-deep-button").click() + cy.get(".json-schema-2020-12-keyword--properties") + .contains("prop4") + .should("exist") + + cy.get(".json-schema-2020-12-keyword--properties").contains("prop2").click() + cy.get(".json-schema-2020-12-keyword--properties") + .contains("prop4") + .should("not.exist") + + cy.get(".json-schema-2020-12-accordion").contains("Expansion").click() + cy.get(".json-schema-2020-12-expand-deep-button").click() + + cy.get(".json-schema-2020-12-keyword--properties") + .contains("prop4") + .should("exist") + }) }) diff --git a/test/unit/core/plugins/oas31/components/models.jsx b/test/unit/core/plugins/oas31/components/models.jsx new file mode 100644 index 00000000000..55e49b7583f --- /dev/null +++ b/test/unit/core/plugins/oas31/components/models.jsx @@ -0,0 +1,130 @@ +/** + * @prettier + */ +import React from "react" +import { mount } from "enzyme" +import Models from "core/plugins/oas31/components/models/models" + +jest.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: () => ({ + getVirtualItems: () => [], + getTotalSize: () => 0, + measureElement: () => {}, + }), +})) + +describe(" (oas31)", function () { + const dummyComponent = () => null + /* eslint-disable react/prop-types */ + const DummyComponentWithChildren = ({ children }) =>
{children}
+ + const makeProps = (overrides = {}) => ({ + getComponent: (c) => { + const components = { + Collapse: DummyComponentWithChildren, + JSONSchema202012: React.forwardRef(({ name }) => ( +
+ )), + ArrowUpIcon: dummyComponent, + ArrowDownIcon: dummyComponent, + } + return components[c] || dummyComponent + }, + specSelectors: { + selectSchemas: () => ({ Schema1: {}, Schema2: {} }), + specResolvedSubtree: () => undefined, + }, + layoutSelectors: { + isShown: jest.fn(() => false), + }, + layoutActions: { + show: jest.fn(), + readyToScroll: jest.fn(), + }, + specActions: { + requestResolvedSubtree: jest.fn(), + }, + getConfigs: () => ({ + docExpansion: "list", + defaultModelsExpandDepth: 1, + }), + fn: { + jsonSchema202012: { + useFn: () => ({ getTitle: () => null }), + }, + }, + ...overrides, + }) + + it("returns null when defaultModelsExpandDepth < 0", function () { + const props = makeProps({ + getConfigs: () => ({ + docExpansion: "list", + defaultModelsExpandDepth: -1, + }), + }) + + const wrapper = mount() + + expect(wrapper.html()).toBeNull() + }) + + it("returns null when there are no schemas", function () { + const props = makeProps({ + specSelectors: { + selectSchemas: () => ({}), + specResolvedSubtree: () => undefined, + }, + }) + + const wrapper = mount() + + expect(wrapper.html()).toBeNull() + }) + + it("uses non-virtualized path when schema count is below threshold", function () { + const props = makeProps() + + const wrapper = mount() + + expect(wrapper.find(".models-scroll").length).toEqual(0) + expect(wrapper.find(".schema-item").length).toEqual(2) + }) + + it("uses non-virtualized path when schema count is just below threshold (99)", function () { + const schemas = {} + for (let i = 0; i < 99; i++) { + schemas[`Schema${i}`] = {} + } + const props = makeProps({ + specSelectors: { + selectSchemas: () => schemas, + specResolvedSubtree: () => undefined, + }, + }) + + const wrapper = mount() + + expect(wrapper.find(".models-scroll").length).toEqual(0) + expect(wrapper.find(".schema-item").length).toEqual(99) + }) + + it("uses virtualized path when schema count is at threshold (100)", function () { + const schemas = {} + for (let i = 0; i < 100; i++) { + schemas[`Schema${i}`] = {} + } + const props = makeProps({ + specSelectors: { + selectSchemas: () => schemas, + specResolvedSubtree: () => undefined, + }, + }) + + const wrapper = mount() + + expect(wrapper.find(".models-scroll").length).toEqual(1) + // virtualizer mock returns no items, so no schema-item should be rendered + expect(wrapper.find(".schema-item").length).toEqual(0) + }) +}) From 8b82dba5ce3d634a3bd86e53afad30b7734b6479 Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Tue, 25 Aug 2026 10:20:44 +0200 Subject: [PATCH 2/6] fix: add display name for schema item --- src/core/plugins/oas31/components/models/models.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/plugins/oas31/components/models/models.jsx b/src/core/plugins/oas31/components/models/models.jsx index 26c96f09a0b..123b739de5a 100644 --- a/src/core/plugins/oas31/components/models/models.jsx +++ b/src/core/plugins/oas31/components/models/models.jsx @@ -55,6 +55,8 @@ const SchemaItem = React.memo( } ) +SchemaItem.displayName = "SchemaItem" + SchemaItem.propTypes = { schemaName: PropTypes.string.isRequired, schema: PropTypes.object.isRequired, From 04b3f7fdd28f0658bee6da1bcdab55b9928ca53b Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Thu, 27 Aug 2026 10:18:32 +0200 Subject: [PATCH 3/6] fix: address the same issues as for oas2/3 --- .../oas31/components/models/models.jsx | 78 +++---------------- .../oas31/components/models/schema-item.tsx | 78 +++++++++++++++++++ src/core/utils/virtualization.ts | 1 + .../e2e/features/models-virtualization.cy.js | 2 +- 4 files changed, 89 insertions(+), 70 deletions(-) create mode 100644 src/core/plugins/oas31/components/models/schema-item.tsx diff --git a/src/core/plugins/oas31/components/models/models.jsx b/src/core/plugins/oas31/components/models/models.jsx index 123b739de5a..b75d03298f2 100644 --- a/src/core/plugins/oas31/components/models/models.jsx +++ b/src/core/plugins/oas31/components/models/models.jsx @@ -5,75 +5,15 @@ import React, { useRef, useCallback, useEffect } from "react" import PropTypes from "prop-types" import classNames from "classnames" import { useVirtualizer } from "@tanstack/react-virtual" -import { VIRTUALIZE_MODELS_THRESHOLD } from "core/utils" +import { + VIRTUALIZE_MODELS_THRESHOLD, + VIRTUALIZE_JSON_SCHEMA_2020_12_ESTIMATE_SIZE, + VIRTUALIZE_MODELS_OVERSCAN, +} from "core/utils/virtualization" +import SchemaItem from "./schema-item" const SCHEMAS_PATH = ["components", "schemas"] -const SchemaItem = React.memo( - ({ - schemaName, - schema, - name, - specSelectors, - specActions, - layoutActions, - getComponent, - }) => { - const JSONSchema202012 = getComponent("JSONSchema202012") - - const handleJSONSchema202012Expand = useCallback( - (e, expanded) => { - const schemaPath = [...SCHEMAS_PATH, schemaName] - if (expanded) { - const isResolved = - specSelectors.specResolvedSubtree(schemaPath) != null - if (!isResolved) specActions.requestResolvedSubtree(schemaPath) - layoutActions.show(schemaPath, true) - } else { - layoutActions.show(schemaPath, false) - } - }, - [schemaName, specSelectors, specActions, layoutActions] - ) - - const handleJSONSchema202012Ref = useCallback( - (node) => { - if (node !== null) - layoutActions.readyToScroll([...SCHEMAS_PATH, schemaName], node) - }, - [schemaName, layoutActions] - ) - - return ( - - ) - } -) - -SchemaItem.displayName = "SchemaItem" - -SchemaItem.propTypes = { - schemaName: PropTypes.string.isRequired, - schema: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - specSelectors: PropTypes.shape({ - specResolvedSubtree: PropTypes.func.isRequired, - }).isRequired, - specActions: PropTypes.shape({ - requestResolvedSubtree: PropTypes.func.isRequired, - }).isRequired, - layoutActions: PropTypes.shape({ - show: PropTypes.func.isRequired, - readyToScroll: PropTypes.func.isRequired, - }).isRequired, - getComponent: PropTypes.func.isRequired, -} - const Models = ({ specActions, specSelectors, @@ -100,8 +40,8 @@ const Models = ({ const virtualizer = useVirtualizer({ count: schemaEntries.length, getScrollElement: () => parentRef.current, - estimateSize: () => 48, - overscan: 5, + estimateSize: () => VIRTUALIZE_JSON_SCHEMA_2020_12_ESTIMATE_SIZE, + overscan: VIRTUALIZE_MODELS_OVERSCAN, getItemKey: (index) => `models-section-${schemaEntries[index][0]}`, initialMeasurementsCache: measurementsCache.current, onChange: (instance) => { @@ -185,7 +125,7 @@ const Models = ({ key={vItem.key} data-index={vItem.index} ref={virtualizer.measureElement} - style={{ paddingBottom: 15 }} + className="models-virtual-item" > unknown + } + specActions: { + requestResolvedSubtree: (path: string[]) => void + } + layoutActions: { + show: (path: string[], value: boolean) => void + readyToScroll: (path: string[], node: HTMLElement) => void + } + getComponent: ( + name: string, + required?: boolean + ) => React.ComponentType> | null +} + +const SchemaItem = React.memo( + ({ + schemaName, + schema, + name, + specSelectors, + specActions, + layoutActions, + getComponent, + }: SchemaItemProps) => { + const JSONSchema202012 = getComponent("JSONSchema202012") + + const handleJSONSchema202012Expand = useCallback( + (e: unknown, expanded: boolean) => { + const schemaPath = [...SCHEMAS_PATH, schemaName] + if (expanded) { + const isResolved = + specSelectors.specResolvedSubtree(schemaPath) != null + if (!isResolved) specActions.requestResolvedSubtree(schemaPath) + layoutActions.show(schemaPath, true) + } else { + layoutActions.show(schemaPath, false) + } + }, + [schemaName, specSelectors, specActions, layoutActions] + ) + + const handleJSONSchema202012Ref = useCallback( + (node: HTMLElement) => { + if (node !== null) + layoutActions.readyToScroll([...SCHEMAS_PATH, schemaName], node) + }, + [schemaName, layoutActions] + ) + + if (!JSONSchema202012) return null + + return ( + + ) + } +) + +SchemaItem.displayName = "SchemaItem" + +export default SchemaItem diff --git a/src/core/utils/virtualization.ts b/src/core/utils/virtualization.ts index b9590ddc700..ea7e91cfd55 100644 --- a/src/core/utils/virtualization.ts +++ b/src/core/utils/virtualization.ts @@ -3,4 +3,5 @@ */ export const VIRTUALIZE_MODELS_THRESHOLD = 100 export const VIRTUALIZE_MODELS_ESTIMATE_SIZE = 71 +export const VIRTUALIZE_JSON_SCHEMA_2020_12_ESTIMATE_SIZE = 48 export const VIRTUALIZE_MODELS_OVERSCAN = 5 diff --git a/test/e2e-cypress/e2e/features/models-virtualization.cy.js b/test/e2e-cypress/e2e/features/models-virtualization.cy.js index a99fd2eeaa3..9e76a67aa48 100644 --- a/test/e2e-cypress/e2e/features/models-virtualization.cy.js +++ b/test/e2e-cypress/e2e/features/models-virtualization.cy.js @@ -98,7 +98,7 @@ describe("Models list virtualization", () => { }) }) - describe("legacy path — below threshold (OpenAPI 3.1)", () => { + describe("non-virtualized path — below threshold (OpenAPI 3.1)", () => { it("renders all schemas without a scroll wrapper", () => { cy.visit("/?url=/documents/features/oas31-schema-expansion.yaml") cy.get(".models-scroll").should("not.exist") From f3998ef785a86f993ab9c0b6732e54b34af182d7 Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Thu, 27 Aug 2026 10:44:02 +0200 Subject: [PATCH 4/6] fix: rewrite new tests to typescript --- config/jest/jest.unit.config.js | 2 ++ package-lock.json | 12 ++++++++ package.json | 1 + .../components/{models.jsx => models.tsx} | 26 +++++++++++------ test/unit/tsconfig.json | 28 +++++++++++++++++++ 5 files changed, 60 insertions(+), 9 deletions(-) rename test/unit/core/plugins/oas31/components/{models.jsx => models.tsx} (83%) create mode 100644 test/unit/tsconfig.json diff --git a/config/jest/jest.unit.config.js b/config/jest/jest.unit.config.js index d7df1f83cef..7275328c35e 100644 --- a/config/jest/jest.unit.config.js +++ b/config/jest/jest.unit.config.js @@ -6,6 +6,8 @@ module.exports = { testMatch: [ '**/test/unit/*.js?(x)', '**/test/unit/**/*.js?(x)', + '**/test/unit/*.ts?(x)', + '**/test/unit/**/*.ts?(x)', ], setupFiles: ['/test/unit/jest-shim.js'], setupFilesAfterEnv: ['/test/unit/setup.js'], diff --git a/package-lock.json b/package-lock.json index c263177425b..0eb40cadd0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", "@release-it/conventional-changelog": "=12.0.0", "@svgr/webpack": "=8.1.0", + "@types/jest": "=30.0.0", "@typescript-eslint/eslint-plugin": "=8.67.0", "@typescript-eslint/parser": "=8.67.0", "autoprefixer": "^10.5.4", @@ -7964,6 +7965,17 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, "node_modules/@types/jsdom": { "version": "21.1.7", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", diff --git a/package.json b/package.json index a2cba83c42d..7e4180278f7 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", "@release-it/conventional-changelog": "=12.0.0", "@svgr/webpack": "=8.1.0", + "@types/jest": "=30.0.0", "@typescript-eslint/eslint-plugin": "=8.67.0", "@typescript-eslint/parser": "=8.67.0", "autoprefixer": "^10.5.4", diff --git a/test/unit/core/plugins/oas31/components/models.jsx b/test/unit/core/plugins/oas31/components/models.tsx similarity index 83% rename from test/unit/core/plugins/oas31/components/models.jsx rename to test/unit/core/plugins/oas31/components/models.tsx index 55e49b7583f..28ca49e6b62 100644 --- a/test/unit/core/plugins/oas31/components/models.jsx +++ b/test/unit/core/plugins/oas31/components/models.tsx @@ -15,16 +15,24 @@ jest.mock("@tanstack/react-virtual", () => ({ describe(" (oas31)", function () { const dummyComponent = () => null - /* eslint-disable react/prop-types */ - const DummyComponentWithChildren = ({ children }) =>
{children}
+ const DummyComponentWithChildren = ({ + children, + }: { + children: React.ReactNode + }) =>
{children}
const makeProps = (overrides = {}) => ({ - getComponent: (c) => { - const components = { - Collapse: DummyComponentWithChildren, - JSONSchema202012: React.forwardRef(({ name }) => ( + getComponent: (c: string) => { + const components: Record< + string, + React.ComponentType> + > = { + Collapse: DummyComponentWithChildren as React.ComponentType< + Record + >, + JSONSchema202012: React.forwardRef(({ name }: { name?: string }) => (
- )), + )) as unknown as React.ComponentType>, ArrowUpIcon: dummyComponent, ArrowDownIcon: dummyComponent, } @@ -92,7 +100,7 @@ describe(" (oas31)", function () { }) it("uses non-virtualized path when schema count is just below threshold (99)", function () { - const schemas = {} + const schemas: Record = {} for (let i = 0; i < 99; i++) { schemas[`Schema${i}`] = {} } @@ -110,7 +118,7 @@ describe(" (oas31)", function () { }) it("uses virtualized path when schema count is at threshold (100)", function () { - const schemas = {} + const schemas: Record = {} for (let i = 0; i < 100; i++) { schemas[`Schema${i}`] = {} } diff --git a/test/unit/tsconfig.json b/test/unit/tsconfig.json new file mode 100644 index 00000000000..9474b15b7e2 --- /dev/null +++ b/test/unit/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "module": "ESNext", + "allowJs": true, + "checkJs": false, + "target": "ES2023", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "types": ["jest"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "paths": { + "core/*": ["../../src/core/*"] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +} From e0c61ba4fe14268bfaf21c48baa89279f6fa08b6 Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Thu, 27 Aug 2026 11:20:43 +0200 Subject: [PATCH 5/6] fix: add memo for schema entries --- src/core/plugins/oas31/components/models/models.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/plugins/oas31/components/models/models.jsx b/src/core/plugins/oas31/components/models/models.jsx index b75d03298f2..edffc8152d2 100644 --- a/src/core/plugins/oas31/components/models/models.jsx +++ b/src/core/plugins/oas31/components/models/models.jsx @@ -1,7 +1,7 @@ /** * @prettier */ -import React, { useRef, useCallback, useEffect } from "react" +import React, { useRef, useCallback, useEffect, useMemo } from "react" import PropTypes from "prop-types" import classNames from "classnames" import { useVirtualizer } from "@tanstack/react-virtual" @@ -32,7 +32,7 @@ const Models = ({ const ArrowDownIcon = getComponent("ArrowDownIcon") const { getTitle } = fn.jsonSchema202012.useFn() - const schemaEntries = Object.entries(schemas) + const schemaEntries = useMemo(() => Object.entries(schemas), [schemas]) const parentRef = useRef(null) const measurementsCache = useRef([]) From ca3ae4aeeaab10627cd2b168afc12e7b59c10611 Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Thu, 3 Sep 2026 07:48:46 +0200 Subject: [PATCH 6/6] feat: add virtualization for operations section (#11024) --- src/core/components/content-type.jsx | 6 +- src/core/components/operation-tag.jsx | 89 ++-- src/core/components/operations.jsx | 386 +++++++++++--- src/core/components/parameter-row.jsx | 2 +- src/core/components/parameters/parameters.jsx | 33 +- src/core/components/response.jsx | 40 +- src/core/components/responses.jsx | 1 + src/core/containers/OperationContainer.jsx | 15 +- src/core/plugins/deep-linking/layout.js | 24 + .../components/model-example.jsx | 67 ++- .../json-schema-5/components/models.jsx | 10 +- src/core/plugins/oas3/actions.js | 8 + .../plugins/oas3/components/request-body.jsx | 2 +- src/core/plugins/oas3/reducers.js | 4 + src/core/plugins/oas3/selectors.js | 12 + src/core/utils/virtualization.ts | 5 + src/style/_layout.scss | 9 + .../features/operations-virtualization.cy.js | 122 +++++ test/unit/components/operations.jsx | 488 ++++++++++++++---- .../components/model-example.jsx | 8 + 20 files changed, 1055 insertions(+), 276 deletions(-) create mode 100644 test/e2e-cypress/e2e/features/operations-virtualization.cy.js diff --git a/src/core/components/content-type.jsx b/src/core/components/content-type.jsx index c89e5453c14..3c929e7d1c0 100644 --- a/src/core/components/content-type.jsx +++ b/src/core/components/content-type.jsx @@ -23,9 +23,9 @@ export default class ContentType extends React.Component { } componentDidMount() { - // Populate the form initially - const { contentTypes, onChange } = this.props - if (contentTypes && contentTypes.size) { + // Populate the form initially, but only if there is no valid value already set + const { contentTypes, value, onChange } = this.props + if (contentTypes && contentTypes.size && !contentTypes.includes(value)) { onChange(contentTypes.first()) } } diff --git a/src/core/components/operation-tag.jsx b/src/core/components/operation-tag.jsx index da10910e509..d06922b9f19 100644 --- a/src/core/components/operation-tag.jsx +++ b/src/core/components/operation-tag.jsx @@ -68,50 +68,57 @@ export default class OperationTag extends React.Component { let isShownKey = ["operations-tag", tag] let showTag = layoutSelectors.isShown(isShownKey, docExpansion === "full" || docExpansion === "list") - return ( -
- -

layoutActions.show(isShownKey, !showTag)} - className={!tagDescription ? "opblock-tag no-desc" : "opblock-tag"} - id={isShownKey.map(v => escapeDeepLinkPath(v)).join("-")} - data-tag={tag} - data-is-open={showTag} - > - - {!tagDescription ? : + const header = ( +

layoutActions.show(isShownKey, !showTag)} + className={!tagDescription ? "opblock-tag no-desc" : "opblock-tag"} + id={isShownKey.map(v => escapeDeepLinkPath(v)).join("-")} + data-tag={tag} + data-is-open={showTag} + > + + {!tagDescription ? : + + + + } + + {!tagExternalDocsUrl ? null : +
- + e.stopPropagation()} + target="_blank" + >{tagExternalDocsDescription || tagExternalDocsUrl} - } - - {!tagExternalDocsUrl ? null : -
- - e.stopPropagation()} - target="_blank" - >{tagExternalDocsDescription || tagExternalDocsUrl} - -
- } - - - -

+

+ } + + + + ) + + // Virtualized path — render only the header + if (children == null) { + return header + } + + return ( +
+ {header} {children} diff --git a/src/core/components/operations.jsx b/src/core/components/operations.jsx index a7e6fc5091b..31265a27bde 100644 --- a/src/core/components/operations.jsx +++ b/src/core/components/operations.jsx @@ -1,99 +1,331 @@ -import React from "react" +/** + * @prettier + */ +import React, { + useMemo, + useRef, + useState, + useEffect, + useLayoutEffect, +} from "react" import PropTypes from "prop-types" +import { useWindowVirtualizer, useVirtualizer } from "@tanstack/react-virtual" +import { opId } from "swagger-client/es/helpers" +import { + VIRTUALIZE_OPERATIONS_THRESHOLD, + VIRTUALIZE_OPERATIONS_TAG_ESTIMATE_SIZE, + VIRTUALIZE_OPERATIONS_ESTIMATE_SIZE, + VIRTUALIZE_OPERATIONS_OVERSCAN, +} from "core/utils/virtualization" -export default class Operations extends React.Component { - - static propTypes = { - specSelectors: PropTypes.object.isRequired, - specActions: PropTypes.object.isRequired, - oas3Actions: PropTypes.object.isRequired, - getComponent: PropTypes.func.isRequired, - oas3Selectors: PropTypes.func.isRequired, - layoutSelectors: PropTypes.object.isRequired, - layoutActions: PropTypes.object.isRequired, - authActions: PropTypes.object.isRequired, - authSelectors: PropTypes.object.isRequired, - getConfigs: PropTypes.func.isRequired, - fn: PropTypes.func.isRequired +const findScrollParent = (el) => { + let node = el.parentElement + + while (node && node !== document.documentElement) { + const { overflow, overflowY } = getComputedStyle(node) + + if (/(auto|scroll)/.test(overflow + overflowY)) return node + + node = node.parentElement } - render() { - let { - specSelectors, - } = this.props + return document.documentElement +} + +const Operations = ({ + specSelectors, + getComponent, + oas3Selectors, + layoutSelectors, + layoutActions, + getConfigs, +}) => { + const taggedOps = specSelectors.taggedOperations() + const validOperationMethods = specSelectors.validOperationMethods() + const { docExpansion } = getConfigs() + + const tagDefaultOpen = docExpansion === "full" || docExpansion === "list" + + // Derive a key for flatItems useMemo that changes when any tag is expanded/collapsed + const expandedTagsState = taggedOps + .keySeq() + .map((tag) => + layoutSelectors.isShown(["operations-tag", tag], tagDefaultOpen) + ) + .join(",") + + const flatItems = useMemo(() => { + const items = [] + + taggedOps.entrySeq().forEach(([tag, tagObj]) => { + items.push({ type: "tag", tag, tagObj }) + + const tagOpen = layoutSelectors.isShown( + ["operations-tag", tag], + tagDefaultOpen + ) + + if (!tagOpen) return + + tagObj.get("operations").forEach((op) => { + const method = op.get("method") + + if (validOperationMethods.indexOf(method) === -1) return + + const path = op.get("path") + const operationId = + op.getIn(["operation", "__originalOperationId"]) || + op.getIn(["operation", "operationId"]) || + opId(op.get("operation"), path, method) || + op.get("id") + + items.push({ + type: "operation", + tag, + op, + method, + path, + specPath: op.get("specPath"), + operationId, + }) + }) + }) + + return items + }, [taggedOps, validOperationMethods, tagDefaultOpen, expandedTagsState]) + + const isVirtualized = flatItems.length >= VIRTUALIZE_OPERATIONS_THRESHOLD + + const [containerEl, setContainerEl] = useState(null) + const [scrollMargin, setScrollMargin] = useState(0) + const listRef = useRef(null) + + useLayoutEffect(() => { + const el = listRef.current + + if (!el) return + + const container = findScrollParent(el) + const isWindowScroll = container === document.documentElement + + if (!isWindowScroll) { + setContainerEl(container) + } + + const measure = () => { + setScrollMargin(el.offsetTop) + } + + let raf = requestAnimationFrame(measure) + + const onResize = () => { + cancelAnimationFrame(raf) + raf = requestAnimationFrame(measure) + } + + window.addEventListener("resize", onResize) + + return () => { + cancelAnimationFrame(raf) + window.removeEventListener("resize", onResize) + } + }, []) + + const isWindowScroll = containerEl === null + + const windowVirtualizer = useWindowVirtualizer({ + enabled: isWindowScroll, + count: isWindowScroll && isVirtualized ? flatItems.length : 0, + estimateSize: (i) => + flatItems[i]?.type === "tag" + ? VIRTUALIZE_OPERATIONS_TAG_ESTIMATE_SIZE + : VIRTUALIZE_OPERATIONS_ESTIMATE_SIZE, + overscan: VIRTUALIZE_OPERATIONS_OVERSCAN, + scrollMargin, + getItemKey: (index) => { + const item = flatItems[index] + return item.type === "tag" + ? `tag-${item.tag}` + : `op-${item.tag}-${item.path}-${item.method}` + }, + }) + + const containerVirtualizer = useVirtualizer({ + enabled: !isWindowScroll, + count: !isWindowScroll && isVirtualized ? flatItems.length : 0, + estimateSize: (i) => + flatItems[i]?.type === "tag" + ? VIRTUALIZE_OPERATIONS_TAG_ESTIMATE_SIZE + : VIRTUALIZE_OPERATIONS_ESTIMATE_SIZE, + overscan: VIRTUALIZE_OPERATIONS_OVERSCAN, + getScrollElement: () => containerEl, + scrollMargin, + getItemKey: (index) => { + const item = flatItems[index] + return item.type === "tag" + ? `tag-${item.tag}` + : `op-${item.tag}-${item.path}-${item.method}` + }, + }) + + const virtualizer = isWindowScroll ? windowVirtualizer : containerVirtualizer + + const pendingVirtualizedOperationScroll = + layoutSelectors.getScrollToVirtualizedOperation() - const taggedOps = specSelectors.taggedOperations() + useEffect(() => { + if (!pendingVirtualizedOperationScroll || !isVirtualized) return - if(taggedOps.size === 0) { - return

No operations defined in spec!

+ const [type, tag, operationId] = pendingVirtualizedOperationScroll + + let idx = -1 + + const tagsToMatch = [tag, tag?.replaceAll("_", " ")].filter(Boolean) + + if (type === "operations") { + idx = flatItems.findIndex( + (item) => + item.type === "operation" && + tagsToMatch.includes(item.tag) && + item.operationId === operationId + ) + } else if (type === "operations-tag") { + idx = flatItems.findIndex( + (item) => item.type === "tag" && tagsToMatch.includes(item.tag) + ) } + if (idx !== -1) { + virtualizer.scrollToIndex(idx, { align: "start" }) + } + + layoutActions.clearScrollToVirtualizedOperation() + layoutActions.clearScrollTo() + }, [ + pendingVirtualizedOperationScroll, + flatItems, + virtualizer, + isVirtualized, + layoutActions, + ]) + + if (taggedOps.size === 0) { + return

No operations defined in spec!

+ } + + const OperationContainer = getComponent("OperationContainer", true) + const OperationTag = getComponent("OperationTag") + + if (!isVirtualized) { return (
- { taggedOps.map(this.renderOperationTag).valueSeq().toArray() } - { taggedOps.size < 1 ?

No operations defined in spec!

: null } + {taggedOps + .map((tagObj, tag) => ( + +
+ {tagObj + .get("operations") + .map((op) => { + const path = op.get("path") + const method = op.get("method") + const specPath = op.get("specPath") + if (validOperationMethods.indexOf(method) === -1) { + return null + } + return ( + + ) + }) + .toArray()} +
+
+ )) + .valueSeq() + .toArray()}
) } - renderOperationTag = (tagObj, tag) => { - const { - specSelectors, - getComponent, - oas3Selectors, - layoutSelectors, - layoutActions, - getConfigs, - } = this.props - const validOperationMethods = specSelectors.validOperationMethods() - const OperationContainer = getComponent("OperationContainer", true) - const OperationTag = getComponent("OperationTag") - const operations = tagObj.get("operations") - return ( - -
- { - operations.map(op => { - const path = op.get("path") - const method = op.get("method") - const specPath = op.get("specPath") - - if (validOperationMethods.indexOf(method) === -1) { - return null - } - - return ( - - ) - }).toArray() - } -
-
- ) - } + const vItems = virtualizer.getVirtualItems() + const offset = virtualizer.options.scrollMargin ?? 0 + const paddingTop = vItems.length > 0 ? vItems[0].start - offset : 0 + const paddingBottom = + virtualizer.getTotalSize() - + (vItems.length > 0 ? vItems.at(-1).end - offset : 0) + + return ( +
+
+ {vItems.map((vItem) => { + const item = flatItems[vItem.index] + return ( +
+ {item.type === "tag" ? ( + + ) : ( + + )} +
+ ) + })} +
+
+ ) } Operations.propTypes = { - layoutActions: PropTypes.object.isRequired, specSelectors: PropTypes.object.isRequired, specActions: PropTypes.object.isRequired, - layoutSelectors: PropTypes.object.isRequired, + oas3Actions: PropTypes.object.isRequired, getComponent: PropTypes.func.isRequired, - fn: PropTypes.object.isRequired + oas3Selectors: PropTypes.object.isRequired, + layoutSelectors: PropTypes.object.isRequired, + layoutActions: PropTypes.object.isRequired, + authActions: PropTypes.object.isRequired, + authSelectors: PropTypes.object.isRequired, + getConfigs: PropTypes.func.isRequired, + fn: PropTypes.object.isRequired, } + +export default Operations diff --git a/src/core/components/parameter-row.jsx b/src/core/components/parameter-row.jsx index 44690be649f..93f97d8783e 100644 --- a/src/core/components/parameter-row.jsx +++ b/src/core/components/parameter-row.jsx @@ -269,7 +269,7 @@ export default class ParameterRow extends Component { pathMethod={ pathMethod } /> - const ModelExample = getComponent("modelExample") + const ModelExample = getComponent("modelExample", true) const Markdown = getComponent("Markdown", true) const ParameterExt = getComponent("ParameterExt") const ParameterIncludeEmpty = getComponent("ParameterIncludeEmpty") diff --git a/src/core/components/parameters/parameters.jsx b/src/core/components/parameters/parameters.jsx index 0a9bd517b24..155b09f06f0 100644 --- a/src/core/components/parameters/parameters.jsx +++ b/src/core/components/parameters/parameters.jsx @@ -8,10 +8,6 @@ export default class Parameters extends Component { constructor(props) { super(props) - this.state = { - callbackVisible: false, - parametersVisible: true, - } } static propTypes = { @@ -22,6 +18,8 @@ export default class Parameters extends Component { specSelectors: PropTypes.object.isRequired, oas3Actions: PropTypes.object.isRequired, oas3Selectors: PropTypes.object.isRequired, + layoutActions: PropTypes.object.isRequired, + layoutSelectors: PropTypes.object.isRequired, fn: PropTypes.object.isRequired, tryItOutEnabled: PropTypes.bool, allowTryItOut: PropTypes.bool, @@ -63,17 +61,8 @@ export default class Parameters extends Component { } toggleTab = (tab) => { - if (tab === "parameters") { - return this.setState({ - parametersVisible: true, - callbackVisible: false, - }) - } else if (tab === "callbacks") { - return this.setState({ - callbackVisible: true, - parametersVisible: false, - }) - } + const { layoutActions, pathMethod } = this.props + layoutActions.show([...pathMethod, "callbacks-tab"], tab === "callbacks") } onChangeMediaType = ({ value, pathMethod }) => { @@ -109,9 +98,13 @@ export default class Parameters extends Component { pathMethod, oas3Actions, oas3Selectors, + layoutSelectors, operation, } = this.props + const callbackVisible = layoutSelectors.isShown([...pathMethod, "callbacks-tab"], false) + const parametersVisible = !callbackVisible + const ParameterRow = getComponent("parameterRow") const TryItOutButton = getComponent("TryItOutButton") const ContentType = getComponent("contentType") @@ -144,13 +137,13 @@ export default class Parameters extends Component { {isOAS3 ? (
this.toggleTab("parameters")} - className={`tab-item ${this.state.parametersVisible && "active"}`}> + className={`tab-item ${parametersVisible && "active"}`}>

Parameters

{operation.get("callbacks") ? (
this.toggleTab("callbacks")} - className={`tab-item ${this.state.callbackVisible && "active"}`}> + className={`tab-item ${callbackVisible && "active"}`}>

Callbacks

) : null @@ -171,7 +164,7 @@ export default class Parameters extends Component { onResetClick={() => onResetClick(pathMethod)}/> ) : null}
- {this.state.parametersVisible ?
+ {parametersVisible ?
{!groupedParametersArr.length ?

No parameters

:
@@ -208,14 +201,14 @@ export default class Parameters extends Component { } : null} - {this.state.callbackVisible ?
+ {callbackVisible ?
: null} { - isOAS3 && requestBody && this.state.parametersVisible && + isOAS3 && requestBody && parametersVisible &&

Request diff --git a/src/core/components/response.jsx b/src/core/components/response.jsx index b1d0b971cf6..256348f5184 100644 --- a/src/core/components/response.jsx +++ b/src/core/components/response.jsx @@ -39,6 +39,7 @@ export default class Response extends React.Component { getConfigs: PropTypes.func.isRequired, specSelectors: PropTypes.object.isRequired, oas3Actions: PropTypes.object.isRequired, + oas3Selectors: PropTypes.object, specPath: ImPropTypes.list.isRequired, fn: PropTypes.object.isRequired, contentType: PropTypes.string, @@ -53,18 +54,34 @@ export default class Response extends React.Component { } _onContentTypeChange = (value) => { - const { onContentTypeChange, controlsAcceptHeader } = this.props - this.setState({ responseContentType: value }) + const { onContentTypeChange, controlsAcceptHeader, specSelectors, path, method, code, oas3Actions } = this.props + + if (specSelectors.isOAS3()) { + oas3Actions.setResponseCodeContentType({ value, path, method, code }) + } else { + this.setState({ responseContentType: value }) + } + onContentTypeChange({ value: value, controlsAcceptHeader }) } + getActiveContentType = () => { + const { specSelectors, path, method, code, contentType, oas3Selectors } = this.props + + if (specSelectors.isOAS3()) { + return oas3Selectors?.responseCodeContentType(path, method, code) || contentType || "" + } + + return this.state.responseContentType || contentType + } + getTargetExamplesKey = () => { - const { response, contentType, activeExamplesKey } = this.props + const { response, activeExamplesKey } = this.props - const activeContentType = this.state.responseContentType || contentType + const activeContentType = this.getActiveContentType() const activeMediaType = response.getIn(["content", activeContentType], Map({})) const examplesForMediaType = activeMediaType.get("examples", null) @@ -84,7 +101,6 @@ export default class Response extends React.Component { getComponent, getConfigs, specSelectors, - contentType, controlsAcceptHeader, oas3Actions, } = this.props @@ -99,7 +115,7 @@ export default class Response extends React.Component { const ResponseExtension = getComponent("ResponseExtension") const Headers = getComponent("headers") const HighlightCode = getComponent("HighlightCode", true) - const ModelExample = getComponent("modelExample") + const ModelExample = getComponent("modelExample", true) const Markdown = getComponent("Markdown", true) const OperationLink = getComponent("operationLink") const ContentType = getComponent("contentType") @@ -109,7 +125,7 @@ export default class Response extends React.Component { var schema, specPathWithPossibleSchema - const activeContentType = this.state.responseContentType || contentType + const activeContentType = this.getActiveContentType() const activeMediaType = response.getIn(["content", activeContentType], Map({})) const examplesForMediaType = activeMediaType.get("examples", null) @@ -119,7 +135,7 @@ export default class Response extends React.Component { schema = oas3SchemaForContentType ? inferSchema(oas3SchemaForContentType.toJS()) : null specPathWithPossibleSchema = oas3SchemaForContentType - ? specPath.push("content", this.state.responseContentType, "schema") + ? specPath.push("content", activeContentType, "schema") : specPath } else { schema = response.get("schema") @@ -197,7 +213,7 @@ export default class Response extends React.Component { Media type + onSelect={(key, { isSyntheticChange } = {}) => { + if (isSyntheticChange) return + oas3Actions.setActiveExamplesMember({ name: key, pathMethod: [path, method], contextType: "responses", contextName: code }) - } + }} showLabels={false} />

diff --git a/src/core/components/responses.jsx b/src/core/components/responses.jsx index fe1f7ddcb8b..9921416fc86 100644 --- a/src/core/components/responses.jsx +++ b/src/core/components/responses.jsx @@ -157,6 +157,7 @@ export default class Responses extends React.Component { code )} oas3Actions={oas3Actions} + oas3Selectors={oas3Selectors} getComponent={ getComponent }/> ) }).toArray() diff --git a/src/core/containers/OperationContainer.jsx b/src/core/containers/OperationContainer.jsx index 65ae7832b85..e3badc0acc8 100644 --- a/src/core/containers/OperationContainer.jsx +++ b/src/core/containers/OperationContainer.jsx @@ -8,10 +8,7 @@ export default class OperationContainer extends PureComponent { constructor(props, context) { super(props, context) - const { tryItOutEnabled } = props.getConfigs() - this.state = { - tryItOutEnabled, executeInProgress: false } } @@ -26,6 +23,7 @@ export default class OperationContainer extends PureComponent { isShown: PropTypes.bool.isRequired, jumpToKey: PropTypes.string.isRequired, allowTryItOut: PropTypes.bool, + tryItOutEnabled: PropTypes.bool, displayOperationId: PropTypes.bool, isAuthorized: PropTypes.bool, displayRequestDuration: PropTypes.bool, @@ -57,7 +55,7 @@ export default class OperationContainer extends PureComponent { mapStateToProps(nextState, props) { const { op, layoutSelectors, getConfigs } = props - const { docExpansion, deepLinking, displayOperationId, displayRequestDuration, supportedSubmitMethods } = getConfigs() + const { docExpansion, deepLinking, displayOperationId, displayRequestDuration, supportedSubmitMethods, tryItOutEnabled: configTryItOutEnabled } = getConfigs() const showSummary = layoutSelectors.showSummary() const operationId = op.getIn(["operation", "__originalOperationId"]) || op.getIn(["operation", "operationId"]) || opId(op.get("operation"), props.path, props.method) || op.get("id") const isShownKey = ["operations", props.tag, operationId] @@ -75,6 +73,7 @@ export default class OperationContainer extends PureComponent { security, isAuthorized: props.authSelectors.isAuthorized(security), isShown: layoutSelectors.isShown(isShownKey, docExpansion === "full" ), + tryItOutEnabled: layoutSelectors.isShown([...isShownKey, "try-it-out"], configTryItOutEnabled), jumpToKey: `paths.${props.path}.${props.method}`, response: props.specSelectors.responseFor(props.path, props.method), request: props.specSelectors.requestFor(props.path, props.method) @@ -114,11 +113,13 @@ export default class OperationContainer extends PureComponent { } onCancelClick=() => { - this.setState({tryItOutEnabled: !this.state.tryItOutEnabled}) + const { layoutActions, tag, operationId } = this.props + layoutActions.show(["operations", tag, operationId, "try-it-out"], false) } onTryoutClick =() => { - this.setState({tryItOutEnabled: !this.state.tryItOutEnabled}) + const { layoutActions, tag, operationId } = this.props + layoutActions.show(["operations", tag, operationId, "try-it-out"], true) } onResetClick = (pathMethod) => { @@ -236,7 +237,7 @@ export default class OperationContainer extends PureComponent { displayRequestDuration, isDeepLinkingEnabled, executeInProgress: this.state.executeInProgress, - tryItOutEnabled: this.state.tryItOutEnabled + tryItOutEnabled: this.props.tryItOutEnabled }) return ( diff --git a/src/core/plugins/deep-linking/layout.js b/src/core/plugins/deep-linking/layout.js index 77b66dc53ac..bbd92ac3445 100644 --- a/src/core/plugins/deep-linking/layout.js +++ b/src/core/plugins/deep-linking/layout.js @@ -7,6 +7,8 @@ const SCROLL_TO = "layout_scroll_to" const CLEAR_SCROLL_TO = "layout_clear_scroll" const SCROLL_TO_VIRTUALIZED_SCHEMA = "layout_scroll_to_virtualized_schema" const CLEAR_SCROLL_TO_VIRTUALIZED_SCHEMA = "layout_clear_scroll_to_virtualized_schema" +const SCROLL_TO_VIRTUALIZED_OPERATION = "layout_scroll_to_virtualized_operation" +const CLEAR_SCROLL_TO_VIRTUALIZED_OPERATION = "layout_clear_scroll_to_virtualized_operation" export const show = (ori, { getConfigs, layoutSelectors }) => (...args) => { ori(...args) @@ -106,6 +108,8 @@ export const parseDeepLinkHash = (rawHash) => ({ layoutActions, layoutSelectors, // Scroll to the newly expanded entity layoutActions.scrollTo(isShownKey) + // Signal the virtualized operations path + layoutActions.scrollToVirtualizedOperation(isShownKey) } } @@ -144,6 +148,15 @@ export const clearScrollToVirtualizedSchema = () => ({ type: CLEAR_SCROLL_TO_VIRTUALIZED_SCHEMA, }) +export const scrollToVirtualizedOperation = (isShownKey) => ({ + type: SCROLL_TO_VIRTUALIZED_OPERATION, + payload: isShownKey, +}) + +export const clearScrollToVirtualizedOperation = () => ({ + type: CLEAR_SCROLL_TO_VIRTUALIZED_OPERATION, +}) + // From: https://stackoverflow.com/a/42543908/3933724 // Modified to return html instead of body element as last resort function getScrollParent(element, includeHidden) { @@ -180,6 +193,8 @@ export default { parseDeepLinkHash, scrollToVirtualizedSchema, clearScrollToVirtualizedSchema, + scrollToVirtualizedOperation, + clearScrollToVirtualizedOperation, }, selectors: { getScrollToKey(state) { @@ -188,6 +203,9 @@ export default { getScrollToVirtualizedSchema(state) { return state.get("scrollToVirtualizedSchema") }, + getScrollToVirtualizedOperation(state) { + return state.get("scrollToVirtualizedOperation") + }, isShownKeyFromUrlHashArray(state, urlHashArray) { const [tag, operationId] = urlHashArray // We only put operations in the URL @@ -222,6 +240,12 @@ export default { [CLEAR_SCROLL_TO_VIRTUALIZED_SCHEMA](state) { return state.delete("scrollToVirtualizedSchema") }, + [SCROLL_TO_VIRTUALIZED_OPERATION](state, action) { + return state.set("scrollToVirtualizedOperation", action.payload) + }, + [CLEAR_SCROLL_TO_VIRTUALIZED_OPERATION](state) { + return state.delete("scrollToVirtualizedOperation") + }, }, wrapActions: { show diff --git a/src/core/plugins/json-schema-5/components/model-example.jsx b/src/core/plugins/json-schema-5/components/model-example.jsx index 71579cdb770..722b72a1ab2 100644 --- a/src/core/plugins/json-schema-5/components/model-example.jsx +++ b/src/core/plugins/json-schema-5/components/model-example.jsx @@ -1,11 +1,12 @@ /** * @prettier */ -import React, { useMemo, useState, useEffect, useCallback, useRef } from "react" +import React, { useMemo, useEffect, useCallback, useRef } from "react" import PropTypes from "prop-types" import ImPropTypes from "react-immutable-proptypes" import cx from "classnames" import randomBytes from "randombytes" +import { immutableToJS } from "core/utils" const usePrevious = (value) => { const ref = useRef() @@ -15,24 +16,52 @@ const usePrevious = (value) => { return ref.current } -const useTabs = ({ initialTab, isExecute, schema, example }) => { +const useTabs = ({ + initialTab, + isExecute, + schema, + example, + specPath, + layoutActions, + layoutSelectors, +}) => { const tabs = useMemo(() => ({ example: "example", model: "model" }), []) - const allowedTabs = useMemo(() => Object.keys(tabs), [tabs]) - const tab = - !allowedTabs.includes(initialTab) || !schema || isExecute - ? tabs.example - : initialTab + const tabKey = useMemo( + () => [...immutableToJS(specPath), "show-model-tab"], + [specPath] + ) + + const showModelByDefault = !!( + initialTab === tabs.model && + schema && + !isExecute + ) + const showModel = layoutSelectors.isShown(tabKey, showModelByDefault) + const activeTab = showModel ? tabs.model : tabs.example + const prevIsExecute = usePrevious(isExecute) - const [activeTab, setActiveTab] = useState(tab) - const handleTabChange = useCallback((e) => { - setActiveTab(e.target.dataset.name) - }, []) + const isFirstRender = useRef(true) + + const handleTabChange = useCallback( + (e) => { + layoutActions.show(tabKey, e.target.dataset.name === tabs.model) + }, + [layoutActions, tabKey, tabs.model] + ) useEffect(() => { - if (prevIsExecute && !isExecute && example) { - setActiveTab(tabs.example) + if (isFirstRender.current) { + isFirstRender.current = false + return + } + + const enteredExecute = !prevIsExecute && isExecute + const leftExecuteWithExample = prevIsExecute && !isExecute && example + + if (enteredExecute || leftExecuteWithExample) { + layoutActions.show(tabKey, false) } - }, [prevIsExecute, isExecute, example]) + }, [prevIsExecute, isExecute, example, layoutActions, tabKey]) return { activeTab, onTabChange: handleTabChange, tabs } } @@ -47,6 +76,8 @@ const ModelExample = ({ getComponent, getConfigs, specSelectors, + layoutActions, + layoutSelectors, }) => { const { defaultModelRendering, defaultModelExpandDepth } = getConfigs() const ModelWrapper = getComponent("ModelWrapper") @@ -61,6 +92,9 @@ const ModelExample = ({ isExecute, schema, example, + specPath, + layoutActions, + layoutSelectors, }) return ( @@ -135,6 +169,9 @@ const ModelExample = ({ specSelectors={specSelectors} expandDepth={defaultModelExpandDepth} specPath={specPath} + fullPath={immutableToJS(specPath)} + layoutActions={layoutActions} + layoutSelectors={layoutSelectors} includeReadOnly={includeReadOnly} includeWriteOnly={includeWriteOnly} /> @@ -148,6 +185,8 @@ ModelExample.propTypes = { getComponent: PropTypes.func.isRequired, specSelectors: PropTypes.shape({ isOAS3: PropTypes.func.isRequired }) .isRequired, + layoutActions: PropTypes.object.isRequired, + layoutSelectors: PropTypes.object.isRequired, schema: PropTypes.object.isRequired, example: PropTypes.any.isRequired, isExecute: PropTypes.bool, diff --git a/src/core/plugins/json-schema-5/components/models.jsx b/src/core/plugins/json-schema-5/components/models.jsx index b16c23e3913..e0e552219fb 100644 --- a/src/core/plugins/json-schema-5/components/models.jsx +++ b/src/core/plugins/json-schema-5/components/models.jsx @@ -92,8 +92,16 @@ const Models = ({ ) if (idx !== -1) { + if (document.querySelector(".operations-virtual")) { + // scroll instantly to avoid recomputing virtualized operations + parentRef.current?.scrollIntoView({ + behavior: "instant", + block: "start", + }) + } else { + layoutActions.scrollToElement(parentRef.current) + } virtualizer.scrollToIndex(idx, { align: "start" }) - layoutActions.scrollToElement(parentRef.current) } layoutActions.clearScrollToVirtualizedSchema() diff --git a/src/core/plugins/oas3/actions.js b/src/core/plugins/oas3/actions.js index a45ae41fff9..20196627d00 100644 --- a/src/core/plugins/oas3/actions.js +++ b/src/core/plugins/oas3/actions.js @@ -8,6 +8,7 @@ export const UPDATE_REQUEST_BODY_INCLUSION = "oas3_set_request_body_inclusion" export const UPDATE_ACTIVE_EXAMPLES_MEMBER = "oas3_set_active_examples_member" export const UPDATE_REQUEST_CONTENT_TYPE = "oas3_set_request_content_type" export const UPDATE_RESPONSE_CONTENT_TYPE = "oas3_set_response_content_type" +export const UPDATE_RESPONSE_CODE_CONTENT_TYPE = "oas3_set_response_code_content_type" export const UPDATE_SERVER_VARIABLE_VALUE = "oas3_set_server_variable_value" export const SET_REQUEST_BODY_VALIDATE_ERROR = "oas3_set_request_body_validate_error" export const CLEAR_REQUEST_BODY_VALIDATE_ERROR = "oas3_clear_request_body_validate_error" @@ -63,6 +64,13 @@ export function setResponseContentType ({ value, path, method }) { } } +export function setResponseCodeContentType ({ value, path, method, code }) { + return { + type: UPDATE_RESPONSE_CODE_CONTENT_TYPE, + payload: { value, path, method, code } + } +} + export function setServerVariableValue ({ server, namespace, key, val }) { return { type: UPDATE_SERVER_VARIABLE_VALUE, diff --git a/src/core/plugins/oas3/components/request-body.jsx b/src/core/plugins/oas3/components/request-body.jsx index 388f5cc6662..8a52118c00f 100644 --- a/src/core/plugins/oas3/components/request-body.jsx +++ b/src/core/plugins/oas3/components/request-body.jsx @@ -69,7 +69,7 @@ const RequestBody = ({ } const Markdown = getComponent("Markdown", true) - const ModelExample = getComponent("modelExample") + const ModelExample = getComponent("modelExample", true) const RequestBodyEditor = getComponent("RequestBodyEditor") const HighlightCode = getComponent("HighlightCode", true) const ExamplesSelectValueRetainer = getComponent("ExamplesSelectValueRetainer") diff --git a/src/core/plugins/oas3/reducers.js b/src/core/plugins/oas3/reducers.js index f4cba1fa41f..6b6befcabfe 100644 --- a/src/core/plugins/oas3/reducers.js +++ b/src/core/plugins/oas3/reducers.js @@ -8,6 +8,7 @@ import { UPDATE_REQUEST_CONTENT_TYPE, UPDATE_SERVER_VARIABLE_VALUE, UPDATE_RESPONSE_CONTENT_TYPE, + UPDATE_RESPONSE_CODE_CONTENT_TYPE, SET_REQUEST_BODY_VALIDATE_ERROR, CLEAR_REQUEST_BODY_VALIDATE_ERROR, CLEAR_REQUEST_BODY_VALUE, UPDATE_REQUEST_BODY_VALUE_RETAIN_FLAG, @@ -59,6 +60,9 @@ export default { [UPDATE_RESPONSE_CONTENT_TYPE]: (state, { payload: { value, path, method } } ) =>{ return state.setIn( [ "requestData", path, method, "responseContentType" ], value) }, + [UPDATE_RESPONSE_CODE_CONTENT_TYPE]: (state, { payload: { value, path, method, code } } ) =>{ + return state.setIn( [ "requestData", path, method, "responseCodeContentType", code ], value) + }, [UPDATE_SERVER_VARIABLE_VALUE]: (state, { payload: { server, namespace, key, val } } ) =>{ const path = namespace ? [ namespace, "serverVariableValues", server, key ] : [ "serverVariableValues", server, key ] return state.setIn(path, val) diff --git a/src/core/plugins/oas3/selectors.js b/src/core/plugins/oas3/selectors.js index 594e5253713..ae24ef6b457 100644 --- a/src/core/plugins/oas3/selectors.js +++ b/src/core/plugins/oas3/selectors.js @@ -187,6 +187,18 @@ export const responseContentType = onlyOAS3((state, path, method) => { ) }) +export const responseCodeContentType = onlyOAS3((state, path, method, code) => { + return ( + state.getIn([ + "requestData", + path, + method, + "responseCodeContentType", + code, + ]) || null + ) +}) + export const serverVariableValue = onlyOAS3((state, locationData, key) => { let path diff --git a/src/core/utils/virtualization.ts b/src/core/utils/virtualization.ts index ea7e91cfd55..302085dd2e1 100644 --- a/src/core/utils/virtualization.ts +++ b/src/core/utils/virtualization.ts @@ -5,3 +5,8 @@ export const VIRTUALIZE_MODELS_THRESHOLD = 100 export const VIRTUALIZE_MODELS_ESTIMATE_SIZE = 71 export const VIRTUALIZE_JSON_SCHEMA_2020_12_ESTIMATE_SIZE = 48 export const VIRTUALIZE_MODELS_OVERSCAN = 5 + +export const VIRTUALIZE_OPERATIONS_THRESHOLD = 150 +export const VIRTUALIZE_OPERATIONS_TAG_ESTIMATE_SIZE = 70 +export const VIRTUALIZE_OPERATIONS_ESTIMATE_SIZE = 60 +export const VIRTUALIZE_OPERATIONS_OVERSCAN = 3 diff --git a/src/style/_layout.scss b/src/style/_layout.scss index a05b5095e47..b2edcfc33ac 100644 --- a/src/style/_layout.scss +++ b/src/style/_layout.scss @@ -47,6 +47,15 @@ flex-direction: column; } +.operations-virtual { + &__item { + display: flex; + flex-direction: column; + + width: 100%; + } +} + .try-out.btn-group { padding: 0; display: flex; diff --git a/test/e2e-cypress/e2e/features/operations-virtualization.cy.js b/test/e2e-cypress/e2e/features/operations-virtualization.cy.js new file mode 100644 index 00000000000..77ce4d30c74 --- /dev/null +++ b/test/e2e-cypress/e2e/features/operations-virtualization.cy.js @@ -0,0 +1,122 @@ +/** + * @prettier + */ +import { + VIRTUALIZE_OPERATIONS_TAG_ESTIMATE_SIZE, + VIRTUALIZE_OPERATIONS_ESTIMATE_SIZE, +} from "core/utils/virtualization" + +describe("Operations list virtualization", () => { + describe("non-virtualized path", () => { + it("renders .opblock-tag-section wrappers and all operations", () => { + cy.visit("/?url=/documents/features/deep-linking.swagger.yaml") + cy.get(".opblock-tag-section").should("exist") + cy.get(".opblock").should("have.length", 5) + }) + }) + + describe("virtualized path", () => { + const baseUrl = "/?url=/documents/perf/many-operations.yaml" + + it("does not render .opblock-tag-section wrappers", () => { + cy.visit(baseUrl) + cy.get("#operations-tag-perfTag01").should("exist") + cy.get(".opblock-tag-section").should("not.exist") + }) + + it("mounts only a windowed subset of operations", () => { + cy.visit(baseUrl) + cy.get("#operations-tag-perfTag01").should("exist") + cy.get(".opblock").should("have.length.lessThan", 529) + }) + + it("scrolling renders new items and unmounts old ones", () => { + cy.visit(baseUrl) + cy.get("#operations-perfTag01-perfOp01_01").should("exist") + cy.scrollTo("bottom") + cy.wait(200) + cy.get("#operations-perfTag24-perfOp24_22").should("exist") + cy.get("#operations-perfTag01-perfOp01_01").should("not.exist") + }) + + it("collapsing a tag removes its operations, expanding restores them", () => { + cy.visit(baseUrl) + cy.get("#operations-tag-perfTag01[data-is-open='true']").should("exist") + cy.get("#operations-perfTag01-perfOp01_01").should("exist") + + cy.get("#operations-tag-perfTag01").click() + cy.get("#operations-tag-perfTag01[data-is-open='false']").should("exist") + cy.get("#operations-perfTag01-perfOp01_01").should("not.exist") + + cy.get("#operations-tag-perfTag01").click() + cy.get("#operations-tag-perfTag01[data-is-open='true']").should("exist") + cy.get("#operations-perfTag01-perfOp01_01").should("exist") + }) + + it("expanding an operation does not reset on scroll", () => { + cy.visit(baseUrl) + cy.get("#operations-tag-perfTag01[data-is-open='true']").should("exist") + + cy.get(".opblock").first().as("firstOp") + cy.get("@firstOp").find(".opblock-summary").click() + cy.get("@firstOp").find(".opblock-body").should("be.visible") + + cy.scrollTo("bottom") + cy.wait(300) + cy.scrollTo("top") + cy.wait(300) + + cy.get(".opblock").first().find(".opblock-body").should("be.visible") + }) + + it("multi-tag operation is rendered under both of its tags", () => { + cy.visit(baseUrl) + cy.get("#operations-tag-perfTag01").should("exist") + + // perfTag01 header + 22 regular ops before multiTagged + const multiTaggedUnderTag01 = + VIRTUALIZE_OPERATIONS_TAG_ESTIMATE_SIZE + + 22 * VIRTUALIZE_OPERATIONS_ESTIMATE_SIZE + cy.window().then((win) => win.scrollTo(0, multiTaggedUnderTag01)) + cy.wait(200) + cy.get("#operations-perfTag01-multiTagged").should("exist") + cy.get("#operations-perfTag01-multiTagged") + .find(".opblock-summary") + .click() + cy.get("#operations-perfTag01-multiTagged") + .find(".opblock-body") + .should("be.visible") + + // scroll past first multiTagged and through perfTag02 to its multiTagged + cy.window().then((win) => + win.scrollTo( + 0, + 2 * multiTaggedUnderTag01 + VIRTUALIZE_OPERATIONS_ESTIMATE_SIZE + ) + ) + cy.wait(200) + cy.get("#operations-perfTag02-multiTagged").should("exist") + cy.get("#operations-perfTag02-multiTagged") + .find(".opblock-body") + .should("not.exist") + }) + }) + + describe("deep linking", () => { + it("deep link to an operation scrolls to it", () => { + cy.visit( + "/?deepLinking=true&url=/documents/perf/many-operations.yaml#/perfTag15/perfOp15_01" + ) + cy.get("#operations-tag-perfTag15[data-is-open='true']", { + timeout: 8000, + }).should("exist") + }) + + it("deep link to a tag header scrolls to it", () => { + cy.visit( + "/?deepLinking=true&url=/documents/perf/many-operations.yaml#/perfTag15" + ) + cy.get("#operations-tag-perfTag15", { timeout: 8000 }).should("exist") + }) + }) +}) diff --git a/test/unit/components/operations.jsx b/test/unit/components/operations.jsx index 8ba7b48df07..53f15787035 100644 --- a/test/unit/components/operations.jsx +++ b/test/unit/components/operations.jsx @@ -1,129 +1,417 @@ +/** + * @prettier + */ import React from "react" -import { render } from "enzyme" -import { fromJS } from "immutable" +import { render, mount } from "enzyme" +import { fromJS, OrderedMap, List } from "immutable" import DeepLink from "core/components/deep-link" import Operations from "core/components/operations" -import {Collapse} from "core/components/layout-utils" +import { Collapse } from "core/components/layout-utils" + +jest.mock("@tanstack/react-virtual", () => { + const instance = () => ({ + getVirtualItems: () => [], + getTotalSize: () => 0, + measureElement: () => {}, + scrollToIndex: jest.fn(), + options: { scrollMargin: 0 }, + }) + return { + useWindowVirtualizer: jest.fn(instance), + useVirtualizer: jest.fn(instance), + } +}) + +jest.mock("swagger-client/es/helpers", () => ({ + opId: (op, path, method) => `${path}-${method}`, +})) const components = { Collapse, DeepLink, // eslint-disable-next-line react/prop-types - OperationContainer: ({ path, method }) => , + OperationContainer: ({ path, method }) => ( + + ), OperationTag: "div", } -describe("", function(){ - it("should render a Swagger2 `get` method, but not a `trace` or `foo` method", function(){ +const dummyComponent = () => null +/* eslint-disable react/prop-types */ +const DummyComponentWithChildren = ({ children }) =>
{children}
+ +const makeOp = (path, method, tag, operationId) => + fromJS({ + path, + method, + specPath: ["paths", path, method], + operation: operationId ? { operationId } : {}, + id: `${path}-${method}`, + }) + +const makeTaggedOps = (entries) => + entries.reduce( + (map, [tag, ops]) => + map.set( + tag, + fromJS({ + tagDetails: {}, + operations: List( + ops.map(([path, method, id]) => makeOp(path, method, tag, id)) + ), + }) + ), + OrderedMap() + ) + +const makeProps = (overrides = {}) => ({ + specSelectors: { + taggedOperations: () => + makeTaggedOps([["pets", [["/pets", "get", "listPets"]]]]), + validOperationMethods: () => [ + "get", + "post", + "put", + "delete", + "patch", + "head", + "options", + ], + url: () => "http://example.com/spec.yaml", + }, + specActions: {}, + oas3Actions: {}, + oas3Selectors: { selectedServer: () => "" }, + layoutSelectors: { + isShown: jest.fn(() => true), + getScrollToVirtualizedOperation: jest.fn(() => null), + }, + layoutActions: { + show: jest.fn(), + clearScrollToVirtualizedOperation: jest.fn(), + clearScrollTo: jest.fn(), + }, + authActions: {}, + authSelectors: {}, + getComponent: (c) => { + const map = { + OperationContainer: dummyComponent, + OperationTag: DummyComponentWithChildren, + Collapse: DummyComponentWithChildren, + Markdown: dummyComponent, + DeepLink: dummyComponent, + Link: dummyComponent, + ArrowUpIcon: dummyComponent, + ArrowDownIcon: dummyComponent, + } + return map[c] || dummyComponent + }, + getConfigs: () => ({ docExpansion: "list" }), + fn: {}, + ...overrides, +}) - let props = { - fn: {}, - specActions: {}, - layoutActions: {}, - getComponent: (name)=> { - return components[name] || null +describe("", function () { + afterEach(() => { + jest.clearAllMocks() + }) + + it("should render a Swagger2 `get` method, but not a `trace` or `foo` method", function () { + const props = makeProps({ + getComponent: (name) => components[name] || null, + getConfigs: () => ({}), + specSelectors: { + url: () => "https://petstore.swagger.io/v2/swagger.json", + validOperationMethods: () => [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + ], + taggedOperations: () => + fromJS({ + default: { + operations: [ + { path: "/pets/{id}", method: "get" }, + { path: "/pets/{id}", method: "trace" }, + { path: "/pets/{id}", method: "foo" }, + ], + }, + }), }, - getConfigs: () => { - return {} + }) + + const wrapper = render() + + expect(wrapper.find("span.mocked-op").length).toEqual(1) + expect(wrapper.find("span.mocked-op").eq(0).attr("id")).toEqual( + "/pets/{id}-get" + ) + }) + + it("should render an OAS3 `get` and `trace` method, but not a `foo` method", function () { + const props = makeProps({ + getComponent: (name) => components[name] || null, + getConfigs: () => ({}), + specSelectors: { + url: () => "https://petstore.swagger.io/v2/swagger.json", + validOperationMethods: () => [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + ], + taggedOperations: () => + fromJS({ + default: { + operations: [ + { path: "/pets/{id}", method: "get" }, + { path: "/pets/{id}", method: "trace" }, + { path: "/pets/{id}", method: "foo" }, + ], + }, + }), }, + }) + + const wrapper = render() + + expect(wrapper.find("span.mocked-op").length).toEqual(2) + expect(wrapper.find("span.mocked-op").eq(0).attr("id")).toEqual( + "/pets/{id}-get" + ) + expect(wrapper.find("span.mocked-op").eq(1).attr("id")).toEqual( + "/pets/{id}-trace" + ) + }) + + it("renders 'No operations defined in spec!' when taggedOps is empty", function () { + const props = makeProps({ specSelectors: { - isOAS3() { return false }, - url() { return "https://petstore.swagger.io/v2/swagger.json" }, - validOperationMethods() { return ["get", "put", "post", "delete", "options", "head", "patch"] }, - taggedOperations() { - return fromJS({ - "default": { - "operations": [ - { - "path": "/pets/{id}", - "method": "get" - }, - { - "path": "/pets/{id}", - "method": "trace" - }, - { - "path": "/pets/{id}", - "method": "foo" - }, - ] - } - }) - }, + taggedOperations: () => OrderedMap(), + validOperationMethods: () => ["get"], + url: () => "", }, - layoutSelectors: { - currentFilter() { - return null - }, - isShown() { - return true - }, - show() { - return true - } - } - } + }) - let wrapper = render() + const wrapper = mount() - expect(wrapper.find("span.mocked-op").length).toEqual(1) - expect(wrapper.find("span.mocked-op").eq(0).attr("id")).toEqual("/pets/{id}-get") + expect(wrapper.text()).toContain("No operations defined in spec!") }) - it("should render an OAS3 `get` and `trace` method, but not a `foo` method", function(){ + it("uses non-virtualized path when item count is below threshold", function () { + const wrapper = mount() + + expect( + wrapper.find("[style]").filterWhere((n) => { + const s = n.prop("style") || {} + return s.position === "absolute" + }).length + ).toEqual(0) + }) - let props = { - fn: {}, - specActions: {}, - layoutActions: {}, - getComponent: (name)=> { - return components[name] || null + it("uses non-virtualized path when item count is just below threshold (149)", function () { + const ops = Array.from({ length: 148 }, (_, i) => [ + `/p${i}`, + "get", + `op${i}`, + ]) + const props = makeProps({ + specSelectors: { + taggedOperations: () => makeTaggedOps([["tag0", ops]]), + validOperationMethods: () => ["get"], + url: () => "", }, - getConfigs: () => { - return {} + }) + + const wrapper = mount() + + expect( + wrapper.find("[style]").filterWhere((n) => { + const s = n.prop("style") || {} + return s.position === "absolute" + }).length + ).toEqual(0) + }) + + it("uses virtualized path when item count is at threshold (150)", function () { + const ops = Array.from({ length: 149 }, (_, i) => [ + `/p${i}`, + "get", + `op${i}`, + ]) + const props = makeProps({ + specSelectors: { + taggedOperations: () => makeTaggedOps([["tag0", ops]]), + validOperationMethods: () => ["get"], + url: () => "", + }, + }) + + const wrapper = mount() + + expect(wrapper.find(".operations-virtual__list").length).toBeGreaterThan(0) + }) + + it("renders OperationTag for each tag in the non-virtualized path", function () { + const TagSpy = jest.fn(({ children }) =>
{children}
) + const props = makeProps({ + getComponent: (c) => { + if (c === "OperationTag") return TagSpy + if (c === "OperationContainer") return dummyComponent + return ({ children }) =>
{children}
}, specSelectors: { - isOAS3() { return true }, - url() { return "https://petstore.swagger.io/v2/swagger.json" }, - validOperationMethods() { return ["get", "put", "post", "delete", "options", "head", "patch", "trace"] }, - taggedOperations() { - return fromJS({ - "default": { - "operations": [ - { - "path": "/pets/{id}", - "method": "get" - }, - { - "path": "/pets/{id}", - "method": "trace" - }, - { - "path": "/pets/{id}", - "method": "foo" - }, - ] - } - }) - }, + taggedOperations: () => + makeTaggedOps([ + ["tagA", [["/a", "get", "opA"]]], + ["tagB", [["/b", "post", "opB"]]], + ]), + validOperationMethods: () => ["get", "post"], + url: () => "", }, - layoutSelectors: { - currentFilter() { - return null - }, - isShown() { - return true - }, - show() { - return true - } - } - } + }) - let wrapper = render() + mount() - expect(wrapper.find("span.mocked-op").length).toEqual(2) - expect(wrapper.find("span.mocked-op").eq(0).attr("id")).toEqual("/pets/{id}-get") - expect(wrapper.find("span.mocked-op").eq(1).attr("id")).toEqual("/pets/{id}-trace") + expect(TagSpy).toHaveBeenCalledTimes(2) + }) + + it("renders OperationTag for each tag in the virtualized path", function () { + const TagSpy = jest.fn(() => null) + const { useWindowVirtualizer } = jest.requireMock("@tanstack/react-virtual") + useWindowVirtualizer.mockReturnValueOnce({ + getVirtualItems: () => [ + { index: 0, key: "tag-tagA", start: 0 }, + { index: 76, key: "tag-tagB", start: 70 }, + ], + getTotalSize: () => 152 * 70, + measureElement: () => {}, + scrollToIndex: jest.fn(), + options: { scrollMargin: 0 }, + }) + const opsA = Array.from({ length: 75 }, (_, i) => [ + `/a${i}`, + "get", + `opA${i}`, + ]) + const opsB = Array.from({ length: 75 }, (_, i) => [ + `/b${i}`, + "get", + `opB${i}`, + ]) + const props = makeProps({ + getComponent: (c) => { + if (c === "OperationTag") return TagSpy + return dummyComponent + }, + specSelectors: { + taggedOperations: () => + makeTaggedOps([ + ["tagA", opsA], + ["tagB", opsB], + ]), + validOperationMethods: () => ["get"], + url: () => "", + }, + }) + + mount() + + expect(TagSpy).toHaveBeenCalledTimes(2) + }) + + it("filters out operations with invalid methods in the non-virtualized path", function () { + const ContainerSpy = jest.fn(() => null) + const props = makeProps({ + getComponent: (c) => { + if (c === "OperationContainer") return ContainerSpy + return ({ children }) =>
{children}
+ }, + specSelectors: { + taggedOperations: () => + makeTaggedOps([ + [ + "tagA", + [ + ["/a", "get", "opA"], + ["/b", "trace", "opB"], + ], + ], + ]), + validOperationMethods: () => ["get", "post"], + url: () => "", + }, + }) + + mount() + + expect(ContainerSpy).toHaveBeenCalledTimes(1) + expect(ContainerSpy.mock.calls[0][0].method).toBe("get") + }) + + it("filters out operations with invalid methods in the virtualized path", function () { + const { useWindowVirtualizer } = jest.requireMock("@tanstack/react-virtual") + const validOps = Array.from({ length: 149 }, (_, i) => [ + `/a${i}`, + "get", + `opA${i}`, + ]) + const props = makeProps({ + specSelectors: { + taggedOperations: () => + makeTaggedOps([ + ["tagA", [...validOps, ["/invalid", "trace", "traceOp"]]], + ]), + validOperationMethods: () => ["get"], + url: () => "", + }, + }) + + mount() + + expect(useWindowVirtualizer).toHaveBeenCalledWith( + expect.objectContaining({ count: 150 }) + ) + }) + + it("OperationTag renders without children in the virtualized path", function () { + const TagSpy = jest.fn(() => null) + const { useWindowVirtualizer } = jest.requireMock("@tanstack/react-virtual") + useWindowVirtualizer.mockReturnValueOnce({ + getVirtualItems: () => [{ index: 0, key: "tag-tag0", start: 0 }], + getTotalSize: () => 150 * 70, + measureElement: () => {}, + scrollToIndex: jest.fn(), + options: { scrollMargin: 0 }, + }) + const ops = Array.from({ length: 149 }, (_, i) => [ + `/p${i}`, + "get", + `op${i}`, + ]) + const props = makeProps({ + getComponent: (c) => { + if (c === "OperationTag") return TagSpy + return dummyComponent + }, + specSelectors: { + taggedOperations: () => makeTaggedOps([["tag0", ops]]), + validOperationMethods: () => ["get"], + url: () => "", + }, + }) + + mount() + + expect(TagSpy).toHaveBeenCalledTimes(1) + expect(TagSpy.mock.calls[0][0].children).toBeUndefined() }) }) diff --git a/test/unit/core/plugins/json-schema-5/components/model-example.jsx b/test/unit/core/plugins/json-schema-5/components/model-example.jsx index 737be87af9b..3206abc5ed7 100644 --- a/test/unit/core/plugins/json-schema-5/components/model-example.jsx +++ b/test/unit/core/plugins/json-schema-5/components/model-example.jsx @@ -1,5 +1,6 @@ import React from "react" import { shallow } from "enzyme" +import { List } from "immutable" import ModelExample from "core/plugins/json-schema-5/components/model-example" import ModelComponent from "core/plugins/json-schema-5/components/model-wrapper" @@ -30,6 +31,13 @@ describe("", function(){ specSelectors: { isOAS3: () => false }, + layoutActions: { + show: jest.fn() + }, + layoutSelectors: { + isShown: (_key, defaultValue) => defaultValue + }, + specPath: List(["definitions", "Example"]), schema: {}, example: "{\"example\": \"value\"}", isExecute: false,