diff --git a/README.md b/README.md index 3ba6b44e..560dfed3 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,6 @@ To help with the migration, here are the currently known issues with 3.X. This l - Only part of the [parameters](#parameters) previously supported are available. - The JSON Form Editor is not implemented. -- Shebang URL support for operations is missing. - Support for `collectionFormat` is partial. - l10n (translations) is not implemented. - Relative path support for external files is not implemented. diff --git a/docs/deep-linking.md b/docs/deep-linking.md new file mode 100644 index 00000000..8fd45497 --- /dev/null +++ b/docs/deep-linking.md @@ -0,0 +1,36 @@ +# Deep linking + +Swagger-UI allows you to deeply link into tags and operations within a spec. When Swagger-UI is provided a URL fragment at runtime, it will automatically expand and scroll to a specified tag or operation. + +## Usage + +👉🏼 Add `deepLinking: true` to your Swagger-UI configuration to enable this functionality. + +When you expand a tag or operation, Swagger-UI will automatically update its URL fragment with a deep link to the item. +Conversely, when you collapse a tag or operation, Swagger-UI will clear the URL fragment. + +You can also right-click a tag name or operation path in order to copy a link to that tag or operation. + +#### Fragment format + +The fragment is formatted in one of two ways: + +- `#/{tagName}`, to trigger the focus of a specific tag +- `#/{tagName}/{operationId}`, to trigger the focus of a specific operation within a tag + +`operationId` is the explicit operationId provided in the spec, if one exists. +Otherwise, Swagger-UI generates an implicit operationId by combining the operation's path and method, and escaping non-alphanumeric characters. + +## FAQ + +> I'm using Swagger-UI in an application that needs control of the URL fragment. How do I disable deep-linking? + +This functionality is disabled by default, but you can pass `deepLinking: false` into Swagger-UI as a configuration item to be sure. + +> Can I link to multiple tags or operations? + +No, this is not supported. + +> Can I collapse everything except the operation or tag I'm linking to? + +Sure - use `docExpansion: none` to collapse all tags and operations. Your deep link will take precedence over the setting, so only the tag or operation you've specified will be expanded. diff --git a/package.json b/package.json index 895e22dd..1893dc7f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "redux-logger": "*", "reselect": "2.5.3", "sanitize-html": "^1.14.1", + "scroll-to-element": "^2.0.0", "serialize-error": "2.0.0", "shallowequal": "0.2.2", "swagger-client": "3.0.17", diff --git a/src/core/components/array-model.jsx b/src/core/components/array-model.jsx index f45ac674..b507b42b 100644 --- a/src/core/components/array-model.jsx +++ b/src/core/components/array-model.jsx @@ -17,16 +17,19 @@ export default class ArrayModel extends Component { render(){ let { getComponent, required, schema, depth, expandDepth } = this.props let items = schema.get("items") + let title = schema.get("title") || name let properties = schema.filter( ( v, key) => ["type", "items", "$$ref"].indexOf(key) === -1 ) const ModelCollapse = getComponent("ModelCollapse") const Model = getComponent("Model") - return + const titleEl = title && - { schema.get("title") } + { title } - expandDepth } collapsedContent="[...]"> + + return + expandDepth } collapsedContent="[...]"> [ ] diff --git a/src/core/components/model-collapse.jsx b/src/core/components/model-collapse.jsx index 1b46e56a..b71096f2 100644 --- a/src/core/components/model-collapse.jsx +++ b/src/core/components/model-collapse.jsx @@ -5,12 +5,14 @@ export default class ModelCollapse extends Component { static propTypes = { collapsedContent: PropTypes.any, collapsed: PropTypes.bool, - children: PropTypes.any + children: PropTypes.any, + title: PropTypes.element } static defaultProps = { collapsedContent: "{...}", collapsed: true, + title: null } constructor(props, context) { @@ -31,11 +33,15 @@ export default class ModelCollapse extends Component { } render () { - return ( - - + const {title} = this.props + return ( + + { title && {title} } + + + + { this.state.collapsed ? this.state.collapsedContent : this.props.children } - { this.state.collapsed ? this.state.collapsedContent : this.props.children } - ) + ) } } \ No newline at end of file diff --git a/src/core/components/object-model.jsx b/src/core/components/object-model.jsx index 2bea7781..5e0a83a9 100644 --- a/src/core/components/object-model.jsx +++ b/src/core/components/object-model.jsx @@ -38,15 +38,13 @@ export default class ObjectModel extends Component { } ) + const titleEl = title && + { isRef && schema.get("$$ref") && { schema.get("$$ref") } } + { title } + return - { - title && - { isRef && schema.get("$$ref") && { schema.get("$$ref") } } - { title } - - } - expandDepth } collapsedContent={ collapsedContent }> + expandDepth } collapsedContent={ collapsedContent }> { braceOpen } { !isRef ? null : diff --git a/src/core/components/operation.jsx b/src/core/components/operation.jsx index 52807722..a4dea2a6 100644 --- a/src/core/components/operation.jsx +++ b/src/core/components/operation.jsx @@ -116,7 +116,8 @@ export default class Operation extends PureComponent { specActions, specSelectors, authActions, - authSelectors + authSelectors, + getConfigs } = this.props let summary = operation.get("summary") @@ -141,6 +142,10 @@ export default class Operation extends PureComponent { const Markdown = getComponent( "Markdown" ) const Schemes = getComponent( "schemes" ) + const { deepLinking } = getConfigs() + + const isDeepLinkingEnabled = deepLinking && deepLinking !== "false" + // Merge in Live Response if(response && response.size > 0) { let notDocumented = !responses.get(String(response.get("status"))) @@ -152,13 +157,18 @@ export default class Operation extends PureComponent { let onChangeKey = [ path, method ] // Used to add values to _this_ operation ( indexed by path and method ) return ( -
+
- {method.toUpperCase()} - - {path} - - + {method.toUpperCase()} + + e.preventDefault()} + href={ isDeepLinkingEnabled ? `#/${isShownKey[1]}/${isShownKey[2]}` : ""} > + {path} + + + { !showSummary ? null :
@@ -191,7 +201,9 @@ export default class Operation extends PureComponent {

Find more details

- { externalDocs.get("description") } + + + { externalDocs.get("url") }
: null diff --git a/src/core/components/operations.jsx b/src/core/components/operations.jsx index 679af129..c9031651 100644 --- a/src/core/components/operations.jsx +++ b/src/core/components/operations.jsx @@ -1,5 +1,8 @@ import React from "react" import PropTypes from "prop-types" +import { helpers } from "swagger-client" + +const { opId } = helpers export default class Operations extends React.Component { @@ -33,7 +36,15 @@ export default class Operations extends React.Component { const Collapse = getComponent("Collapse") let showSummary = layoutSelectors.showSummary() - let { docExpansion, displayOperationId, displayRequestDuration, maxDisplayedTags } = getConfigs() + let { + docExpansion, + displayOperationId, + displayRequestDuration, + maxDisplayedTags, + deepLinking + } = getConfigs() + + const isDeepLinkingEnabled = deepLinking && deepLinking !== "false" let filter = layoutSelectors.currentFilter() @@ -62,8 +73,16 @@ export default class Operations extends React.Component { return (
-

layoutActions.show(isShownKey, !showTag)} className={!tagDescription ? "opblock-tag no-desc" : "opblock-tag" }> - {tag} +

layoutActions.show(isShownKey, !showTag)} + className={!tagDescription ? "opblock-tag no-desc" : "opblock-tag" } + id={isShownKey.join("-")}> + e.preventDefault()} + href={ isDeepLinkingEnabled ? `#/${tag}` : ""}> + {tag} + { !tagDescription ? null : { tagDescription } @@ -81,11 +100,14 @@ export default class Operations extends React.Component { { operations.map( op => { - const isShownKey = ["operations", op.get("id"), tag] const path = op.get("path", "") const method = op.get("method", "") const jumpToKey = `paths.${path}.${method}` + const operationId = + op.getIn(["operation", "operationId"]) || op.getIn(["operation", "__originalOperationId"]) || opId(op.get("operation"), path, method) || op.get("id") + const isShownKey = ["operations", tag, operationId] + const allowTryItOut = specSelectors.allowTryItOutFor(op.get("path"), op.get("method")) const response = specSelectors.responseFor(op.get("path"), op.get("method")) const request = specSelectors.requestFor(op.get("path"), op.get("method")) diff --git a/src/core/components/providers/markdown.jsx b/src/core/components/providers/markdown.jsx index ff29f021..8c8d0f78 100644 --- a/src/core/components/providers/markdown.jsx +++ b/src/core/components/providers/markdown.jsx @@ -18,10 +18,12 @@ function Markdown({ source }) { return null } - return + return
+ +
} Markdown.propTypes = { diff --git a/src/core/index.js b/src/core/index.js index fa5db888..c0ba97c8 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -30,6 +30,7 @@ const CONFIGS = [ "parameterMacro", "displayOperationId", "displayRequestDuration", + "deepLinking", ] // eslint-disable-next-line no-undef @@ -61,6 +62,7 @@ module.exports = function SwaggerUI(opts) { custom: {}, displayOperationId: false, displayRequestDuration: false, + deepLinking: false, // Initial set of plugins ( TODO rename this, or refactor - we don't need presets _and_ plugins. Its just there for performance. // Instead, we can compile the first plugin ( it can be a collection of plugins ), then batch the rest. diff --git a/src/core/plugins/deep-linking/README.md b/src/core/plugins/deep-linking/README.md new file mode 100644 index 00000000..5f417982 --- /dev/null +++ b/src/core/plugins/deep-linking/README.md @@ -0,0 +1 @@ +See `docs/deep-linking.md`. diff --git a/src/core/plugins/deep-linking/helpers.js b/src/core/plugins/deep-linking/helpers.js new file mode 100644 index 00000000..d06bbe27 --- /dev/null +++ b/src/core/plugins/deep-linking/helpers.js @@ -0,0 +1,7 @@ +export const setHash = (value) => { + if(value) { + return history.pushState(null, null, `#${value}`) + } else { + return window.location.hash = "" + } +} diff --git a/src/core/plugins/deep-linking/index.js b/src/core/plugins/deep-linking/index.js new file mode 100644 index 00000000..8cec4dd5 --- /dev/null +++ b/src/core/plugins/deep-linking/index.js @@ -0,0 +1,18 @@ +// 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" + +export default function() { + return { + statePlugins: { + spec: { + wrapActions: specWrapActions + }, + layout: { + wrapActions: layoutWrapActions + } + } + } +} diff --git a/src/core/plugins/deep-linking/layout-wrap-actions.js b/src/core/plugins/deep-linking/layout-wrap-actions.js new file mode 100644 index 00000000..72d98948 --- /dev/null +++ b/src/core/plugins/deep-linking/layout-wrap-actions.js @@ -0,0 +1,36 @@ +import { setHash } from "./helpers" + +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(`/${tag}/${operationId}`) + } + + if(type === "operations-tag") { + let [, tag] = thing + setHash(`/${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/spec-wrap-actions.js b/src/core/plugins/deep-linking/spec-wrap-actions.js new file mode 100644 index 00000000..bb13a6ff --- /dev/null +++ b/src/core/plugins/deep-linking/spec-wrap-actions.js @@ -0,0 +1,51 @@ +import scrollTo from "scroll-to-element" + +const SCROLL_OFFSET = -5 +let hasHashBeenParsed = false + + +export const updateResolved = (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("/") + + if(tag && operationId) { + // Pre-expand and scroll to the operation + layoutActions.show(["operations-tag", tag], true) + layoutActions.show(["operations", tag, operationId], true) + + scrollTo(`#operations-${tag}-${operationId}`, { + offset: SCROLL_OFFSET + }) + } else if(tag) { + // Pre-expand and scroll to the tag + layoutActions.show(["operations-tag", tag], true) + + scrollTo(`#operations-tag-${tag}`, { + offset: SCROLL_OFFSET + }) + } + } + + hasHashBeenParsed = true +} diff --git a/src/core/presets/base.js b/src/core/presets/base.js index d5471791..0043b543 100644 --- a/src/core/presets/base.js +++ b/src/core/presets/base.js @@ -10,6 +10,7 @@ import auth from "core/plugins/auth" import util from "core/plugins/util" import SplitPaneModePlugin from "core/plugins/split-pane-mode" import downloadUrlPlugin from "core/plugins/download-url" +import deepLinkingPlugin from "core/plugins/deep-linking" import App from "core/components/app" import AuthorizationPopup from "core/components/auth/authorization-popup" @@ -131,6 +132,7 @@ export default function() { auth, ast, SplitPaneModePlugin, - downloadUrlPlugin + downloadUrlPlugin, + deepLinkingPlugin ] } diff --git a/src/core/utils.js b/src/core/utils.js index 677b8d90..5ba837c6 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -228,13 +228,13 @@ export function highlight (el) { var reset = function(el) { var text = el.textContent, - pos = 0, // current position + pos = 0, // current position next1 = text[0], // next character - chr = 1, // current character - prev1, // previous character - prev2, // the one before the previous - token = // current token content - el.innerHTML = "", // (and cleaning the node) + chr = 1, // current character + prev1, // previous character + prev2, // the one before the previous + token = // current token content + el.innerHTML = "", // (and cleaning the node) // current token type: // 0: anything else (whitespaces / newlines) @@ -274,11 +274,11 @@ export function highlight (el) { (tokenType > 8 && chr == "\n") || [ // finalize conditions for other token types // 0: whitespaces - /\S/[test](chr), // merged together + /\S/[test](chr), // merged together // 1: operators - 1, // consist of a single character + 1, // consist of a single character // 2: braces - 1, // consist of a single character + 1, // consist of a single character // 3: (key)word !/[$\w]/[test](chr), // 4: regex @@ -341,12 +341,12 @@ export function highlight (el) { // condition) tokenType = 11 while (![ - 1, // 0: whitespace + 1, // 0: whitespace // 1: operator or braces - /[\/{}[(\-+*=<>:;|\\.,?!&@~]/[test](chr), // eslint-disable-line no-useless-escape - /[\])]/[test](chr), // 2: closing brace - /[$\w]/[test](chr), // 3: (key)word - chr == "/" && // 4: regex + /[\/{}[(\-+*=<>:;|\\.,?!&@~]/[test](chr), // eslint-disable-line no-useless-escape + /[\])]/[test](chr), // 2: closing brace + /[$\w]/[test](chr), // 3: (key)word + chr == "/" && // 4: regex // previous token was an // opening brace or an // operator (otherwise @@ -355,13 +355,13 @@ export function highlight (el) { // workaround for xml // closing tags prev1 != "<", - chr == "\"", // 5: string with " - chr == "'", // 6: string with ' + chr == "\"", // 5: string with " + chr == "'", // 6: string with ' // 7: xml comment chr+next1+text[pos+1]+text[pos+2] == "