From 90157c1a408e9a797ab1669fec9148cb2101b920 Mon Sep 17 00:00:00 2001 From: Josh Ponelat Date: Fri, 1 Jun 2018 22:19:44 +0200 Subject: [PATCH] Refactor deep-linking, in the process extracted out OperationsTag (#4349) * add configsActions.loaded hook * add OperationTag to hold Operations * fix test for operations * refactor deep-linking plugin --- src/core/components/operation-tag.jsx | 108 +++++++++++ src/core/components/operations.jsx | 132 +++++-------- src/core/index.js | 13 +- src/core/plugins/configs/actions.js | 4 + src/core/plugins/deep-linking/index.js | 31 +-- .../deep-linking/layout-wrap-actions.js | 37 ---- src/core/plugins/deep-linking/layout.js | 181 ++++++++++++++++++ .../deep-linking/operation-tag-wrapper.jsx | 25 +++ .../deep-linking/operation-wrapper.jsx | 26 +++ .../plugins/deep-linking/spec-wrap-actions.js | 64 ------- src/core/presets/base.js | 2 + test/components/operations.js | 5 +- 12 files changed, 416 insertions(+), 212 deletions(-) create mode 100644 src/core/components/operation-tag.jsx delete mode 100644 src/core/plugins/deep-linking/layout-wrap-actions.js create mode 100644 src/core/plugins/deep-linking/layout.js create mode 100644 src/core/plugins/deep-linking/operation-tag-wrapper.jsx create mode 100644 src/core/plugins/deep-linking/operation-wrapper.jsx delete mode 100644 src/core/plugins/deep-linking/spec-wrap-actions.js diff --git a/src/core/components/operation-tag.jsx b/src/core/components/operation-tag.jsx new file mode 100644 index 00000000..0c4dc8b6 --- /dev/null +++ b/src/core/components/operation-tag.jsx @@ -0,0 +1,108 @@ +import React from "react" +import PropTypes from "prop-types" +import ImPropTypes from "react-immutable-proptypes" +import Im from "immutable" +import { createDeepLinkPath, sanitizeUrl } from "core/utils" + +export default class OperationTag extends React.Component { + + static defaultProps = { + tagObj: Im.fromJS({}), + tag: "", + } + + static propTypes = { + tagObj: ImPropTypes.map.isRequired, + tag: PropTypes.string.isRequired, + + layoutSelectors: PropTypes.object.isRequired, + layoutActions: PropTypes.object.isRequired, + + getConfigs: PropTypes.func.isRequired, + getComponent: PropTypes.func.isRequired, + + children: PropTypes.element, + } + + render() { + const { + tagObj, + tag, + children, + + layoutSelectors, + layoutActions, + getConfigs, + getComponent, + } = this.props + + let { + docExpansion, + deepLinking, + } = getConfigs() + + const isDeepLinkingEnabled = deepLinking && deepLinking !== "false" + + const Collapse = getComponent("Collapse") + const Markdown = getComponent("Markdown") + const DeepLink = getComponent("DeepLink") + + let tagDescription = tagObj.getIn(["tagDetails", "description"], null) + let tagExternalDocsDescription = tagObj.getIn(["tagDetails", "externalDocs", "description"]) + let tagExternalDocsUrl = tagObj.getIn(["tagDetails", "externalDocs", "url"]) + + let isShownKey = ["operations-tag", createDeepLinkPath(tag)] + let showTag = layoutSelectors.isShown(isShownKey, docExpansion === "full" || docExpansion === "list") + + return ( +
+ +

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

+ + + {children} + +
+ ) + } +} diff --git a/src/core/components/operations.jsx b/src/core/components/operations.jsx index 6bc4030e..68244177 100644 --- a/src/core/components/operations.jsx +++ b/src/core/components/operations.jsx @@ -1,7 +1,6 @@ import React from "react" import PropTypes from "prop-types" import Im from "immutable" -import { createDeepLinkPath, sanitizeUrl } from "core/utils" const SWAGGER2_OPERATION_METHODS = [ "get", "put", "post", "delete", "options", "head", "patch" @@ -38,18 +37,12 @@ export default class Operations extends React.Component { let taggedOps = specSelectors.taggedOperations() const OperationContainer = getComponent("OperationContainer", true) - const Collapse = getComponent("Collapse") - const Markdown = getComponent("Markdown") - const DeepLink = getComponent("DeepLink") + const OperationTag = getComponent("OperationTag") let { - docExpansion, maxDisplayedTags, - deepLinking } = getConfigs() - const isDeepLinkingEnabled = deepLinking && deepLinking !== "false" - let filter = layoutSelectors.currentFilter() if (filter) { @@ -66,88 +59,49 @@ export default class Operations extends React.Component {
{ taggedOps.map( (tagObj, tag) => { - let operations = tagObj.get("operations") - let tagDescription = tagObj.getIn(["tagDetails", "description"], null) - let tagExternalDocsDescription = tagObj.getIn(["tagDetails", "externalDocs", "description"]) - let tagExternalDocsUrl = tagObj.getIn(["tagDetails", "externalDocs", "url"]) - - let isShownKey = ["operations-tag", createDeepLinkPath(tag)] - let showTag = layoutSelectors.isShown(isShownKey, docExpansion === "full" || docExpansion === "list") - + const operations = tagObj.get("operations") return ( -
- -

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

- - - { - operations.map( op => { - const path = op.get("path") - const method = op.get("method") - const specPath = Im.List(["paths", path, method]) - - - // FIXME: (someday) this logic should probably be in a selector, - // but doing so would require further opening up - // selectors to the plugin system, to allow for dynamic - // overriding of low-level selectors that other selectors - // rely on. --KS, 12/17 - const validMethods = specSelectors.isOAS3() ? - OAS3_OPERATION_METHODS : SWAGGER2_OPERATION_METHODS - - if(validMethods.indexOf(method) === -1) { - return null - } - - return - }).toArray() - } - -
- ) + + { + operations.map( op => { + const path = op.get("path") + const method = op.get("method") + const specPath = Im.List(["paths", path, method]) + + + // FIXME: (someday) this logic should probably be in a selector, + // but doing so would require further opening up + // selectors to the plugin system, to allow for dynamic + // overriding of low-level selectors that other selectors + // rely on. --KS, 12/17 + const validMethods = specSelectors.isOAS3() ? + OAS3_OPERATION_METHODS : SWAGGER2_OPERATION_METHODS + + if(validMethods.indexOf(method) === -1) { + return null + } + + return + }).toArray() + } + + + + ) }).toArray() } diff --git a/src/core/index.js b/src/core/index.js index f4c88d92..c839ca61 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -27,7 +27,7 @@ module.exports = function SwaggerUI(opts) { const defaults = { // Some general settings, that we floated to the top - dom_id: null, + dom_id: null, // eslint-disable-line camelcase domNode: null, spec: {}, url: "", @@ -131,10 +131,6 @@ module.exports = function SwaggerUI(opts) { var system = store.getSystem() const downloadSpec = (fetchedConfig) => { - if(typeof constructorConfig !== "object") { - return system - } - let localConfig = system.specSelectors.getLocalConfig ? system.specSelectors.getLocalConfig() : {} let mergedConfig = deepExtend({}, localConfig, constructorConfig, fetchedConfig || {}, queryConfig) @@ -144,6 +140,7 @@ module.exports = function SwaggerUI(opts) { } store.setConfigs(mergedConfig) + system.configsActions.loaded() if (fetchedConfig !== null) { if (!queryConfig.url && typeof mergedConfig.spec === "object" && Object.keys(mergedConfig.spec).length) { @@ -171,15 +168,17 @@ module.exports = function SwaggerUI(opts) { return system } - let configUrl = queryConfig.config || constructorConfig.configUrl + const configUrl = queryConfig.config || constructorConfig.configUrl - if (!configUrl || !system.specActions.getConfigByUrl || system.specActions.getConfigByUrl && !system.specActions.getConfigByUrl({ + if (!configUrl || !system.specActions || !system.specActions.getConfigByUrl || system.specActions.getConfigByUrl && !system.specActions.getConfigByUrl({ url: configUrl, loadRemoteConfig: true, requestInterceptor: constructorConfig.requestInterceptor, responseInterceptor: constructorConfig.responseInterceptor, }, downloadSpec)) { return downloadSpec() + } else { + system.specActions.getConfigByUrl(configUrl, downloadSpec) } return system diff --git a/src/core/plugins/configs/actions.js b/src/core/plugins/configs/actions.js index 70588372..977407de 100644 --- a/src/core/plugins/configs/actions.js +++ b/src/core/plugins/configs/actions.js @@ -18,3 +18,7 @@ export function toggle(configName) { payload: configName, } } + + +// Hook +export const loaded = () => () => {} diff --git a/src/core/plugins/deep-linking/index.js b/src/core/plugins/deep-linking/index.js index 8cec4dd5..ebfe2d8d 100644 --- a/src/core/plugins/deep-linking/index.js +++ b/src/core/plugins/deep-linking/index.js @@ -1,18 +1,23 @@ -// import reducers from "./reducers" -// import * as actions from "./actions" -// import * as selectors from "./selectors" -import * as specWrapActions from "./spec-wrap-actions" -import * as layoutWrapActions from "./layout-wrap-actions" +import layout from "./layout" +import OperationWrapper from "./operation-wrapper" +import OperationTagWrapper from "./operation-tag-wrapper" export default function() { - return { + return [layout, { statePlugins: { - spec: { - wrapActions: specWrapActions - }, - layout: { - wrapActions: layoutWrapActions + configs: { + wrapActions: { + loaded: (ori, system) => (...args) => { + ori(...args) + const hash = window.location.hash + system.layoutActions.parseDeepLinkHash(hash) + } + } } - } - } + }, + wrapComponents: { + operation: OperationWrapper, + OperationTag: OperationTagWrapper, + }, + }] } diff --git a/src/core/plugins/deep-linking/layout-wrap-actions.js b/src/core/plugins/deep-linking/layout-wrap-actions.js deleted file mode 100644 index f1e67d12..00000000 --- a/src/core/plugins/deep-linking/layout-wrap-actions.js +++ /dev/null @@ -1,37 +0,0 @@ -import { setHash } from "./helpers" -import { createDeepLinkPath } from "core/utils" - -export const show = (ori, { getConfigs }) => (...args) => { - ori(...args) - - const isDeepLinkingEnabled = getConfigs().deepLinking - if(!isDeepLinkingEnabled || isDeepLinkingEnabled === "false") { - return - } - - try { - let [thing, shown] = args - let [type] = thing - - if(type === "operations-tag" || type === "operations") { - if(!shown) { - return setHash("/") - } - - if(type === "operations") { - let [, tag, operationId] = thing - setHash(`/${createDeepLinkPath(tag)}/${createDeepLinkPath(operationId)}`) - } - - if(type === "operations-tag") { - let [, tag] = thing - setHash(`/${createDeepLinkPath(tag)}`) - } - } - - } catch(e) { - // This functionality is not mission critical, so if something goes wrong - // we'll just move on - console.error(e) - } -} diff --git a/src/core/plugins/deep-linking/layout.js b/src/core/plugins/deep-linking/layout.js new file mode 100644 index 00000000..ba405b29 --- /dev/null +++ b/src/core/plugins/deep-linking/layout.js @@ -0,0 +1,181 @@ +import { setHash } from "./helpers" +import zenscroll from "zenscroll" +import Im, { fromJS } from "immutable" + +const SCROLL_TO = "layout_scroll_to" +const CLEAR_SCROLL_TO = "layout_clear_scroll" + +export const show = (ori, { getConfigs, layoutSelectors }) => (...args) => { + ori(...args) + + if(!getConfigs().deepLinking) { + return + } + + try { + let [tokenArray, shown] = args + //Coerce in to array + tokenArray = Array.isArray(tokenArray) ? tokenArray : [tokenArray] + // Convert into something we can put in the URL hash + // Or return empty, if we cannot + const urlHashArray = layoutSelectors.urlHashArrayFromIsShownKey(tokenArray) // Will convert + + // No hash friendly list? + if(!urlHashArray.length) + return + + const [type, assetName] = urlHashArray + + if (!shown) { + return setHash("/") + } + + if (urlHashArray.length === 2) { + setHash(`/${type}/${assetName}`) + } else if (urlHashArray.length === 1) { + setHash(`/${type}`) + } + + } catch (e) { + // This functionality is not mission critical, so if something goes wrong + // we'll just move on + console.error(e) // eslint-disable-line no-console + } +} + +export const scrollTo = (path) => { + return { + type: SCROLL_TO, + payload: Array.isArray(path) ? path : [path] + } +} + +export const parseDeepLinkHash = (rawHash) => ({ layoutActions, layoutSelectors, getConfigs }) => { + + if(!getConfigs().deepLinking) { + return + } + + if(rawHash) { + let hash = rawHash.slice(1) // # is first character + + + if(hash[0] === "!") { + // Parse UI 2.x shebangs + hash = hash.slice(1) + } + + if(hash[0] === "/") { + // "/pet/addPet" => "pet/addPet" + // makes the split result cleaner + // also handles forgotten leading slash + hash = hash.slice(1) + } + + const isShownKey = layoutSelectors.isShownKeyFromUrlHashArray(hash.split("/")) + + layoutActions.show(isShownKey, true) // TODO: 'show' operation tag + layoutActions.scrollTo(isShownKey) + } +} + +export const readyToScroll = (isShownKey, ref) => (system) => { + const scrollToKey = system.layoutSelectors.getScrollToKey() + + if(Im.is(scrollToKey, fromJS(isShownKey))) { + system.layoutActions.scrollToElement(ref) + system.layoutActions.clearScrollTo() + } +} + +// Scroll to "ref" (dom node) with the scrollbar on "container" or the nearest parent +export const scrollToElement = (ref, container) => (system) => { + try { + container = container || system.fn.getScrollParent(ref) + let myScroller = zenscroll.createScroller(container) + myScroller.to(ref) + } catch(e) { + console.error(e) // eslint-disable-line no-console + } +} + +export const clearScrollTo = () => { + return { + type: CLEAR_SCROLL_TO, + } +} + +// From: https://stackoverflow.com/a/42543908/3933724 +// Modified to return html instead of body element as last resort +function getScrollParent(element, includeHidden) { + const LAST_RESORT = document.documentElement + let style = getComputedStyle(element) + const excludeStaticParent = style.position === "absolute" + const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/ + + if (style.position === "fixed") + return LAST_RESORT + for (let parent = element; (parent = parent.parentElement);) { + style = getComputedStyle(parent) + if (excludeStaticParent && style.position === "static") { + continue + } + if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) + return parent + } + + return LAST_RESORT +} + +export default { + fn: { + getScrollParent, + }, + statePlugins: { + layout: { + actions: { + scrollToElement, + scrollTo, + clearScrollTo, + readyToScroll, + parseDeepLinkHash + }, + selectors: { + getScrollToKey(state) { + return state.get("scrollToKey") + }, + isShownKeyFromUrlHashArray(state, urlHashArray) { + const [tag, operationId] = urlHashArray + // We only put operations in the URL + if(operationId) { + return ["operations", tag, operationId] + } else if (tag) { + return ["operations-tag", tag] + } + return [] + }, + urlHashArrayFromIsShownKey(state, isShownKey) { + let [type, tag, operationId] = isShownKey + // We only put operations in the URL + if(type == "operations") { + return [tag, operationId] + } else if (type == "operations-tag") { + return [tag] + } + return [] + }, + }, + reducers: { + [SCROLL_TO](state, action) { + return state.set("scrollToKey", Im.fromJS(action.payload)) + }, + [CLEAR_SCROLL_TO](state) { + return state.delete("scrollToKey") + } + }, + wrapActions: { + show + } + } + } +} diff --git a/src/core/plugins/deep-linking/operation-tag-wrapper.jsx b/src/core/plugins/deep-linking/operation-tag-wrapper.jsx new file mode 100644 index 00000000..1694683f --- /dev/null +++ b/src/core/plugins/deep-linking/operation-tag-wrapper.jsx @@ -0,0 +1,25 @@ +import React from "react" +import { PropTypes } from "prop-types" + +const Wrapper = (Ori, system) => class OperationTagWrapper extends React.Component { + + static propTypes = { + tag: PropTypes.object.isRequired, + } + + onLoad = (ref) => { + const { tag } = this.props + const isShownKey = ["operations-tag", tag] + system.layoutActions.readyToScroll(isShownKey, ref) + } + + render() { + return ( + + + + ) + } +} + +export default Wrapper diff --git a/src/core/plugins/deep-linking/operation-wrapper.jsx b/src/core/plugins/deep-linking/operation-wrapper.jsx new file mode 100644 index 00000000..ab2fb8e4 --- /dev/null +++ b/src/core/plugins/deep-linking/operation-wrapper.jsx @@ -0,0 +1,26 @@ +import React from "react" +import ImPropTypes from "react-immutable-proptypes" + +const Wrapper = (Ori, system) => class OperationWrapper extends React.Component { + + static propTypes = { + operation: ImPropTypes.map.isRequired, + } + + onLoad = (ref) => { + const { operation } = this.props + const { tag, operationId } = operation.toObject() + const isShownKey = ["operations", tag, operationId] + system.layoutActions.readyToScroll(isShownKey, ref) + } + + render() { + return ( + + + + ) + } +} + +export default Wrapper diff --git a/src/core/plugins/deep-linking/spec-wrap-actions.js b/src/core/plugins/deep-linking/spec-wrap-actions.js deleted file mode 100644 index 398fbcbc..00000000 --- a/src/core/plugins/deep-linking/spec-wrap-actions.js +++ /dev/null @@ -1,64 +0,0 @@ -import zenscroll from "zenscroll" -import { escapeDeepLinkPath } from "core/utils" - -let hasHashBeenParsed = false //TODO this forces code to only run once which may prevent scrolling if page not refreshed - - -export const updateJsonSpec = (ori, { layoutActions, getConfigs }) => (...args) => { - ori(...args) - - const isDeepLinkingEnabled = getConfigs().deepLinking - if(!isDeepLinkingEnabled || isDeepLinkingEnabled === "false") { - return - } - if(window.location.hash && !hasHashBeenParsed ) { - let hash = window.location.hash.slice(1) // # is first character - - if(hash[0] === "!") { - // Parse UI 2.x shebangs - hash = hash.slice(1) - } - - if(hash[0] === "/") { - // "/pet/addPet" => "pet/addPet" - // makes the split result cleaner - // also handles forgotten leading slash - hash = hash.slice(1) - } - - let [tag, operationId] = hash.split("/") - - let swaggerUI = document.querySelector(".swagger-ui") - let myScroller = zenscroll.createScroller(swaggerUI) - - let target - - if(tag && operationId) { - // Pre-expand and scroll to the operation - layoutActions.show(["operations-tag", tag], true) - layoutActions.show(["operations", tag, operationId], true) - - target = document - .getElementById(`operations-${escapeDeepLinkPath(tag)}-${escapeDeepLinkPath(operationId)}`) - } else if(tag) { - // Pre-expand and scroll to the tag - layoutActions.show(["operations-tag", tag], true) - - target = document.getElementById(`operations-tag-${escapeDeepLinkPath(tag)}`) - } - - - if(target) { - myScroller.to(target) - setTimeout(() => { - // Backup functionality: if we're still at the top of the document, - // scroll on the entire page (not within the Swagger-UI container) - if(zenscroll.getY() === 0) { - zenscroll.to(target) - } - }, 50) - } - } - - hasHashBeenParsed = true -} diff --git a/src/core/presets/base.js b/src/core/presets/base.js index f0d985e7..10fba1e3 100644 --- a/src/core/presets/base.js +++ b/src/core/presets/base.js @@ -31,6 +31,7 @@ import Clear from "core/components/clear" import LiveResponse from "core/components/live-response" import OnlineValidatorBadge from "core/components/online-validator-badge" import Operations from "core/components/operations" +import OperationTag from "core/components/operation-tag" import Operation from "core/components/operation" import OperationExt from "core/components/operation-extensions" import OperationExtRow from "core/components/operation-extension-row" @@ -128,6 +129,7 @@ export default function() { OperationExt, OperationExtRow, ParameterExt, + OperationTag, OperationContainer, DeepLink, InfoUrl, diff --git a/test/components/operations.js b/test/components/operations.js index bfcbed4f..844a4931 100644 --- a/test/components/operations.js +++ b/test/components/operations.js @@ -1,6 +1,6 @@ /* eslint-env mocha */ import React from "react" -import expect, { createSpy } from "expect" +import expect from "expect" import { render } from "enzyme" import { fromJS } from "immutable" import DeepLink from "components/deep-link" @@ -10,7 +10,8 @@ import {Collapse} from "components/layout-utils" const components = { Collapse, DeepLink, - OperationContainer: ({ path, method }) => + OperationContainer: ({ path, method }) => , + OperationTag: "div", } describe("", function(){