Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Advanced Filter (Regex Matching, Case Matching, Full Words Matching, Tag/Route) #6744

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/customization/plug-points.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ For example, you can implement a multiple-phrase filter:
const MultiplePhraseFilterPlugin = function() {
return {
fn: {
opsFilter: (taggedOps, phrase) => {
opsFilter: (taggedOps, phrase, filterConfig) => {
const phrases = phrase.split(", ")

return taggedOps.filter((val, key) => {
Expand Down
8 changes: 6 additions & 2 deletions docs/usage/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ Parameter name | Docker variable | Description
<a name="defaultModelRendering"></a>`defaultModelRendering` | `DEFAULT_MODEL_RENDERING` | `String=["example"*, "model"]`. Controls how the model is shown when the API is first rendered. (The user can always switch the rendering for a given model by clicking the 'Model' and 'Example Value' links.)
<a name="displayRequestDuration"></a>`displayRequestDuration` | `DISPLAY_REQUEST_DURATION` | `Boolean=false`. Controls the display of the request duration (in milliseconds) for "Try it out" requests.
<a name="docExpansion"></a>`docExpansion` | `DOC_EXPANSION` | `String=["list"*, "full", "none"]`. Controls the default expansion setting for the operations and tags. It can be 'list' (expands only the tags), 'full' (expands the tags and operations) or 'none' (expands nothing).
<a name="filter"></a>`filter` | `FILTER` | `Boolean=false OR String`. If set, enables filtering. The top bar will show an edit box that you can use to filter the tagged operations that are shown. Can be Boolean to enable or disable, or a string, in which case filtering will be enabled using that string as the filter expression. Filtering is case sensitive matching the filter expression anywhere inside the tag.
<a name="filter"></a>`filter` | `FILTER` | `Boolean=false OR String`. If set, enables filtering. The top bar will show an edit box that you can use to filter the tagged operations that are shown. Can be Boolean to enable or disable, or a string, in which case filtering will be enabled using that string as the filter expression.
<a name="filterConfig"></a>`filterConfig` | _Unavailable_ | `Object={}`. A JavaScript object describing how to filter.
<a name="filterConfig.isRegexFilter"></a>`filterConfig.isRegexFilter` | _Unavailable_ | `Boolean=false`. Controls the filter mode. By default, the filter is matched anywhere inside the tag. If set to true it will treat the provided filter value as regular expression.
<a name="filterConfig.matchCase"></a>`filterConfig.matchCase` | _Unavailable_ | `Boolean=true`. Controls the case matching mode. By default, filtering is case sensitive. It can be set to false to match case insensitive.
<a name="filterConfig.matchCase"></a>`filterConfig.matchWords` | _Unavailable_ | `Boolean=false`. Controls the full words matching mode. By default, it is disabled. If set to true it will match only full words.
<a name="maxDisplayedTags"></a>`maxDisplayedTags` | `MAX_DISPLAYED_TAGS` | `Number`. If set, limits the number of tagged operations displayed to at most this many. The default is to show all operations.
<a name="operationsSorter"></a>`operationsSorter` | _Unavailable_ | `Function=(a => a)`. Apply a sort to the operation list of each API. It can be 'alpha' (sort by paths alphanumerically), 'method' (sort by HTTP method) or a function (see Array.prototype.sort() to know how sort function works). Default is the order returned by the server unchanged.
<a name="showExtensions"></a>`showExtensions` | `SHOW_EXTENSIONS` | `Boolean=false`. Controls the display of vendor extension (`x-`) fields and values for Operations, Parameters, Responses, and Schema.
Expand Down Expand Up @@ -167,4 +171,4 @@ SPEC="{ \"openapi\": \"3.0.0\" }"
```sh
SUPPORTED_SUBMIT_METHODS=['get', 'post']
URLS=[ { url: 'http://petstore.swagger.io/v2/swagger.json', name: 'Petstore' } ]
```
```
3 changes: 2 additions & 1 deletion src/core/components/operations.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ export default class Operations extends React.Component {
} = getConfigs()

let filter = layoutSelectors.currentFilter()
let filterConfig = layoutSelectors.currentFilterConfig()

if (filter) {
if (filter !== true && filter !== "true" && filter !== "false") {
taggedOps = fn.opsFilter(taggedOps, filter)
taggedOps = fn.opsFilter(taggedOps, filter, filterConfig)
}
}

Expand Down
221 changes: 214 additions & 7 deletions src/core/containers/filter.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import React from "react"
import PropTypes from "prop-types"
import { isFunc } from "../utils"

export default class FilterContainer extends React.Component {
constructor() {
super()
this.state = {
savedCursorOffset: 0,
}
}

static propTypes = {
specSelectors: PropTypes.object.isRequired,
Expand All @@ -10,35 +17,235 @@ export default class FilterContainer extends React.Component {
getComponent: PropTypes.func.isRequired,
}

componentDidMount() {
if (this.inputRef) {
const filter = this.props.layoutSelectors.currentFilter()
this.inputRef.innerText = filter === true || filter === "true" ? "" : filter
this.inputRef.addEventListener("keypress", e => {
if (e.key === "Enter") {
e.preventDefault()
}
})
this.inputRef.addEventListener("paste", (e) => {
const content = e.clipboardData.getData("text/plain")
document.execCommand("insertText", false, content
.replace(/\r?\n|\r/g, "")
.replace(new RegExp(String.fromCharCode(160), "g"), " "))
e.preventDefault()
return false
})
}
}

getFilterConfig = () => {
const configMap = this.props.layoutSelectors.currentFilterConfig()
if (isFunc(configMap.toJS)) {
return configMap.toJS()
}
return configMap
}

onFilterChange = (e) => {
const {target: {value}} = e
this.props.layoutActions.updateFilter(value)
if (this.inputRef.innerHTML.trim() === "<br>") {
this.inputRef.innerHTML = ""
}
this.setState({ savedCursorOffset: document.getSelection().focusOffset })
const { target: { innerText } } = e
this.props.layoutActions.updateFilter(innerText)
}
onRegexToggle = () => {
const currentFilterConfig = this.getFilterConfig()
this.props.layoutActions.updateFilterConfig({
...currentFilterConfig,
matchWords: false,
isRegexFilter: !currentFilterConfig.isRegexFilter,
})
this.restoreInputFocus()
}

restoreInputFocus() {
this.inputRef.focus()
if (this.state.savedCursorOffset !== undefined) {
document.getSelection().collapse(this.inputRef.firstChild, this.state.savedCursorOffset)
}
}

onMatchCaseToggle = () => {
const currentFilterConfig = this.getFilterConfig()
this.props.layoutActions.updateFilterConfig({
...currentFilterConfig,
matchCase: !currentFilterConfig.matchCase,
})
this.restoreInputFocus()
}

onMatchWordsToggle = () => {
const currentFilterConfig = this.getFilterConfig()
this.props.layoutActions.updateFilterConfig({
...currentFilterConfig,
matchWords: !currentFilterConfig.matchWords,
})
this.restoreInputFocus()
}

onSearchLocationChange = (locationOption) => {
const currentFilterConfig = this.getFilterConfig()
this.props.layoutActions.updateFilterConfig({
...currentFilterConfig,
searchLocation: locationOption.target.value,
})
this.restoreInputFocus()
}

render () {
const {specSelectors, layoutSelectors, getComponent} = this.props
moveCaret(win, charCount) {
let sel, range
if (win.getSelection) {
// IE9+ and other browsers
sel = win.getSelection()
if (sel.rangeCount > 0) {
const textNode = this.inputRef.firstChild
const newOffset = sel.focusOffset + charCount
sel.collapse(textNode, Math.min(textNode.length, newOffset))
}
} else if ((sel = win.document.selection)) {
// IE <= 8
if (sel.type !== "Control") {
range = sel.createRange()
range.move("character", charCount)
range.select()
}
}
this.setState({ savedCursorOffset: document.getSelection().focusOffset })
}

render() {
const { specSelectors, layoutSelectors, getComponent } = this.props
const Col = getComponent("Col")

const isLoading = specSelectors.loadingStatus() === "loading"
const isFailed = specSelectors.loadingStatus() === "failed"
const filter = layoutSelectors.currentFilter()
const filterConfig = this.getFilterConfig()
const isRegexFilter = filterConfig.isRegexFilter

const formStyle = {
display: "flex",
flexDirection: "row",
border: "1px solid grey",
margin: "20px 0",
background: "white",
padding: "5px",
}

const inputStyle = {
flexShrink: 1,
padding: "0px !important",
border: "none",
margin: 0,
overflow: "overlay",
outline: "none",
}

const btnStyle = {
marginRight: "5px",
padding: "5px 10px",
fontSize: "larger",
}

const separatorStyle = {
flexGrow: 1,
margin: "0 5px",
borderRight: "1px solid grey",
}

const regexPreAndSuffixStyle = {
height: "100%",
fontSize: "larger",
color: "grey",
alignSelf: "center",
}

const btnClassNames = ["btn"]

const classNames = ["operation-filter-input"]
if (isFailed) classNames.push("failed")
if (isLoading) classNames.push("loading")

const makeActiveBtn = style => ({ ...style, color: "#49cc90", borderColor: "#49cc90" })

let regexBtnStyle
if (isRegexFilter) {
regexBtnStyle = makeActiveBtn(btnStyle)
} else {
regexBtnStyle = { ...btnStyle }
regexBtnStyle.border = "none"
}

let matchCaseBtnStyle
if (filterConfig.matchCase) {
matchCaseBtnStyle = makeActiveBtn(btnStyle)
} else {
matchCaseBtnStyle = { ...btnStyle }
matchCaseBtnStyle.border = "none"
}

let matchWordsBtnStyle
if (filterConfig.matchWords) {
matchWordsBtnStyle = makeActiveBtn(btnStyle)
} else {
matchWordsBtnStyle = { ...btnStyle }
matchWordsBtnStyle.border = "none"
}

return (
<div>
{filter === null || filter === false || filter === "false" ? null :
<div className="filter-container">
<Col className="filter wrapper" mobile={12}>
<input className={classNames.join(" ")} placeholder="Filter by tag" type="text"
onChange={this.onFilterChange} value={filter === true || filter === "true" ? "" : filter}
disabled={isLoading}/>
<form style={formStyle}>
{isRegexFilter && (
<div style={{ ...regexPreAndSuffixStyle, marginLeft: "5px" }}>
/
</div>
)}
<span ref={(r) => this.getRef(r)} role={"textbox"} contentEditable={!isLoading} style={inputStyle}
className={classNames.join(" ")} placeholder={`Filter by ${filterConfig.searchLocation}`}
onClick={() => this.setState({ savedCursorOffset: document.getSelection().focusOffset })}
onFocus={() => this.setState({ savedCursorOffset: document.getSelection().focusOffset })}
onInput={this.onFilterChange} />
{isRegexFilter && (
<div style={{ ...regexPreAndSuffixStyle, marginRight: "5px" }}>
/{!filterConfig.matchCase ? "i" : null}
</div>
)}
<div style={separatorStyle} onClick={() => {
this.inputRef.focus()
this.moveCaret(document, filter.length)
}} />
<button title="Match Case" onClick={this.onMatchCaseToggle} style={matchCaseBtnStyle} type="button"
className={btnClassNames.join(" ")}>Aa
</button>
<button disabled={isRegexFilter} title="Match Whole Word" onClick={this.onMatchWordsToggle}
style={matchWordsBtnStyle} type="button"
className={btnClassNames.join(" ")}>W
</button>
<button title="Use Regular Expression" onClick={this.onRegexToggle} style={regexBtnStyle}
type="button"
className={btnClassNames.join(" ")}>.*
</button>
<select className="location-select" value={filterConfig.searchLocation} onChange={this.onSearchLocationChange}>
<option value="tag">tag</option>
<option value="route">route</option>
</select>
</form>
</Col>
</div>
}
</div>
)
}

getRef(r) {
this.inputRef = r
}
}
9 changes: 8 additions & 1 deletion src/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export default function SwaggerUI(opts) {
docExpansion: "list",
maxDisplayedTags: null,
filter: null,
filterConfig: {
isRegexFilter: false,
matchCase: true,
matchWords: false,
searchLocation: "tag",
},
validatorUrl: "https://validator.swagger.io/validator",
oauth2RedirectUrl: `${window.location.protocol}//${window.location.host}/oauth2-redirect.html`,
persistAuthorization: false,
Expand Down Expand Up @@ -101,7 +107,8 @@ export default function SwaggerUI(opts) {
state: deepExtend({
layout: {
layout: constructorConfig.layout,
filter: constructorConfig.filter
filter: constructorConfig.filter,
filterConfig: constructorConfig.filterConfig,
},
spec: {
spec: "",
Expand Down
Loading