From 4cb93e629df420eee4815af6457d319321408f79 Mon Sep 17 00:00:00 2001 From: mathis-m Date: Sun, 27 Dec 2020 21:24:34 +0100 Subject: [PATCH 1/7] feat: filterConfig (flags: match-case, match-words, regex) introduce filterConfig to activate case-, words-, regex-matching --- src/core/components/operations.jsx | 3 +- src/core/containers/filter.jsx | 206 ++++++++++++++++++++++++++- src/core/index.js | 8 +- src/core/plugins/filter/opsFilter.js | 49 ++++++- src/core/plugins/layout/actions.js | 8 ++ src/core/plugins/layout/reducers.js | 7 +- src/core/plugins/layout/selectors.js | 1 + src/style/_layout.scss | 2 - 8 files changed, 269 insertions(+), 15 deletions(-) diff --git a/src/core/components/operations.jsx b/src/core/components/operations.jsx index b50e2afb4cf..d377a3bdbc8 100644 --- a/src/core/components/operations.jsx +++ b/src/core/components/operations.jsx @@ -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) } } diff --git a/src/core/containers/filter.jsx b/src/core/containers/filter.jsx index 872297f055e..8beba1456a2 100644 --- a/src/core/containers/filter.jsx +++ b/src/core/containers/filter.jsx @@ -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, @@ -10,35 +17,220 @@ 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() === "
") { + 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, 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() + } + + moveCaret(win, charCount) { + let sel, range + if (win.getSelection) { + // IE9+ and other browsers + sel = win.getSelection() + if (sel.rangeCount > 0) { + const textNode = sel.focusNode + 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 + 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", + } + + 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 (
{filter === null || filter === false || filter === "false" ? null :
- +
+ {isRegexFilter && ( +
+ / +
+ )} + this.getRef(r)} role={"textbox"} contentEditable={!isLoading} style={inputStyle} + className={classNames.join(" ")} placeholder="Filter by tag" + onClick={() => this.setState({ savedCursorOffset: document.getSelection().focusOffset })} + onFocus={() => this.setState({ savedCursorOffset: document.getSelection().focusOffset })} + onInput={this.onFilterChange} /> + {isRegexFilter && ( +
+ /{!filterConfig.matchCase ? "i" : null} +
+ )} +
{ + this.inputRef.focus() + this.moveCaret(document, filter.length) + }} /> + + + +
}
) } + + getRef(r) { + this.inputRef = r + } } diff --git a/src/core/index.js b/src/core/index.js index 1007d87c740..79675948101 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -35,6 +35,11 @@ export default function SwaggerUI(opts) { docExpansion: "list", maxDisplayedTags: null, filter: null, + filterConfig: { + isRegexFilter: false, + matchCase: true, + matchWords: false, + }, validatorUrl: "https://validator.swagger.io/validator", oauth2RedirectUrl: `${window.location.protocol}//${window.location.host}/oauth2-redirect.html`, persistAuthorization: false, @@ -101,7 +106,8 @@ export default function SwaggerUI(opts) { state: deepExtend({ layout: { layout: constructorConfig.layout, - filter: constructorConfig.filter + filter: constructorConfig.filter, + filterConfig: constructorConfig.filterConfig, }, spec: { spec: "", diff --git a/src/core/plugins/filter/opsFilter.js b/src/core/plugins/filter/opsFilter.js index fecf262f40d..0fa0cae9107 100644 --- a/src/core/plugins/filter/opsFilter.js +++ b/src/core/plugins/filter/opsFilter.js @@ -1,3 +1,48 @@ -export default function(taggedOps, phrase) { - return taggedOps.filter((tagObj, tag) => tag.indexOf(phrase) !== -1) +import { isFunc } from "../../utils" + +export default function(taggedOps, phrase, filterConfig = { + isRegexFilter: false, + matchCase: true, + matchWords: false, +}) { + if(isFunc(filterConfig.toJS)) { + filterConfig = filterConfig.toJS() + } + if(phrase === "") { + return taggedOps + } + if (filterConfig.isRegexFilter) { + let expr + try { + expr = new RegExp( + filterConfig.matchWords + ? `\\b${phrase}\\b` + : phrase, + !filterConfig.matchCase ? "i" : "", + ) + } catch { + // noop + } + if (expr) { + return taggedOps.filter((tagObj, tag) => expr.test(tag)) + } + } + let isMatch = (tag) => tag.indexOf(phrase) !== -1 + if (filterConfig.matchWords) { + isMatch = (tag) => { + const index = tag.indexOf(phrase) + if (index !== -1) { + return (index === 0 || tag[index - 1] === " ") && + (index + phrase.length === tag.length || tag[index + phrase.length] === " ") + } + return false + } + } + if (!filterConfig.matchCase) { + phrase = phrase.toLowerCase() + } + return taggedOps + .filter((tagObj, tag) => isMatch(!filterConfig.matchCase + ? tag.toLowerCase() + : tag)) } diff --git a/src/core/plugins/layout/actions.js b/src/core/plugins/layout/actions.js index 987395b0662..c575ddd47f1 100644 --- a/src/core/plugins/layout/actions.js +++ b/src/core/plugins/layout/actions.js @@ -2,6 +2,7 @@ import { normalizeArray } from "core/utils" export const UPDATE_LAYOUT = "layout_update_layout" export const UPDATE_FILTER = "layout_update_filter" +export const UPDATE_FILTER_CONFIG = "layout_update_filter_config" export const UPDATE_MODE = "layout_update_mode" export const SHOW = "layout_show" @@ -21,6 +22,13 @@ export function updateFilter(filter) { } } +export function updateFilterConfig(filterConfig) { + return { + type: UPDATE_FILTER_CONFIG, + payload: filterConfig + } +} + export function show(thing, shown=true) { thing = normalizeArray(thing) return { diff --git a/src/core/plugins/layout/reducers.js b/src/core/plugins/layout/reducers.js index aa067abdfd0..b005720c62a 100644 --- a/src/core/plugins/layout/reducers.js +++ b/src/core/plugins/layout/reducers.js @@ -3,7 +3,8 @@ import { UPDATE_LAYOUT, UPDATE_FILTER, UPDATE_MODE, - SHOW + SHOW, + UPDATE_FILTER_CONFIG, } from "./actions" export default { @@ -12,6 +13,8 @@ export default { [UPDATE_FILTER]: (state, action) => state.set("filter", action.payload), + [UPDATE_FILTER_CONFIG]: (state, action) => state.set("filterConfig", action.payload), + [SHOW]: (state, action) => { const isShown = action.payload.shown // This is one way to serialize an array, another (preferred) is to convert to json-pointer @@ -27,6 +30,6 @@ export default { let thing = action.payload.thing let mode = action.payload.mode return state.setIn(["modes"].concat(thing), (mode || "") + "") - } + }, } diff --git a/src/core/plugins/layout/selectors.js b/src/core/plugins/layout/selectors.js index 7d75d37dc88..ecf60b287fa 100644 --- a/src/core/plugins/layout/selectors.js +++ b/src/core/plugins/layout/selectors.js @@ -7,6 +7,7 @@ const state = state => state export const current = state => state.get("layout") export const currentFilter = state => state.get("filter") +export const currentFilterConfig = state => state.get("filterConfig") export const isShown = (state, thing, def) => { thing = normalizeArray(thing) diff --git a/src/style/_layout.scss b/src/style/_layout.scss index 91493496fee..8f2bb691c64 100644 --- a/src/style/_layout.scss +++ b/src/style/_layout.scss @@ -417,10 +417,8 @@ { .operation-filter-input { - width: 100%; margin: 20px 0; padding: 10px 10px; - border: 2px solid $operational-filter-input-border-color; } } From 0213a360899eac61d0af177e3dd864a7d0f9228a Mon Sep 17 00:00:00 2001 From: mathis-m Date: Wed, 30 Dec 2020 02:00:39 +0100 Subject: [PATCH 2/7] fix(style): contenteditable placeholder --- src/style/_layout.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/style/_layout.scss b/src/style/_layout.scss index 8f2bb691c64..9fe9ef735cc 100644 --- a/src/style/_layout.scss +++ b/src/style/_layout.scss @@ -421,6 +421,11 @@ padding: 10px 10px; border: 2px solid $operational-filter-input-border-color; } + [contenteditable][placeholder]:empty:before { + content: attr(placeholder); + color: gray; + background-color: transparent; + } } .filter, .download-url-wrapper From 986324cb433aa0da3cea943c306bca7df1c6da60 Mon Sep 17 00:00:00 2001 From: mathis-m Date: Sun, 27 Dec 2020 21:55:31 +0100 Subject: [PATCH 3/7] docs: filterConfig configuratiuon options --- docs/customization/plug-points.md | 2 +- docs/usage/configuration.md | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/customization/plug-points.md b/docs/customization/plug-points.md index 9d7303bb1e7..e7ab11abb5c 100644 --- a/docs/customization/plug-points.md +++ b/docs/customization/plug-points.md @@ -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) => { diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index 77740567fd6..ad3f2b63ab7 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -53,7 +53,11 @@ Parameter name | Docker variable | Description `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.) `displayRequestDuration` | `DISPLAY_REQUEST_DURATION` | `Boolean=false`. Controls the display of the request duration (in milliseconds) for "Try it out" requests. `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). -`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. +`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. +`filterConfig` | _Unavailable_ | `Object={}`. A JavaScript object describing how to filter. +`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. +`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. +`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. `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. `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. `showExtensions` | `SHOW_EXTENSIONS` | `Boolean=false`. Controls the display of vendor extension (`x-`) fields and values for Operations, Parameters, Responses, and Schema. @@ -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' } ] -``` \ No newline at end of file +``` From 5cf61dfb6cc6f83acb5379a20e857322a78b0651 Mon Sep 17 00:00:00 2001 From: mathis-m Date: Sun, 27 Dec 2020 22:10:43 +0100 Subject: [PATCH 4/7] fix(test): adjust suts to provide currentFilterConfig --- test/unit/components/filter.jsx | 18 +++++++++++++++++- test/unit/components/operations.jsx | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/test/unit/components/filter.jsx b/test/unit/components/filter.jsx index 6be672df3b3..a0a47435a20 100644 --- a/test/unit/components/filter.jsx +++ b/test/unit/components/filter.jsx @@ -10,7 +10,8 @@ describe("", function(){ loadingStatus() {} }, layoutSelectors: { - currentFilter() {} + currentFilter() {}, + currentFilterConfig() {} }, getComponent: () => {return Col} } @@ -21,6 +22,11 @@ describe("", function(){ let props = {...mockedProps} props.layoutSelectors = {...mockedProps.specSelectors} props.layoutSelectors.currentFilter = function() {return true} + props.layoutSelectors.currentFilterConfig = function() {return { + isRegexFilter: false, + matchCase: true, + matchWords: false, + }} // When let wrapper = mount() @@ -36,6 +42,11 @@ describe("", function(){ let props = {...mockedProps} props.layoutSelectors = {...mockedProps.specSelectors} props.layoutSelectors.currentFilter = function() {return null} + props.layoutSelectors.currentFilterConfig = function() {return { + isRegexFilter: false, + matchCase: true, + matchWords: false, + }} // When let wrapper = mount() @@ -51,6 +62,11 @@ describe("", function(){ let props = {...mockedProps} props.layoutSelectors = {...mockedProps.specSelectors} props.layoutSelectors.currentFilter = function() {return false} + props.layoutSelectors.currentFilterConfig = function() {return { + isRegexFilter: false, + matchCase: true, + matchWords: false, + }} // When let wrapper = mount() diff --git a/test/unit/components/operations.jsx b/test/unit/components/operations.jsx index d8295aa332f..b6c5be8d9fc 100644 --- a/test/unit/components/operations.jsx +++ b/test/unit/components/operations.jsx @@ -53,6 +53,13 @@ describe("", function(){ currentFilter() { return null }, + currentFilterConfig() { + return { + isRegexFilter: false, + matchCase: true, + matchWords: false, + } + }, isShown() { return true }, @@ -108,6 +115,13 @@ describe("", function(){ currentFilter() { return null }, + currentFilterConfig() { + return { + isRegexFilter: false, + matchCase: true, + matchWords: false, + } + }, isShown() { return true }, From 2af0b7152144d364b33668e26ef5f6881e37bdd4 Mon Sep 17 00:00:00 2001 From: mathis-m Date: Wed, 30 Dec 2020 00:36:17 +0100 Subject: [PATCH 5/7] fix(test): check for text instead of value input is now span contenteditable --- test/e2e-cypress/tests/bugs/6276.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e-cypress/tests/bugs/6276.js b/test/e2e-cypress/tests/bugs/6276.js index e26dbd72b69..a04189427b3 100644 --- a/test/e2e-cypress/tests/bugs/6276.js +++ b/test/e2e-cypress/tests/bugs/6276.js @@ -31,7 +31,7 @@ describe("#6276: Query parameter filter=true is filtering by the value 'true'", cy.visit("/?url=/documents/petstore.swagger.yaml&filter=pet") .get(".operation-filter-input") .should("exist") - .should("have.value", "pet") + .should("have.text", "pet") .get(".opblock-tag[data-tag='pet']") .should("exist") .get(".opblock-tag[data-tag='store']") From 575bc621b889016cdc8f5db39db359630d28f1eb Mon Sep 17 00:00:00 2001 From: mathis-m Date: Mon, 28 Dec 2020 02:22:33 +0100 Subject: [PATCH 6/7] test: test opsFilter related to filterConfig --- test/unit/core/plugins/filter/opsFilter.js | 160 +++++++++++++++++++-- 1 file changed, 149 insertions(+), 11 deletions(-) diff --git a/test/unit/core/plugins/filter/opsFilter.js b/test/unit/core/plugins/filter/opsFilter.js index d64ce609ba4..f7adb2f9be3 100644 --- a/test/unit/core/plugins/filter/opsFilter.js +++ b/test/unit/core/plugins/filter/opsFilter.js @@ -1,24 +1,162 @@ import { Map } from "immutable" import opsFilter from "corePlugins/filter/opsFilter" -describe("opsFilter", function() { - const taggedOps = Map([["pet"], ["store"], ["user"]]) +describe("opsFilter", () => { + const taggedOps = Map([["pet"], ["store"], ["user"], ["user word"]]) - it("should filter taggedOps by tag name", function () { - const filtered = opsFilter(taggedOps, "sto") + describe("with default filterConfig", () => { + it("should filter taggedOps by tag name", () => { + const filtered = opsFilter(taggedOps, "sto") - expect(filtered.size).toEqual(1) + expect(filtered.size).toEqual(1) + }) + + it("should filter taggedOps using case sensitive matching", () => { + const filtered = opsFilter(taggedOps, "Sto") + + expect(filtered.size).toEqual(0) + }) + + it("should return all taggedOps when search phrase is empty", () => { + const filtered = opsFilter(taggedOps, "") + + expect(filtered.size).toEqual(taggedOps.size) + }) + + it("should return empty result when there is no match", () => { + const filtered = opsFilter(taggedOps, "NoMatch") + + expect(filtered.size).toEqual(0) + }) + + it("should not use regex matching", () => { + const filtered = opsFilter(taggedOps, ".*") + + expect(filtered.size).toEqual(0) + }) }) - it("should return all taggedOps when search phrase is empty", function () { - const filtered = opsFilter(taggedOps, "") + describe("with regex matching", () => { + const regexFilterConfig = { + isRegexFilter: true, + matchCase: true, + matchWords: false, + } + it("should filter taggedOps by tag name", () => { + const filtered = opsFilter(taggedOps, "st.", regexFilterConfig) + + expect(filtered.size).toEqual(1) + }) + + it("should filter taggedOps using case sensitive matching", () => { + const filtered = opsFilter(taggedOps, "St.", regexFilterConfig) - expect(filtered.size).toEqual(taggedOps.size) + expect(filtered.size).toEqual(0) + }) + + it("should return all taggedOps when search phrase is empty", () => { + const filtered = opsFilter(taggedOps, "", regexFilterConfig) + + expect(filtered.size).toEqual(taggedOps.size) + }) + + it("should return empty result when there is no match", () => { + const filtered = opsFilter(taggedOps, "NoM.tch", regexFilterConfig) + + expect(filtered.size).toEqual(0) + }) + + it("should use regex matching", () => { + const filtered = opsFilter(taggedOps, ".*", regexFilterConfig) + + expect(filtered.size).toEqual(taggedOps.size) + }) }) - it("should return empty result when there is no match", function () { - const filtered = opsFilter(taggedOps, "NoMatch") + describe("full words matching", () => { + const filterConfig = { + isRegexFilter: false, + matchCase: true, + matchWords: true, + } + const regexFilterConfig = { + isRegexFilter: true, + matchCase: true, + matchWords: true, + } + describe("with default matching", () => { + it("should return empty result if it is a partial match", () => { + const filtered = opsFilter(taggedOps, "sto", filterConfig) + + expect(filtered.size).toEqual(0) + }) + + it("should return full words match result, when string boundary == word boundary", () => { + const filtered = opsFilter(taggedOps, "store", filterConfig) + + expect(filtered.size).toEqual(1) + }) + + it("should return full words match result, when space == word boundary", () => { + const filtered = opsFilter(taggedOps, "user", filterConfig) + + expect(filtered.size).toEqual(2) + }) + + it("should filter taggedOps using case sensitive matching", () => { + const filtered = opsFilter(taggedOps, "Store", filterConfig) + + expect(filtered.size).toEqual(0) + }) + + it("should return all taggedOps when search phrase is empty", () => { + const filtered = opsFilter(taggedOps, "", filterConfig) + + expect(filtered.size).toEqual(taggedOps.size) + }) + + it("should return empty result when there is no match", () => { + const filtered = opsFilter(taggedOps, "NoMatch", filterConfig) + + expect(filtered.size).toEqual(0) + }) + }) + describe("with regex matching", () => { + it("should return empty result if it is a partial match", () => { + const filtered = opsFilter(taggedOps, "st.", regexFilterConfig) + + expect(filtered.size).toEqual(0) + }) + + it("should return full words match result, when string boundary == word boundary", () => { + const filtered = opsFilter(taggedOps, "st.re", regexFilterConfig) + + expect(filtered.size).toEqual(1) + }) + + it("should return full words match result, when space == word boundary", () => { + const filtered = opsFilter(taggedOps, "u.er", regexFilterConfig) + + expect(filtered.size).toEqual(2) + }) + + + it("should filter taggedOps using case sensitive matching", () => { + const filtered = opsFilter(taggedOps, "St.re", regexFilterConfig) + + expect(filtered.size).toEqual(0) + }) + + it("should return all taggedOps when search phrase is empty", () => { + const filtered = opsFilter(taggedOps, "", regexFilterConfig) + + expect(filtered.size).toEqual(taggedOps.size) + }) + it("should return empty result when there is no match", () => { + const filtered = opsFilter(taggedOps, "NoM.tch", filterConfig) - expect(filtered.size).toEqual(0) + expect(filtered.size).toEqual(0) + }) + }) }) }) From 59956a94a55e91603dd28523578cbccac3987eae Mon Sep 17 00:00:00 2001 From: mathis-m Date: Wed, 30 Dec 2020 21:36:00 +0100 Subject: [PATCH 7/7] feat(advancedFilter): filter tags or route added select to switch between tag and route search --- src/core/containers/filter.jsx | 21 ++++++++++++--- src/core/index.js | 1 + src/core/plugins/filter/opsFilter.js | 38 ++++++++++++++++++++++++---- src/style/_layout.scss | 18 +++++++++---- 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/core/containers/filter.jsx b/src/core/containers/filter.jsx index 8beba1456a2..10bd8a64eb3 100644 --- a/src/core/containers/filter.jsx +++ b/src/core/containers/filter.jsx @@ -66,7 +66,7 @@ export default class FilterContainer extends React.Component { restoreInputFocus() { this.inputRef.focus() if (this.state.savedCursorOffset !== undefined) { - document.getSelection().collapse(this.inputRef, this.state.savedCursorOffset) + document.getSelection().collapse(this.inputRef.firstChild, this.state.savedCursorOffset) } } @@ -78,6 +78,7 @@ export default class FilterContainer extends React.Component { }) this.restoreInputFocus() } + onMatchWordsToggle = () => { const currentFilterConfig = this.getFilterConfig() this.props.layoutActions.updateFilterConfig({ @@ -87,13 +88,22 @@ export default class FilterContainer extends React.Component { this.restoreInputFocus() } + onSearchLocationChange = (locationOption) => { + const currentFilterConfig = this.getFilterConfig() + this.props.layoutActions.updateFilterConfig({ + ...currentFilterConfig, + searchLocation: locationOption.target.value, + }) + this.restoreInputFocus() + } + moveCaret(win, charCount) { let sel, range if (win.getSelection) { // IE9+ and other browsers sel = win.getSelection() if (sel.rangeCount > 0) { - const textNode = sel.focusNode + const textNode = this.inputRef.firstChild const newOffset = sel.focusOffset + charCount sel.collapse(textNode, Math.min(textNode.length, newOffset)) } @@ -124,6 +134,7 @@ export default class FilterContainer extends React.Component { border: "1px solid grey", margin: "20px 0", background: "white", + padding: "5px", } const inputStyle = { @@ -198,7 +209,7 @@ export default class FilterContainer extends React.Component {
)} this.getRef(r)} role={"textbox"} contentEditable={!isLoading} style={inputStyle} - className={classNames.join(" ")} placeholder="Filter by tag" + 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} /> @@ -222,6 +233,10 @@ export default class FilterContainer extends React.Component { type="button" className={btnClassNames.join(" ")}>.* + diff --git a/src/core/index.js b/src/core/index.js index 79675948101..2ccb3b8c4d2 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -39,6 +39,7 @@ export default function SwaggerUI(opts) { isRegexFilter: false, matchCase: true, matchWords: false, + searchLocation: "tag", }, validatorUrl: "https://validator.swagger.io/validator", oauth2RedirectUrl: `${window.location.protocol}//${window.location.host}/oauth2-redirect.html`, diff --git a/src/core/plugins/filter/opsFilter.js b/src/core/plugins/filter/opsFilter.js index 0fa0cae9107..6e0e01c2f3a 100644 --- a/src/core/plugins/filter/opsFilter.js +++ b/src/core/plugins/filter/opsFilter.js @@ -4,10 +4,12 @@ export default function(taggedOps, phrase, filterConfig = { isRegexFilter: false, matchCase: true, matchWords: false, + searchLocation: "tag" }) { if(isFunc(filterConfig.toJS)) { filterConfig = filterConfig.toJS() } + filterConfig.searchLocation ??= "tag" if(phrase === "") { return taggedOps } @@ -24,7 +26,19 @@ export default function(taggedOps, phrase, filterConfig = { // noop } if (expr) { - return taggedOps.filter((tagObj, tag) => expr.test(tag)) + switch (filterConfig.searchLocation) { + case "tag": + return taggedOps.filter((tagObj, tag) => expr.test(tag)) + case "route": + return taggedOps.mapEntries(([k,v]) => { + const newValue = v.set( + "operations", + v.get("operations") + .filter(op => expr.test(op.get("path"))) + ) + return [k, newValue] + }).filter((tagObj) => tagObj.get("operations").size !== 0) + } } } let isMatch = (tag) => tag.indexOf(phrase) !== -1 @@ -41,8 +55,22 @@ export default function(taggedOps, phrase, filterConfig = { if (!filterConfig.matchCase) { phrase = phrase.toLowerCase() } - return taggedOps - .filter((tagObj, tag) => isMatch(!filterConfig.matchCase - ? tag.toLowerCase() - : tag)) + switch (filterConfig.searchLocation) { + case "tag": + return taggedOps + .filter((tagObj, tag) => isMatch(!filterConfig.matchCase + ? tag.toLowerCase() + : tag)) + case "route": + return taggedOps.mapEntries(([k,v]) => { + const newValue = v.set( + "operations", + v.get("operations") + .filter(op => isMatch(!filterConfig.matchCase + ? op.get("path").toLowerCase() + : op.get("path"))) + ) + return [k, newValue] + }).filter((tagObj) => tagObj.get("operations").size !== 0) + } } diff --git a/src/style/_layout.scss b/src/style/_layout.scss index 9fe9ef735cc..4e94fa6b3e9 100644 --- a/src/style/_layout.scss +++ b/src/style/_layout.scss @@ -421,11 +421,19 @@ padding: 10px 10px; border: 2px solid $operational-filter-input-border-color; } - [contenteditable][placeholder]:empty:before { - content: attr(placeholder); - color: gray; - background-color: transparent; - } + + [contenteditable][placeholder]:empty:before { + content: attr(placeholder); + color: gray; + background-color: transparent; + } + + .location-select { + border: none; + outline: none; + background-color: transparent; + box-shadow: 0 1px 2px rgba(0,0,0,.1); + } } .filter, .download-url-wrapper