* add configsActions.loaded hook * add OperationTag to hold Operations * fix test for operations * refactor deep-linking pluginbubble
@@ -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> | |||
) | |||
} | |||
} |
@@ -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() | |||
} | |||
@@ -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 | |||
@@ -18,3 +18,7 @@ export function toggle(configName) { | |||
payload: configName, | |||
} | |||
} | |||
// Hook | |||
export const loaded = () => () => {} |
@@ -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, | |||
}, | |||
}] | |||
} |
@@ -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) | |||
} | |||
} |
@@ -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 | |||
} | |||
} | |||
} | |||
} |
@@ -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 |
@@ -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 |
@@ -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 | |||
} |
@@ -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, | |||
@@ -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(){ | |||