From 95906f60258380988e0c2dee5a35c26ee6cf1b06 Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Fri, 21 Aug 2026 11:14:51 +0200 Subject: [PATCH 1/6] feat: add virtualization for schema section --- .../phase-1-models-virtualization.md | 30 +- package-lock.json | 28 ++ package.json | 1 + src/core/plugins/deep-linking/layout.js | 26 +- .../json-schema-5/components/models.jsx | 341 ++++++++++++------ src/core/utils/index.js | 2 + src/style/_models.scss | 12 + .../e2e/features/models-virtualization.cy.js | 76 ++++ .../features/plugins/oas3/complex-spec.cy.js | 4 + .../json-schema-5/components/models.jsx | 183 ++++++++-- test/unit/jest-shim.js | 6 + 11 files changed, 546 insertions(+), 163 deletions(-) create mode 100644 test/e2e-cypress/e2e/features/models-virtualization.cy.js diff --git a/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md b/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md index b50d2e22cc5..152486afa36 100644 --- a/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md +++ b/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md @@ -76,39 +76,39 @@ component renders instead of this one. ## Acceptance Criteria -- [ ] A spec **below** the 100-schema threshold renders today's markup unchanged (legacy path) — +- [x] A spec **below** the 100-schema threshold renders today's markup unchanged (legacy path) — verify `model-collapse.cy.js` passes with no edits -- [ ] A spec **above** the threshold uses the windowed path, and only visible models in the viewport +- [x] A spec **above** the threshold uses the windowed path, and only visible models in the viewport are mounted (verify in React DevTools) -- [ ] Boundary tested both sides — one fixture just under the threshold, one just over -- [ ] Scrolling through the models list renders/unmounts items correctly -- [ ] Collapsing and expanding the "Schemas/Models" section works as before -- [ ] Existing `model-collapse.cy.js` scenarios still pass **with no edits** — its fixtures have 3 +- [x] Boundary tested both sides — one fixture just under the threshold, one just over +- [x] Scrolling through the models list renders/unmounts items correctly +- [x] Collapsing and expanding the "Schemas/Models" section works as before +- [x] Existing `model-collapse.cy.js` scenarios still pass **with no edits** — its fixtures have 3 definitions each, so they take the legacy path. If any of its selectors needed changing (`.models h4 .models-control`, `#model-User .model-box .model-box-control` at `:40`/`:44`, `#model-Pet` / `#model-Order` at `:18`/`:28`/`:34`), that means the legacy path was altered — treat it as a regression, not a test to update -- [ ] `defaultModelsExpandDepth < 0` still short-circuits the whole section to `null` +- [x] `defaultModelsExpandDepth < 0` still short-circuits the whole section to `null` (`models.jsx:51`), and `defaultModelsExpandDepth > 0 && isShown` still drives initial per-model expansion (`models.jsx:131`) - [ ] No visual regression — layout, spacing, expand/collapse of individual model unchanged -- [ ] No accessibility regression — keyboard navigation and screen reader order preserved -- [ ] Performance: initial render time for the 200+ model fixture reduced vs. the recorded baseline +- [x] No accessibility regression — keyboard navigation and screen reader order preserved +- [x] Performance: initial render time for the 200+ model fixture reduced vs. the recorded baseline (React Profiler, before/after) -- [ ] Unit tests updated in `test/unit/core/plugins/json-schema-5/components/models.jsx` -- [ ] `ResizeObserver` polyfill added to `test/unit/jest-shim.js` and `npm run test:unit` green +- [x] Unit tests updated in `test/unit/core/plugins/json-schema-5/components/models.jsx` +- [x] `ResizeObserver` polyfill added to `test/unit/jest-shim.js` and `npm run test:unit` green (blocking prerequisite — see Unit-test infrastructure) - [ ] Bundle-size impact recorded via `npm run deps-size` before/after; `@tanstack/react-virtual` adds ~5KB min+gzip. Flag it in the PR if the measured delta is materially larger -- [ ] `swagger-ui-react` still renders models correctly — the flavor re-exports core, so it +- [x] `swagger-ui-react` still renders models correctly — the flavor re-exports core, so it inherits this change with no code edit, but it is a separately published package and is not covered by the Cypress suite -- [ ] E2E test: models section scrolls and renders correctly with the new fixture -- [ ] `#model-` browser-anchor navigation still works below the threshold, and its +- [x] E2E test: models section scrolls and renders correctly with the new fixture +- [x] `#model-` browser-anchor navigation still works below the threshold, and its above-threshold breakage is accepted per [Accepted Behavior Changes](#accepted-behavior-changes-confirm-with-maintainers-before-building). Note this is the plain browser anchor — model *deep linking* does not exist (see Technical Notes) -- [ ] Expanding model A, scrolling it out of view, and scrolling back shows A still expanded and +- [x] Expanding model A, scrolling it out of view, and scrolling back shows A still expanded and no *other* model wrongly expanded (guards the `getItemKey` requirement below) ## Technical Notes diff --git a/package-lock.json b/package-lock.json index 7fc53c07ea9..aff9687596d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@babel/runtime-corejs3": "^7.27.1", "@scarf/scarf": "=1.4.0", + "@tanstack/react-virtual": "=3.14.9", "base64-js": "^1.5.1", "buffer": "^6.0.3", "classnames": "^2.5.1", @@ -7669,6 +7670,33 @@ "node": ">=12.20.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", diff --git a/package.json b/package.json index 3ff966dcf27..90640330a1f 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "dependencies": { "@babel/runtime-corejs3": "^7.27.1", "@scarf/scarf": "=1.4.0", + "@tanstack/react-virtual": "=3.14.9", "base64-js": "^1.5.1", "buffer": "^6.0.3", "classnames": "^2.5.1", diff --git a/src/core/plugins/deep-linking/layout.js b/src/core/plugins/deep-linking/layout.js index bbd526cb9ff..2434b7cb44c 100644 --- a/src/core/plugins/deep-linking/layout.js +++ b/src/core/plugins/deep-linking/layout.js @@ -5,6 +5,8 @@ import Im, { fromJS } from "immutable" 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" export const show = (ori, { getConfigs, layoutSelectors }) => (...args) => { ori(...args) @@ -133,6 +135,15 @@ export const clearScrollTo = () => { } } +export const scrollToVirtualizedSchema = (name) => ({ + type: SCROLL_TO_VIRTUALIZED_SCHEMA, + payload: name, +}) + +export const clearScrollToVirtualizedSchema = () => ({ + type: CLEAR_SCROLL_TO_VIRTUALIZED_SCHEMA, +}) + // From: https://stackoverflow.com/a/42543908/3933724 // Modified to return html instead of body element as last resort function getScrollParent(element, includeHidden) { @@ -166,12 +177,17 @@ export default { scrollTo, clearScrollTo, readyToScroll, - parseDeepLinkHash + parseDeepLinkHash, + scrollToVirtualizedSchema, + clearScrollToVirtualizedSchema, }, selectors: { getScrollToKey(state) { return state.get("scrollToKey") }, + getScrollToVirtualizedSchema(state) { + return state.get("scrollToVirtualizedSchema") + }, isShownKeyFromUrlHashArray(state, urlHashArray) { const [tag, operationId] = urlHashArray // We only put operations in the URL @@ -199,7 +215,13 @@ export default { }, [CLEAR_SCROLL_TO](state) { return state.delete("scrollToKey") - } + }, + [SCROLL_TO_VIRTUALIZED_SCHEMA](state, action) { + return state.set("scrollToVirtualizedSchema", action.payload) + }, + [CLEAR_SCROLL_TO_VIRTUALIZED_SCHEMA](state) { + return state.delete("scrollToVirtualizedSchema") + }, }, wrapActions: { show diff --git a/src/core/plugins/json-schema-5/components/models.jsx b/src/core/plugins/json-schema-5/components/models.jsx index 5f185ca090f..373bf186c00 100644 --- a/src/core/plugins/json-schema-5/components/models.jsx +++ b/src/core/plugins/json-schema-5/components/models.jsx @@ -1,67 +1,199 @@ -import React, { Component } from "react" +/** + * @prettier + */ +import React, { useRef, useMemo, useCallback, useEffect } from "react" import Im, { Map } from "immutable" import PropTypes from "prop-types" +import { useVirtualizer } from "@tanstack/react-virtual" +import { VIRTUALIZE_MODELS_THRESHOLD } from "core/utils" /* eslint-disable react/jsx-no-bind */ -export default class Models extends Component { - static propTypes = { - getComponent: PropTypes.func, - specSelectors: PropTypes.object, - specActions: PropTypes.object.isRequired, - layoutSelectors: PropTypes.object, - layoutActions: PropTypes.object, - getConfigs: PropTypes.func.isRequired - } - - getSchemaBasePath = () => { - const isOAS3 = this.props.specSelectors.isOAS3() - return isOAS3 ? ["components", "schemas"] : ["definitions"] - } - - getCollapsedContent = () => { - return " " - } - - handleToggle = (name, isExpanded) => { - const { layoutActions } = this.props - layoutActions.show([...this.getSchemaBasePath(), name], isExpanded) - if(isExpanded) { - this.props.specActions.requestResolvedSubtree([...this.getSchemaBasePath(), name]) +const Models = ({ + getComponent, + specSelectors, + specActions, + layoutSelectors, + layoutActions, + getConfigs, +}) => { + const definitions = specSelectors.definitions() + const { docExpansion, defaultModelsExpandDepth } = getConfigs() + + const isOAS3 = specSelectors.isOAS3() + const specPathBase = useMemo( + () => (isOAS3 ? ["components", "schemas"] : ["definitions"]), + [isOAS3] + ) + + const getCollapsedContent = useCallback(() => " ", []) + + const handleToggle = useCallback( + (name, isExpanded) => { + layoutActions.show([...specPathBase, name], isExpanded) + if (isExpanded) { + specActions.requestResolvedSubtree([...specPathBase, name]) + } + }, + [layoutActions, specActions, specPathBase] + ) + + const onLoadModels = useCallback( + (ref) => { + if (ref) { + layoutActions.readyToScroll(specPathBase, ref) + } + }, + [layoutActions, specPathBase] + ) + + const onLoadModel = useCallback( + (ref) => { + if (ref) { + const name = ref.getAttribute("data-name") + layoutActions.readyToScroll([...specPathBase, name], ref) + } + }, + [layoutActions, specPathBase] + ) + + const definitionEntries = useMemo( + () => definitions.entrySeq().toArray(), + [definitions] + ) + + const parentRef = useRef(null) + const measurementsCache = useRef([]) + + const virtualizer = useVirtualizer({ + count: definitionEntries.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 71, + overscan: 5, + getItemKey: (index) => `models-section-${definitionEntries[index][0]}`, + initialMeasurementsCache: measurementsCache.current, + onChange: (instance) => { + measurementsCache.current = instance.takeSnapshot() + }, + }) + + const isVirtualized = definitionEntries.length >= VIRTUALIZE_MODELS_THRESHOLD + const pendingVirtualizedSchemaScroll = + layoutSelectors.getScrollToVirtualizedSchema() + + useEffect(() => { + if (!pendingVirtualizedSchemaScroll || !isVirtualized) return + + const idx = definitionEntries.findIndex( + ([name]) => name === pendingVirtualizedSchemaScroll + ) + + if (idx !== -1) { + virtualizer.scrollToIndex(idx, { align: "start" }) + layoutActions.scrollToElement(parentRef.current) } - } - onLoadModels = (ref) => { - if (ref) { - this.props.layoutActions.readyToScroll(this.getSchemaBasePath(), ref) + layoutActions.clearScrollToVirtualizedSchema() + }, [ + pendingVirtualizedSchemaScroll, + definitionEntries, + virtualizer, + isVirtualized, + layoutActions, + ]) + + if (!definitions.size || defaultModelsExpandDepth < 0) return null + + const showModels = layoutSelectors.isShown( + specPathBase, + defaultModelsExpandDepth > 0 && docExpansion !== "none" + ) + + const ModelWrapper = getComponent("ModelWrapper") + const Collapse = getComponent("Collapse") + const ModelCollapse = getComponent("ModelCollapse") + const JumpToPath = getComponent("JumpToPath", true) + const ArrowUpIcon = getComponent("ArrowUpIcon") + const ArrowDownIcon = getComponent("ArrowDownIcon") + + const renderModelItem = (name, key) => { + const fullPath = [...specPathBase, name] + const specPath = Im.List(fullPath) + + const schemaValue = specSelectors.specResolvedSubtree(fullPath) + const rawSchemaValue = specSelectors.specJson().getIn(fullPath) + + const schema = Map.isMap(schemaValue) ? schemaValue : Im.Map() + const rawSchema = Map.isMap(rawSchemaValue) ? rawSchemaValue : Im.Map() + + const displayName = schema.get("title") || rawSchema.get("title") || name + const isShown = layoutSelectors.isShown(fullPath, false) + + if (isShown && schema.size === 0 && rawSchema.size > 0) { + // Firing an action in a container render is not great, + // but it works for now. + specActions.requestResolvedSubtree(fullPath) } - } - onLoadModel = (ref) => { - if (ref) { - const name = ref.getAttribute("data-name") - this.props.layoutActions.readyToScroll([...this.getSchemaBasePath(), name], ref) - } + const content = ( + + ) + + const title = ( + + {displayName} + + ) + + return ( +
+ + + + 0 && isShown} + > + {content} + +
+ ) } - render(){ - let { specSelectors, getComponent, layoutSelectors, layoutActions, getConfigs } = this.props - let definitions = specSelectors.definitions() - let { docExpansion, defaultModelsExpandDepth } = getConfigs() - if (!definitions.size || defaultModelsExpandDepth < 0) return null - - const specPathBase = this.getSchemaBasePath() - let showModels = layoutSelectors.isShown(specPathBase, defaultModelsExpandDepth > 0 && docExpansion !== "none") - const isOAS3 = specSelectors.isOAS3() - - const ModelWrapper = getComponent("ModelWrapper") - const Collapse = getComponent("Collapse") - const ModelCollapse = getComponent("ModelCollapse") - const JumpToPath = getComponent("JumpToPath", true) - const ArrowUpIcon = getComponent("ArrowUpIcon") - const ArrowDownIcon = getComponent("ArrowDownIcon") - - return
+ return ( +

- { - definitions.entrySeq().map(([name])=>{ - - const fullPath = [...specPathBase, name] - const specPath = Im.List(fullPath) - - const schemaValue = specSelectors.specResolvedSubtree(fullPath) - const rawSchemaValue = specSelectors.specJson().getIn(fullPath) - - const schema = Map.isMap(schemaValue) ? schemaValue : Im.Map() - const rawSchema = Map.isMap(rawSchemaValue) ? rawSchemaValue : Im.Map() - - const displayName = schema.get("title") || rawSchema.get("title") || name - const isShown = layoutSelectors.isShown(fullPath, false) - - if( isShown && (schema.size === 0 && rawSchema.size > 0) ) { - // Firing an action in a container render is not great, - // but it works for now. - this.props.specActions.requestResolvedSubtree(fullPath) - } - - const content = - - const title = - - {displayName} - - - - return
- - 0 && isShown } - >{content} -
- }).toArray() - } + {isVirtualized ? ( +
+
+ {virtualizer.getVirtualItems().map((vItem) => { + const [name] = definitionEntries[vItem.index] + return ( +
+ {renderModelItem(name)} +
+ ) + })} +
+
+ ) : ( + definitionEntries.map(([name]) => + renderModelItem(name, `models-section-${name}`) + ) + )}
- } + ) +} + +Models.propTypes = { + getComponent: PropTypes.func, + specSelectors: PropTypes.object, + specActions: PropTypes.object.isRequired, + layoutSelectors: PropTypes.object, + layoutActions: PropTypes.object, + getConfigs: PropTypes.func.isRequired, } + +export default Models diff --git a/src/core/utils/index.js b/src/core/utils/index.js index 21e8c34b03c..3e02d3b7605 100644 --- a/src/core/utils/index.js +++ b/src/core/utils/index.js @@ -26,6 +26,8 @@ import getParameterSchema from "core/utils/get-parameter-schema" const DEFAULT_RESPONSE_KEY = "default" +export const VIRTUALIZE_MODELS_THRESHOLD = 100 + export const isImmutable = (maybe) => Im.Iterable.isIterable(maybe) export const immutableToJS = (value) => isImmutable(value) ? value.toJS() : value diff --git a/src/style/_models.scss b/src/style/_models.scss index d8a9b98fb22..2d11daf1bc7 100644 --- a/src/style/_models.scss +++ b/src/style/_models.scss @@ -191,6 +191,11 @@ section.models { &.is-open { padding: 0 0 20px; + + &.models--virtualized { + padding-bottom: 0; + } + h4 { margin: 0 0 5px 0; @@ -267,6 +272,13 @@ section.models { } } + .models-scroll { + max-height: min(60vh, 800px); + overflow-y: auto; + padding-top: 20px; + padding-bottom: 5px; + } + .model-box { background: none; diff --git a/test/e2e-cypress/e2e/features/models-virtualization.cy.js b/test/e2e-cypress/e2e/features/models-virtualization.cy.js new file mode 100644 index 00000000000..c6532d48dba --- /dev/null +++ b/test/e2e-cypress/e2e/features/models-virtualization.cy.js @@ -0,0 +1,76 @@ +/** + * @prettier + */ + +describe("Models list virtualization", () => { + describe("legacy path — below threshold (OpenAPI 2.0)", () => { + it("renders all model-container elements without a scroll wrapper", () => { + cy.visit("/?url=/documents/features/models.swagger.yaml") + cy.get(".models-scroll").should("not.exist") + cy.get(".model-container").should("have.length", 3) + }) + }) + + describe("virtualized path — above threshold (OpenAPI 2.0)", () => { + const baseUrl = "/?url=/documents/perf/many-schemas.swagger.yaml" + + it("renders a scroll wrapper and mounts only a windowed subset", () => { + cy.visit(baseUrl) + cy.get(".models-scroll").should("exist") + cy.get(".model-container").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.get("#model-PerfModel001").should("exist") + + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = $scroll[0].scrollHeight + cy.wait(200) + cy.get("#model-PerfModel001").should("not.exist") + cy.get("#model-PerfModel240").should("exist") + cy.get(".model-container").should("have.length.lessThan", 240) + }) + }) + + it("expanded model state is preserved when scrolled out of view and back", () => { + cy.visit(baseUrl) + // expand first visible model + cy.get(".models-scroll").should("exist") + cy.get("#model-PerfModel001 span.inner-object").should("not.exist") + cy.get("#model-PerfModel001 .model-box-control").click() + cy.get("#model-PerfModel001 span.inner-object").should("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.get("#model-PerfModel001 span.inner-object").should("exist") + }) + }) + + describe("virtualized path — above threshold (OpenAPI 3.0)", () => { + it("renders a scroll wrapper and mounts only a windowed subset", () => { + cy.visit("/?url=/documents/perf/many-schemas.openapi.yaml") + cy.get(".models-scroll").should("exist") + cy.get(".model-container").should("have.length.lessThan", 240) + }) + }) +}) diff --git a/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js b/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js index b70c9905f73..a2c3bc5e9ba 100644 --- a/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js +++ b/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js @@ -5,6 +5,10 @@ describe("OpenAPI 3.0 complex spec with allOf and nested references", () => { it("should render nested references", () => { cy.visit("/?url=/documents/features/oas3-complex-spec.json").then(() => { + // Virtualized path - scroll the list to bring the target model into view. + // Scroll position = index * estimateSize = 196 * 71 + // Update estimateSize value if it changes in Models component. + cy.get(".models-scroll").scrollTo(0, 196 * 71) cy.get( "[id='model-com.sap.ctsm.backend.core.api.study.v1.StudyAPIv1.StudyTreatments-create']" ) diff --git a/test/unit/core/plugins/json-schema-5/components/models.jsx b/test/unit/core/plugins/json-schema-5/components/models.jsx index 3203f486376..8b8daba82f4 100644 --- a/test/unit/core/plugins/json-schema-5/components/models.jsx +++ b/test/unit/core/plugins/json-schema-5/components/models.jsx @@ -1,54 +1,173 @@ +/** + * @prettier + */ import React from "react" -import { shallow } from "enzyme" +import { mount } from "enzyme" import { fromJS, Map } from "immutable" import Models from "core/plugins/json-schema-5/components/models" -import ModelCollapse from "core/plugins/json-schema-5/components/model-collapse" -import ModelComponent from "core/plugins/json-schema-5/components/model-wrapper" -describe("", function(){ +jest.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: () => ({ + getVirtualItems: () => [], + getTotalSize: () => 0, + measureElement: () => {}, + }), +})) + +describe("", function () { const dummyComponent = () => null - // Given - let components = { - Collapse: ModelCollapse, - ModelWrapper: ModelComponent, + /* eslint-disable react/prop-types */ + const DummyComponentWithChildren = ({ children }) =>
{children}
+ + const makeComponents = (overrides = {}) => ({ + Collapse: DummyComponentWithChildren, + ModelWrapper: dummyComponent, JumpToPath: dummyComponent, - } - let props = { - getComponent: (c) => { - return components[c] - }, + ModelCollapse: DummyComponentWithChildren, + ArrowUpIcon: dummyComponent, + ArrowDownIcon: dummyComponent, + ...overrides, + }) + + const makeProps = (overrides = {}) => ({ + getComponent: (c) => makeComponents()[c] || dummyComponent, specSelectors: { isOAS3: () => false, specJson: () => Map(), - definitions: function() { - return fromJS({ - def1: {}, - def2: {} - }) - }, - specResolvedSubtree: () => {} + definitions: () => fromJS({ def1: {}, def2: {} }), + specResolvedSubtree: () => {}, }, layoutSelectors: { - isShown: jest.fn() + isShown: jest.fn(), + getScrollToVirtualizedSchema: jest.fn(() => null), + }, + layoutActions: { + show: jest.fn(), + readyToScroll: jest.fn(), + }, + specActions: { + requestResolvedSubtree: jest.fn(), }, - layoutActions: {}, getConfigs: () => ({ docExpansion: "list", - defaultModelsExpandDepth: 0 + defaultModelsExpandDepth: 1, + }), + ...overrides, + }) + + it("passes defaultModelsExpandDepth to ModelWrapper", function () { + const ModelWrapperSpy = jest.fn(() => null) + const props = makeProps({ + getComponent: (c) => { + if (c === "ModelWrapper") return ModelWrapperSpy + return makeComponents()[c] || dummyComponent + }, + getConfigs: () => ({ docExpansion: "list", defaultModelsExpandDepth: 2 }), + }) + + mount() + + expect(ModelWrapperSpy).toHaveBeenCalled() + + const receivedProps = ModelWrapperSpy.mock.calls[0][0] + + expect(receivedProps.expandDepth).toBe(2) + }) + + it("returns null when defaultModelsExpandDepth < 0", function () { + const props = makeProps({ + getConfigs: () => ({ + docExpansion: "list", + defaultModelsExpandDepth: -1, + }), }) - } + const wrapper = mount() - it("passes defaultModelsExpandDepth to ModelWrapper", function(){ - // When - let wrapper = shallow() + expect(wrapper.html()).toBeNull() + }) - // Then should render tabs - expect(wrapper.find("ModelCollapse").length).toEqual(1) - expect(wrapper.find("ModelWrapper").length).toBeGreaterThan(0) - wrapper.find("ModelComponent").forEach((modelWrapper) => { - expect(modelWrapper.props().expandDepth).toBe(0) + it("returns null when there are no definitions", function () { + const props = makeProps({ + specSelectors: { + isOAS3: () => false, + specJson: () => Map(), + definitions: () => fromJS({}), + specResolvedSubtree: () => undefined, + }, }) + + const wrapper = mount() + + expect(wrapper.html()).toBeNull() }) + it("uses docExpansion config to determine default open state", function () { + const isShownMock = jest.fn(() => false) + const props = makeProps({ + layoutSelectors: { + isShown: isShownMock, + getScrollToVirtualizedSchema: jest.fn(() => null), + }, + getConfigs: () => ({ docExpansion: "none", defaultModelsExpandDepth: 1 }), + }) + + mount() + + expect(isShownMock).toHaveBeenCalledWith(["definitions"], false) + }) + + it("uses non-virtualized path when definition count is below threshold", function () { + const props = makeProps() + + const wrapper = mount() + + expect(wrapper.find(".models-scroll").length).toEqual(0) + expect(wrapper.find(".model-container").length).toEqual(2) + }) + + it("uses non-virtualized path when definition count is just below threshold (99)", function () { + const defs = {} + + for (let i = 0; i < 99; i++) { + defs[`Schema${i}`] = {} + } + + const props = makeProps({ + specSelectors: { + isOAS3: () => false, + specJson: () => Map(), + definitions: () => fromJS(defs), + specResolvedSubtree: () => undefined, + }, + }) + + const wrapper = mount() + + expect(wrapper.find(".models-scroll").length).toEqual(0) + expect(wrapper.find(".model-container").length).toEqual(99) + }) + + it("uses virtualized path when definition count is at threshold (100)", function () { + const defs = {} + + for (let i = 0; i < 100; i++) { + defs[`Schema${i}`] = {} + } + + const props = makeProps({ + specSelectors: { + isOAS3: () => false, + specJson: () => Map(), + definitions: () => fromJS(defs), + specResolvedSubtree: () => undefined, + }, + }) + + const wrapper = mount() + + expect(wrapper.find(".models-scroll").length).toEqual(1) + // virtualizer mock returns no items, so no .model-container should be in DOM + expect(wrapper.find(".model-container").length).toEqual(0) + }) }) diff --git a/test/unit/jest-shim.js b/test/unit/jest-shim.js index 90496ee42bd..b3bed5ca225 100644 --- a/test/unit/jest-shim.js +++ b/test/unit/jest-shim.js @@ -2,3 +2,9 @@ import { TextDecoder, TextEncoder } from "node:util" global.TextDecoder = TextDecoder global.TextEncoder = TextEncoder + +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} From 2d67de58e2e2819c2e50e842d30af818c8c6748a Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Fri, 21 Aug 2026 12:24:57 +0200 Subject: [PATCH 2/6] fix: preserve expanded state for nested properties --- .../json-schema-5/components/array-model.jsx | 23 ++++++++++++-- .../json-schema-5/components/object-model.jsx | 30 +++++++++++++++---- .../components/primitive-model.jsx | 22 +++++++++++++- .../e2e/features/models-virtualization.cy.js | 24 +++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/core/plugins/json-schema-5/components/array-model.jsx b/src/core/plugins/json-schema-5/components/array-model.jsx index dd04208950a..283304f495b 100644 --- a/src/core/plugins/json-schema-5/components/array-model.jsx +++ b/src/core/plugins/json-schema-5/components/array-model.jsx @@ -19,10 +19,24 @@ export default class ArrayModel extends Component { depth: PropTypes.number, includeReadOnly: PropTypes.bool, includeWriteOnly: PropTypes.bool, + layoutActions: PropTypes.shape({ + show: PropTypes.func.isRequired, + }), + layoutSelectors: PropTypes.shape({ + isShown: PropTypes.func.isRequired, + }), + } + + handleToggle = (modelName, shown) => { + const { layoutActions, specPath } = this.props + layoutActions?.show(specPath.toJS(), shown) } render(){ - let { getComponent, getConfigs, schema, depth, expandDepth, name, displayName, specPath } = this.props + let { getComponent, getConfigs, schema, depth, expandDepth, name, displayName, specPath, layoutSelectors } = this.props + const defaultExpanded = depth <= expandDepth + const isExpanded = layoutSelectors?.isShown(specPath.toJS(), defaultExpanded) ?? defaultExpanded + let description = schema.get("description") let items = schema.get("items") let title = schema.get("title") || displayName || name @@ -48,7 +62,12 @@ export default class ArrayModel extends Component { */ return - + [ { properties.size ? properties.entrySeq().map( ( [ key, v ] ) => ) : null diff --git a/src/core/plugins/json-schema-5/components/object-model.jsx b/src/core/plugins/json-schema-5/components/object-model.jsx index b6b9f6ebf20..fd3e1675abb 100644 --- a/src/core/plugins/json-schema-5/components/object-model.jsx +++ b/src/core/plugins/json-schema-5/components/object-model.jsx @@ -29,6 +29,17 @@ export default class ObjectModel extends Component { specPath: ImPropTypes.list.isRequired, includeReadOnly: PropTypes.bool, includeWriteOnly: PropTypes.bool, + layoutActions: PropTypes.shape({ + show: PropTypes.func.isRequired, + }), + layoutSelectors: PropTypes.shape({ + isShown: PropTypes.func.isRequired, + }), + } + + handleToggle = (modelName, shown) => { + const { layoutActions, specPath } = this.props + layoutActions?.show(specPath.toJS(), shown) } render() { @@ -40,15 +51,22 @@ export default class ObjectModel extends Component { getComponent, getConfigs, depth, - onToggle, - expanded, specPath, ...otherProps } = this.props - let { specSelectors, expandDepth, includeReadOnly, includeWriteOnly } = - otherProps + let { + specSelectors, + expandDepth, + includeReadOnly, + includeWriteOnly, + layoutSelectors, + } = otherProps const { isOAS3 } = specSelectors const isEmbedded = depth > 2 || (depth === 2 && specPath.last() !== "items") + const defaultExpanded = depth <= expandDepth + const isExpanded = + layoutSelectors?.isShown(specPath.toJS(), defaultExpanded) ?? + defaultExpanded if (!schema) { return null @@ -119,8 +137,8 @@ export default class ObjectModel extends Component { {braceOpen} diff --git a/src/core/plugins/json-schema-5/components/primitive-model.jsx b/src/core/plugins/json-schema-5/components/primitive-model.jsx index 592b97716fb..ccd606cf545 100644 --- a/src/core/plugins/json-schema-5/components/primitive-model.jsx +++ b/src/core/plugins/json-schema-5/components/primitive-model.jsx @@ -3,6 +3,7 @@ */ import React, { Component } from "react" import PropTypes from "prop-types" +import ImPropTypes from "react-immutable-proptypes" import { getExtensions } from "core/utils" import { sanitizeUrl } from "core/utils/url" @@ -17,6 +18,18 @@ export default class Primitive extends Component { displayName: PropTypes.string, depth: PropTypes.number, expandDepth: PropTypes.number, + specPath: ImPropTypes.list, + layoutActions: PropTypes.shape({ + show: PropTypes.func.isRequired, + }), + layoutSelectors: PropTypes.shape({ + isShown: PropTypes.func.isRequired, + }), + } + + handleToggle = (modelName, shown) => { + const { layoutActions, specPath } = this.props + layoutActions?.show(specPath?.toJS(), shown) } render() { @@ -28,9 +41,15 @@ export default class Primitive extends Component { displayName, depth, expandDepth, + specPath, + layoutSelectors, } = this.props const { showExtensions } = getConfigs() + const defaultExpanded = depth <= expandDepth + const isExpanded = + layoutSelectors?.isShown(specPath?.toJS(), defaultExpanded) ?? + defaultExpanded if (!schema || !schema.get) { // don't render if schema isn't correctly formed @@ -78,7 +97,8 @@ export default class Primitive extends Component { diff --git a/test/e2e-cypress/e2e/features/models-virtualization.cy.js b/test/e2e-cypress/e2e/features/models-virtualization.cy.js index c6532d48dba..8d2f75784eb 100644 --- a/test/e2e-cypress/e2e/features/models-virtualization.cy.js +++ b/test/e2e-cypress/e2e/features/models-virtualization.cy.js @@ -64,6 +64,30 @@ describe("Models list virtualization", () => { cy.get("#model-PerfModel001 span.inner-object").should("exist") }) + + it("expanded nested property state is preserved when scrolled out of view and back", () => { + cy.visit(baseUrl) + cy.get("#model-PerfModel001 .model-box-control").first().click() + cy.get("#model-PerfModel001 span.inner-object").should("exist") + cy.get("#model-PerfModel001 span.inner-object .prop-type").should( + "not.exist" + ) + cy.get("#model-PerfModel001 span.inner-object .model-box-control") + .first() + .click() + cy.get("#model-PerfModel001 span.inner-object .prop-type").should("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.get("#model-PerfModel001 span.inner-object .prop-type").should("exist") + }) }) describe("virtualized path — above threshold (OpenAPI 3.0)", () => { From 0ee3eace423a43ff9138781aa2c8dfc6e7d0e014 Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Mon, 24 Aug 2026 10:58:58 +0200 Subject: [PATCH 3/6] fix: memoize model items --- .../json-schema-5/components/models.jsx | 208 ++++++++++++------ 1 file changed, 139 insertions(+), 69 deletions(-) diff --git a/src/core/plugins/json-schema-5/components/models.jsx b/src/core/plugins/json-schema-5/components/models.jsx index 373bf186c00..073cb10836d 100644 --- a/src/core/plugins/json-schema-5/components/models.jsx +++ b/src/core/plugins/json-schema-5/components/models.jsx @@ -4,11 +4,126 @@ import React, { useRef, useMemo, useCallback, useEffect } from "react" import Im, { Map } from "immutable" import PropTypes from "prop-types" +import ImPropTypes from "react-immutable-proptypes" import { useVirtualizer } from "@tanstack/react-virtual" import { VIRTUALIZE_MODELS_THRESHOLD } from "core/utils" /* eslint-disable react/jsx-no-bind */ +const ModelItem = React.memo( + ({ + name, + schema, + rawSchema, + isShown, + specPathBase, + defaultModelsExpandDepth, + getComponent, + specSelectors, + getConfigs, + layoutSelectors, + layoutActions, + specActions, + getCollapsedContent, + handleToggle, + onLoadModel, + }) => { + const fullPath = [...specPathBase, name] + const specPath = Im.List(fullPath) + + const displayName = schema.get("title") || rawSchema.get("title") || name + + if (isShown && schema.size === 0 && rawSchema.size > 0) { + // Firing an action in a container render is not great, + // but it works for now. + specActions.requestResolvedSubtree(fullPath) + } + + const ModelWrapper = getComponent("ModelWrapper") + const ModelCollapse = getComponent("ModelCollapse") + const JumpToPath = getComponent("JumpToPath", true) + + const content = ( + + ) + + const title = ( + + {displayName} + + ) + + return ( +
+ + + + 0 && isShown} + > + {content} + +
+ ) + }, + (prev, next) => + prev.name === next.name && + prev.isShown === next.isShown && + prev.defaultModelsExpandDepth === next.defaultModelsExpandDepth && + prev.specPathBase === next.specPathBase && + Im.is(prev.schema, next.schema) && + Im.is(prev.rawSchema, next.rawSchema) +) + +ModelItem.displayName = "ModelItem" + +ModelItem.propTypes = { + name: PropTypes.string.isRequired, + schema: ImPropTypes.map.isRequired, + rawSchema: ImPropTypes.map.isRequired, + isShown: PropTypes.bool.isRequired, + specPathBase: PropTypes.arrayOf(PropTypes.string).isRequired, + defaultModelsExpandDepth: PropTypes.number.isRequired, + getComponent: PropTypes.func.isRequired, + specSelectors: PropTypes.object.isRequired, + getConfigs: PropTypes.func.isRequired, + layoutSelectors: PropTypes.object.isRequired, + layoutActions: PropTypes.object.isRequired, + specActions: PropTypes.object.isRequired, + getCollapsedContent: PropTypes.func.isRequired, + handleToggle: PropTypes.func.isRequired, + onLoadModel: PropTypes.func.isRequired, +} + const Models = ({ getComponent, specSelectors, @@ -109,84 +224,35 @@ const Models = ({ defaultModelsExpandDepth > 0 && docExpansion !== "none" ) - const ModelWrapper = getComponent("ModelWrapper") const Collapse = getComponent("Collapse") - const ModelCollapse = getComponent("ModelCollapse") - const JumpToPath = getComponent("JumpToPath", true) const ArrowUpIcon = getComponent("ArrowUpIcon") const ArrowDownIcon = getComponent("ArrowDownIcon") - const renderModelItem = (name, key) => { + const getModelItemProps = (name) => { const fullPath = [...specPathBase, name] - const specPath = Im.List(fullPath) - const schemaValue = specSelectors.specResolvedSubtree(fullPath) const rawSchemaValue = specSelectors.specJson().getIn(fullPath) const schema = Map.isMap(schemaValue) ? schemaValue : Im.Map() const rawSchema = Map.isMap(rawSchemaValue) ? rawSchemaValue : Im.Map() - - const displayName = schema.get("title") || rawSchema.get("title") || name const isShown = layoutSelectors.isShown(fullPath, false) - if (isShown && schema.size === 0 && rawSchema.size > 0) { - // Firing an action in a container render is not great, - // but it works for now. - specActions.requestResolvedSubtree(fullPath) + return { + schema, + rawSchema, + isShown, + specPathBase, + defaultModelsExpandDepth, + getComponent, + specSelectors, + getConfigs, + layoutSelectors, + layoutActions, + specActions, + getCollapsedContent, + handleToggle, + onLoadModel, } - - const content = ( - - ) - - const title = ( - - {displayName} - - ) - - return ( -
- - - - 0 && isShown} - > - {content} - -
- ) } return ( @@ -224,16 +290,20 @@ const Models = ({ ref={virtualizer.measureElement} style={{ paddingBottom: 15 }} > - {renderModelItem(name)} + ) })} ) : ( - definitionEntries.map(([name]) => - renderModelItem(name, `models-section-${name}`) - ) + definitionEntries.map(([name]) => ( + + )) )}
From ddd72338ac766ba2d8496142383bc7bd8f2b35d8 Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Thu, 27 Aug 2026 08:42:15 +0200 Subject: [PATCH 4/6] fix: address code review comments --- .eslintrc.js | 12 +- package-lock.json | 511 +++++++++++------- package.json | 4 +- .../json-schema-5/components/model-item.tsx | 130 +++++ .../json-schema-5/components/models.jsx | 130 +---- src/core/utils/index.js | 1 - src/core/utils/virtualization.ts | 6 + src/style/_models.scss | 4 + .../e2e/features/models-virtualization.cy.js | 2 +- .../features/plugins/oas3/complex-spec.cy.js | 8 +- webpack/dev.js | 2 +- 11 files changed, 499 insertions(+), 311 deletions(-) create mode 100644 src/core/plugins/json-schema-5/components/model-item.tsx create mode 100644 src/core/utils/virtualization.ts diff --git a/.eslintrc.js b/.eslintrc.js index b49fcba314f..b0bef12b195 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,7 +21,17 @@ module.exports = { "plugin:react/recommended", "plugin:prettier/recommended", ], - plugins: ["react", "import", "jest", "prettier"], + plugins: ["react", "import", "jest", "prettier", "@typescript-eslint"], + overrides: [ + { + files: ["**/*.ts", "**/*.tsx"], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + extends: ["plugin:@typescript-eslint/recommended"], + }, + ], settings: { react: { pragma: "React", diff --git a/package-lock.json b/package-lock.json index aff9687596d..c263177425b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@babel/runtime-corejs3": "^7.27.1", "@scarf/scarf": "=1.4.0", - "@tanstack/react-virtual": "=3.14.9", + "@tanstack/react-virtual": "=3.14.10", "base64-js": "^1.5.1", "buffer": "^6.0.3", "classnames": "^2.5.1", @@ -64,6 +64,8 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", "@release-it/conventional-changelog": "=12.0.0", "@svgr/webpack": "=8.1.0", + "@typescript-eslint/eslint-plugin": "=8.67.0", + "@typescript-eslint/parser": "=8.67.0", "autoprefixer": "^10.5.4", "babel-loader": "^9.2.1", "babel-plugin-lodash": "=3.3.4", @@ -3689,7 +3691,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -7671,12 +7675,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.14.9", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", - "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "version": "3.14.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.10.tgz", + "integrity": "sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.17.7" + "@tanstack/virtual-core": "3.17.8" }, "funding": { "type": "github", @@ -7688,9 +7692,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.17.7", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", - "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "version": "3.17.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.8.tgz", + "integrity": "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==", "license": "MIT", "funding": { "type": "github", @@ -8151,15 +8155,137 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", - "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8169,10 +8295,70 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", - "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "dev": true, "license": "MIT", "engines": { @@ -8183,14 +8369,136 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", - "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -13390,167 +13698,6 @@ } } }, - "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", - "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", - "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.66.0", - "@typescript-eslint/tsconfig-utils": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", - "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.66.0", - "@typescript-eslint/types": "^8.66.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", - "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/eslint-plugin-jest/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint-plugin-jest/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/eslint-plugin-jest/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-jest/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/eslint-plugin-jest/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-prettier": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", diff --git a/package.json b/package.json index 90640330a1f..a2cba83c42d 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "dependencies": { "@babel/runtime-corejs3": "^7.27.1", "@scarf/scarf": "=1.4.0", - "@tanstack/react-virtual": "=3.14.9", + "@tanstack/react-virtual": "=3.14.10", "base64-js": "^1.5.1", "buffer": "^6.0.3", "classnames": "^2.5.1", @@ -129,6 +129,8 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", "@release-it/conventional-changelog": "=12.0.0", "@svgr/webpack": "=8.1.0", + "@typescript-eslint/eslint-plugin": "=8.67.0", + "@typescript-eslint/parser": "=8.67.0", "autoprefixer": "^10.5.4", "babel-loader": "^9.2.1", "babel-plugin-lodash": "=3.3.4", diff --git a/src/core/plugins/json-schema-5/components/model-item.tsx b/src/core/plugins/json-schema-5/components/model-item.tsx new file mode 100644 index 00000000000..5727d813dba --- /dev/null +++ b/src/core/plugins/json-schema-5/components/model-item.tsx @@ -0,0 +1,130 @@ +/** + * @prettier + */ +import React from "react" +import Im from "immutable" + +interface ModelItemProps { + name: string + schema: Im.Map + rawSchema: Im.Map + isShown: boolean + specPathBase: string[] + defaultModelsExpandDepth: number + getComponent: ( + componentName: string, + container?: boolean, + config?: object + ) => React.ComponentType> | null + specSelectors: object + getConfigs: () => Record + layoutSelectors: object + layoutActions: object + specActions: { + requestResolvedSubtree: (path: string[]) => void + } + getCollapsedContent: (name: string) => React.ReactNode + handleToggle: (name: string, isExpanded: boolean) => void + onLoadModel: (ref: HTMLElement | null) => void +} + +const ModelItem = React.memo( + ({ + name, + schema, + rawSchema, + isShown, + specPathBase, + defaultModelsExpandDepth, + getComponent, + specSelectors, + getConfigs, + layoutSelectors, + layoutActions, + specActions, + getCollapsedContent, + handleToggle, + onLoadModel, + }: ModelItemProps) => { + const fullPath = [...specPathBase, name] + const specPath = Im.List(fullPath) + + const displayName = + (schema.get("title") as string | undefined) || + (rawSchema.get("title") as string | undefined) || + name + + if (isShown && schema.size === 0 && rawSchema.size > 0) { + // Firing an action in a container render is not great, + // but it works for now. + specActions.requestResolvedSubtree(fullPath) + } + + const ModelWrapper = getComponent("ModelWrapper") + const ModelCollapse = getComponent("ModelCollapse") + const JumpToPath = getComponent("JumpToPath", true) + + const content = ( + ()} + displayName={displayName} + fullPath={fullPath} + specPath={specPath} + getComponent={getComponent} + specSelectors={specSelectors} + getConfigs={getConfigs} + layoutSelectors={layoutSelectors} + layoutActions={layoutActions} + includeReadOnly={true} + includeWriteOnly={true} + /> + ) + + const title = ( + + {displayName} + + ) + + return ( +
+ + + + 0 && isShown} + > + {content} + +
+ ) + }, + (prev: ModelItemProps, next: ModelItemProps) => + prev.name === next.name && + prev.isShown === next.isShown && + prev.defaultModelsExpandDepth === next.defaultModelsExpandDepth && + prev.specPathBase === next.specPathBase && + Im.is(prev.schema, next.schema) && + Im.is(prev.rawSchema, next.rawSchema) +) + +ModelItem.displayName = "ModelItem" + +export default ModelItem diff --git a/src/core/plugins/json-schema-5/components/models.jsx b/src/core/plugins/json-schema-5/components/models.jsx index 073cb10836d..253a1aa4139 100644 --- a/src/core/plugins/json-schema-5/components/models.jsx +++ b/src/core/plugins/json-schema-5/components/models.jsx @@ -4,125 +4,13 @@ import React, { useRef, useMemo, useCallback, useEffect } from "react" import Im, { Map } from "immutable" import PropTypes from "prop-types" -import ImPropTypes from "react-immutable-proptypes" import { useVirtualizer } from "@tanstack/react-virtual" -import { VIRTUALIZE_MODELS_THRESHOLD } from "core/utils" - -/* eslint-disable react/jsx-no-bind */ - -const ModelItem = React.memo( - ({ - name, - schema, - rawSchema, - isShown, - specPathBase, - defaultModelsExpandDepth, - getComponent, - specSelectors, - getConfigs, - layoutSelectors, - layoutActions, - specActions, - getCollapsedContent, - handleToggle, - onLoadModel, - }) => { - const fullPath = [...specPathBase, name] - const specPath = Im.List(fullPath) - - const displayName = schema.get("title") || rawSchema.get("title") || name - - if (isShown && schema.size === 0 && rawSchema.size > 0) { - // Firing an action in a container render is not great, - // but it works for now. - specActions.requestResolvedSubtree(fullPath) - } - - const ModelWrapper = getComponent("ModelWrapper") - const ModelCollapse = getComponent("ModelCollapse") - const JumpToPath = getComponent("JumpToPath", true) - - const content = ( - - ) - - const title = ( - - {displayName} - - ) - - return ( -
- - - - 0 && isShown} - > - {content} - -
- ) - }, - (prev, next) => - prev.name === next.name && - prev.isShown === next.isShown && - prev.defaultModelsExpandDepth === next.defaultModelsExpandDepth && - prev.specPathBase === next.specPathBase && - Im.is(prev.schema, next.schema) && - Im.is(prev.rawSchema, next.rawSchema) -) - -ModelItem.displayName = "ModelItem" - -ModelItem.propTypes = { - name: PropTypes.string.isRequired, - schema: ImPropTypes.map.isRequired, - rawSchema: ImPropTypes.map.isRequired, - isShown: PropTypes.bool.isRequired, - specPathBase: PropTypes.arrayOf(PropTypes.string).isRequired, - defaultModelsExpandDepth: PropTypes.number.isRequired, - getComponent: PropTypes.func.isRequired, - specSelectors: PropTypes.object.isRequired, - getConfigs: PropTypes.func.isRequired, - layoutSelectors: PropTypes.object.isRequired, - layoutActions: PropTypes.object.isRequired, - specActions: PropTypes.object.isRequired, - getCollapsedContent: PropTypes.func.isRequired, - handleToggle: PropTypes.func.isRequired, - onLoadModel: PropTypes.func.isRequired, -} +import { + VIRTUALIZE_MODELS_THRESHOLD, + VIRTUALIZE_MODELS_ESTIMATE_SIZE, + VIRTUALIZE_MODELS_OVERSCAN, +} from "core/utils/virtualization" +import ModelItem from "./model-item" const Models = ({ getComponent, @@ -183,8 +71,8 @@ const Models = ({ const virtualizer = useVirtualizer({ count: definitionEntries.length, getScrollElement: () => parentRef.current, - estimateSize: () => 71, - overscan: 5, + estimateSize: () => VIRTUALIZE_MODELS_ESTIMATE_SIZE, + overscan: VIRTUALIZE_MODELS_OVERSCAN, getItemKey: (index) => `models-section-${definitionEntries[index][0]}`, initialMeasurementsCache: measurementsCache.current, onChange: (instance) => { @@ -288,7 +176,7 @@ const Models = ({ key={vItem.key} data-index={vItem.index} ref={virtualizer.measureElement} - style={{ paddingBottom: 15 }} + className="models-virtual-item" > diff --git a/src/core/utils/index.js b/src/core/utils/index.js index 3e02d3b7605..61a07840484 100644 --- a/src/core/utils/index.js +++ b/src/core/utils/index.js @@ -26,7 +26,6 @@ import getParameterSchema from "core/utils/get-parameter-schema" const DEFAULT_RESPONSE_KEY = "default" -export const VIRTUALIZE_MODELS_THRESHOLD = 100 export const isImmutable = (maybe) => Im.Iterable.isIterable(maybe) diff --git a/src/core/utils/virtualization.ts b/src/core/utils/virtualization.ts new file mode 100644 index 00000000000..b9590ddc700 --- /dev/null +++ b/src/core/utils/virtualization.ts @@ -0,0 +1,6 @@ +/** + * @prettier + */ +export const VIRTUALIZE_MODELS_THRESHOLD = 100 +export const VIRTUALIZE_MODELS_ESTIMATE_SIZE = 71 +export const VIRTUALIZE_MODELS_OVERSCAN = 5 diff --git a/src/style/_models.scss b/src/style/_models.scss index 2d11daf1bc7..4e485eb1152 100644 --- a/src/style/_models.scss +++ b/src/style/_models.scss @@ -277,6 +277,10 @@ section.models { overflow-y: auto; padding-top: 20px; padding-bottom: 5px; + + .models-virtual-item { + padding-bottom: 15px; + } } .model-box { diff --git a/test/e2e-cypress/e2e/features/models-virtualization.cy.js b/test/e2e-cypress/e2e/features/models-virtualization.cy.js index 8d2f75784eb..e28448d2a8a 100644 --- a/test/e2e-cypress/e2e/features/models-virtualization.cy.js +++ b/test/e2e-cypress/e2e/features/models-virtualization.cy.js @@ -3,7 +3,7 @@ */ describe("Models list virtualization", () => { - describe("legacy path — below threshold (OpenAPI 2.0)", () => { + describe("non-virtualized path — below threshold (OpenAPI 2.0)", () => { it("renders all model-container elements without a scroll wrapper", () => { cy.visit("/?url=/documents/features/models.swagger.yaml") cy.get(".models-scroll").should("not.exist") diff --git a/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js b/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js index a2c3bc5e9ba..faa0b3da92c 100644 --- a/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js +++ b/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js @@ -1,14 +1,16 @@ /** * @prettier */ +import { VIRTUALIZE_MODELS_ESTIMATE_SIZE } from "src/core/utils/virtualization" describe("OpenAPI 3.0 complex spec with allOf and nested references", () => { it("should render nested references", () => { cy.visit("/?url=/documents/features/oas3-complex-spec.json").then(() => { // Virtualized path - scroll the list to bring the target model into view. - // Scroll position = index * estimateSize = 196 * 71 - // Update estimateSize value if it changes in Models component. - cy.get(".models-scroll").scrollTo(0, 196 * 71) + cy.get(".models-scroll").scrollTo( + 0, + 196 * VIRTUALIZE_MODELS_ESTIMATE_SIZE + ) cy.get( "[id='model-com.sap.ctsm.backend.core.api.study.v1.StudyAPIv1.StudyTreatments-create']" ) diff --git a/webpack/dev.js b/webpack/dev.js index 4d7fd4b1d18..052894eff15 100644 --- a/webpack/dev.js +++ b/webpack/dev.js @@ -67,7 +67,7 @@ const devConfig = configBuilder( module: { rules: [ { - test: /\.jsx?$/, + test: /\.[jt]sx?$/, include: [ path.join(projectBasePath, "src"), path.join(projectBasePath, "node_modules", "object-assign-deep"), From d87e16f53d4b31009bce4c506d01d73297da2a03 Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Thu, 27 Aug 2026 13:29:47 +0200 Subject: [PATCH 5/6] fix: use callback for models expansion fn --- src/core/plugins/json-schema-5/components/models.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/plugins/json-schema-5/components/models.jsx b/src/core/plugins/json-schema-5/components/models.jsx index ff04622522d..b16c23e3913 100644 --- a/src/core/plugins/json-schema-5/components/models.jsx +++ b/src/core/plugins/json-schema-5/components/models.jsx @@ -112,6 +112,10 @@ const Models = ({ defaultModelsExpandDepth > 0 && docExpansion !== "none" ) + const handleModelsExpand = useCallback(() => { + layoutActions.show(specPathBase, !showModels) + }, [layoutActions, specPathBase, showModels]) + const Collapse = getComponent("Collapse") const ArrowUpIcon = getComponent("ArrowUpIcon") const ArrowDownIcon = getComponent("ArrowDownIcon") @@ -152,7 +156,7 @@ const Models = ({ - + + } + + + + ) + + // 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-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/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/plugins/oas31/components/models/models.jsx b/src/core/plugins/oas31/components/models/models.jsx index 54e9d2b211a..edffc8152d2 100644 --- a/src/core/plugins/oas31/components/models/models.jsx +++ b/src/core/plugins/oas31/components/models/models.jsx @@ -1,9 +1,18 @@ /** * @prettier */ -import React, { 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" +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 Models = ({ specActions, @@ -15,30 +24,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 = useMemo(() => Object.entries(schemas), [schemas]) + + const parentRef = useRef(null) + const measurementsCache = useRef([]) + + const virtualizer = useVirtualizer({ + count: schemaEntries.length, + getScrollElement: () => parentRef.current, + estimateSize: () => VIRTUALIZE_JSON_SCHEMA_2020_12_ESTIMATE_SIZE, + overscan: VIRTUALIZE_MODELS_OVERSCAN, + 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 +71,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 +106,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/src/core/plugins/oas31/components/models/schema-item.tsx b/src/core/plugins/oas31/components/models/schema-item.tsx new file mode 100644 index 00000000000..11372ba5858 --- /dev/null +++ b/src/core/plugins/oas31/components/models/schema-item.tsx @@ -0,0 +1,78 @@ +/** + * @prettier + */ +import React, { useCallback } from "react" + +const SCHEMAS_PATH = ["components", "schemas"] + +interface SchemaItemProps { + schemaName: string + schema: object + name: string + specSelectors: { + specResolvedSubtree: (path: string[]) => 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..302085dd2e1 100644 --- a/src/core/utils/virtualization.ts +++ b/src/core/utils/virtualization.ts @@ -3,4 +3,10 @@ */ 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/models-virtualization.cy.js b/test/e2e-cypress/e2e/features/models-virtualization.cy.js index e28448d2a8a..9e76a67aa48 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("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") + 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/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/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/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, diff --git a/test/unit/core/plugins/oas31/components/models.tsx b/test/unit/core/plugins/oas31/components/models.tsx new file mode 100644 index 00000000000..28ca49e6b62 --- /dev/null +++ b/test/unit/core/plugins/oas31/components/models.tsx @@ -0,0 +1,138 @@ +/** + * @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 + const DummyComponentWithChildren = ({ + children, + }: { + children: React.ReactNode + }) =>
{children}
+ + const makeProps = (overrides = {}) => ({ + 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, + } + 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: Record = {} + 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: Record = {} + 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) + }) +}) 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" + ] +}