Browse Source

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
bubble
Josh Ponelat 6 years ago
committed by kyle
parent
commit
90157c1a40
12 changed files with 416 additions and 212 deletions
  1. +108
    -0
      src/core/components/operation-tag.jsx
  2. +43
    -89
      src/core/components/operations.jsx
  3. +6
    -7
      src/core/index.js
  4. +4
    -0
      src/core/plugins/configs/actions.js
  5. +18
    -13
      src/core/plugins/deep-linking/index.js
  6. +0
    -37
      src/core/plugins/deep-linking/layout-wrap-actions.js
  7. +181
    -0
      src/core/plugins/deep-linking/layout.js
  8. +25
    -0
      src/core/plugins/deep-linking/operation-tag-wrapper.jsx
  9. +26
    -0
      src/core/plugins/deep-linking/operation-wrapper.jsx
  10. +0
    -64
      src/core/plugins/deep-linking/spec-wrap-actions.js
  11. +2
    -0
      src/core/presets/base.js
  12. +3
    -2
      test/components/operations.js

+ 108
- 0
src/core/components/operation-tag.jsx View File

@@ -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 (
<div className={showTag ? "opblock-tag-section is-open" : "opblock-tag-section"} >

<h4
onClick={() => layoutActions.show(isShownKey, !showTag)}
className={!tagDescription ? "opblock-tag no-desc" : "opblock-tag" }
id={isShownKey.join("-")}>
<DeepLink
enabled={isDeepLinkingEnabled}
isShown={showTag}
path={tag}
text={tag} />
{ !tagDescription ? <small></small> :
<small>
<Markdown source={tagDescription} />
</small>
}

<div>
{ !tagExternalDocsDescription ? null :
<small>
{ tagExternalDocsDescription }
{ tagExternalDocsUrl ? ": " : null }
{ tagExternalDocsUrl ?
<a
href={sanitizeUrl(tagExternalDocsUrl)}
onClick={(e) => e.stopPropagation()}
target={"_blank"}
>{tagExternalDocsUrl}</a> : null
}
</small>
}
</div>

<button
className="expand-operation"
title={showTag ? "Collapse operation": "Expand operation"}
onClick={() => layoutActions.show(isShownKey, !showTag)}>

<svg className="arrow" width="20" height="20">
<use href={showTag ? "#large-arrow-down" : "#large-arrow"} xlinkHref={showTag ? "#large-arrow-down" : "#large-arrow"} />
</svg>
</button>
</h4>

<Collapse isOpened={showTag}>
{children}
</Collapse>
</div>
)
}
}

+ 43
- 89
src/core/components/operations.jsx View File

@@ -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 {
<div>
{
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 (
<div className={showTag ? "opblock-tag-section is-open" : "opblock-tag-section"} key={"operation-" + tag}>

<h4
onClick={() => layoutActions.show(isShownKey, !showTag)}
className={!tagDescription ? "opblock-tag no-desc" : "opblock-tag" }
id={isShownKey.join("-")}>
<DeepLink
enabled={isDeepLinkingEnabled}
isShown={showTag}
path={tag}
text={tag} />
{ !tagDescription ? <small></small> :
<small>
<Markdown source={tagDescription} />
</small>
}

<div>
{ !tagExternalDocsDescription ? null :
<small>
{ tagExternalDocsDescription }
{ tagExternalDocsUrl ? ": " : null }
{ tagExternalDocsUrl ?
<a
href={sanitizeUrl(tagExternalDocsUrl)}
onClick={(e) => e.stopPropagation()}
target={"_blank"}
>{tagExternalDocsUrl}</a> : null
}
</small>
}
</div>

<button className="expand-operation" title={showTag ? "Collapse operation": "Expand operation"} onClick={() => layoutActions.show(isShownKey, !showTag)}>
<svg className="arrow" width="20" height="20">
<use href={showTag ? "#large-arrow-down" : "#large-arrow"} xlinkHref={showTag ? "#large-arrow-down" : "#large-arrow"} />
</svg>
</button>
</h4>

<Collapse isOpened={showTag}>
{
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 <OperationContainer
key={`${path}-${method}`}
specPath={specPath}
op={op}
path={path}
method={method}
tag={tag}
/>
}).toArray()
}
</Collapse>
</div>
)
<OperationTag
key={"operation-" + tag}
tagObj={tagObj}
tag={tag}
layoutSelectors={layoutSelectors}
layoutActions={layoutActions}
getConfigs={getConfigs}
getComponent={getComponent}>
{
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 <OperationContainer
key={`${path}-${method}`}
specPath={specPath}
op={op}
path={path}
method={method}
tag={tag}
/>
}).toArray()
}


</OperationTag>
)
}).toArray()
}



+ 6
- 7
src/core/index.js View File

@@ -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


+ 4
- 0
src/core/plugins/configs/actions.js View File

@@ -18,3 +18,7 @@ export function toggle(configName) {
payload: configName,
}
}


// Hook
export const loaded = () => () => {}

+ 18
- 13
src/core/plugins/deep-linking/index.js View File

@@ -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,
},
}]
}

+ 0
- 37
src/core/plugins/deep-linking/layout-wrap-actions.js View File

@@ -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)
}
}

+ 181
- 0
src/core/plugins/deep-linking/layout.js View File

@@ -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
}
}
}
}

+ 25
- 0
src/core/plugins/deep-linking/operation-tag-wrapper.jsx View File

@@ -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 (
<span ref={this.onLoad}>
<Ori {...this.props} />
</span>
)
}
}

export default Wrapper

+ 26
- 0
src/core/plugins/deep-linking/operation-wrapper.jsx View File

@@ -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 (
<span ref={this.onLoad}>
<Ori {...this.props} />
</span>
)
}
}

export default Wrapper

+ 0
- 64
src/core/plugins/deep-linking/spec-wrap-actions.js View File

@@ -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
}

+ 2
- 0
src/core/presets/base.js View File

@@ -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,


+ 3
- 2
test/components/operations.js View File

@@ -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 }) => <span className="mocked-op" id={`${path}-${method}`} />
OperationContainer: ({ path, method }) => <span className="mocked-op" id={`${path}-${method}`} />,
OperationTag: "div",
}

describe("<Operations/>", function(){


Loading…
Cancel
Save