diff --git a/sphinx_search/_static/js/rtd_sphinx_search.js b/sphinx_search/_static/js/rtd_sphinx_search.js index 7143cf0..f3b90ab 100644 --- a/sphinx_search/_static/js/rtd_sphinx_search.js +++ b/sphinx_search/_static/js/rtd_sphinx_search.js @@ -46,20 +46,64 @@ const debounce = (func, wait) => { /** * Take an object as parameter and convert it to * url params string. - * Eg. if obj = { 'a': 1, 'b': 2 }, then it will return - * the string a=1&b=2. + * Eg. if obj = { 'a': 1, 'b': 2, 'c': ['hello', 'world'] }, then it will return + * the string a=1&b=2&c=hello,world * * @param {Object} obj the object to be converted - * @return {String} object in url params form + * @return {String|Array} object in url params form */ const convertObjToUrlParams = obj => { - const params = Object.keys(obj) - .map(function(key) { - const s = key + "=" + obj[key]; + let params = Object.keys(obj).map(function(key) { + if (_is_string(key)) { + const s = key + "=" + encodeURI(obj[key]); return s; - }) - .join("&"); - return params; + } + }); + + // removing empty strings from the 'params' array + let final_params = []; + for (let i = 0; i < params.length; ++i) { + if (_is_string(params[i])) { + final_params.push(params[i]); + } + } + if (final_params.length === 1) { + return final_params[0]; + } else { + let final_url_params = final_params.join("&"); + return final_url_params; + } +}; + +/** + * Adds/removes "rtd_search" url parameter to the url. + */ +const updateUrl = () => { + let origin = window.location.origin; + let path = window.location.pathname; + let url_params = $.getQueryParameters(); + let hash = window.location.hash; + + // SEARCH_QUERY should not be an empty string + if (_is_string(SEARCH_QUERY)) { + url_params.rtd_search = SEARCH_QUERY; + } else { + delete url_params.rtd_search; + } + + let window_location_search = convertObjToUrlParams(url_params) + hash; + + // this happens during the tests, + // when window.location.origin is "null" in Firefox + // then correct URL is contained by window.location.pathname + // which starts with "file://" + let url = path + "?" + window_location_search; + if (origin.substring(0, 4) === "http") { + url = origin + url; + } + + // update url + window.history.pushState({}, null, url); }; /** @@ -552,8 +596,10 @@ const generateAndReturnInitialHtml = () => { /** * Opens the search modal. + * + * @param {String} custom_query if a custom query is provided, initialise the value of input field with it */ -const showSearchModal = () => { +const showSearchModal = custom_query => { // removes previous results (if there are any). removeResults(); @@ -568,7 +614,14 @@ const showSearchModal = () => { ".search__outer__input" ); if (search_outer_input !== null) { - search_outer_input.value = ""; + if ( + typeof custom_query !== "undefined" && + _is_string(custom_query) + ) { + search_outer_input.value = custom_query; + } else { + search_outer_input.value = ""; + } search_outer_input.focus(); } }); @@ -588,6 +641,12 @@ const removeSearchModal = () => { search_outer_input.blur(); } + // reset SEARCH_QUERY + SEARCH_QUERY = ""; + + // update url (remove 'rtd_search' param) + updateUrl(); + $(".search__outer__wrapper").fadeOut(400); }; @@ -628,7 +687,7 @@ window.addEventListener("DOMContentLoaded", evt => { COUNT = 0; let search_params = { - q: encodeURIComponent(SEARCH_QUERY), + q: SEARCH_QUERY, project: project, version: version, language: language @@ -653,6 +712,9 @@ window.addEventListener("DOMContentLoaded", evt => { // is debounced here. debounce(removeResults, 600)(); } + + // update URL + updateUrl(); }); search_outer_input.addEventListener("keydown", e => { @@ -723,5 +785,19 @@ window.addEventListener("DOMContentLoaded", evt => { removeSearchModal(); } }); + + // if "rtd_search" is present in URL parameters, + // then open the search modal and show the results + // for the value of "rtd_search" + let url_params = $.getQueryParameters(); + if (_is_array(url_params.rtd_search)) { + let query = decodeURIComponent(url_params.rtd_search); + showSearchModal(query); + search_outer_input.value = query; + + let event = document.createEvent("Event"); + event.initEvent("input", true, true); + search_outer_input.dispatchEvent(event); + } } }); diff --git a/sphinx_search/_static/js/rtd_sphinx_search.min.js b/sphinx_search/_static/js/rtd_sphinx_search.min.js index fff3527..9ec3b9b 100644 --- a/sphinx_search/_static/js/rtd_sphinx_search.min.js +++ b/sphinx_search/_static/js/rtd_sphinx_search.min.js @@ -1 +1 @@ -"use strict";var MAX_SUGGESTIONS=50,MAX_SECTION_RESULTS=3,MAX_SUBSTRING_LIMIT=100,TOTAL_PAGE_RESULTS=0,SEARCH_QUERY="",COUNT=0,debounce=function(n,r){function e(){var e=this,t=arguments;clearTimeout(i),i=setTimeout(function(){return n.apply(e,t)},r)}var i;return e.cancel=function(){clearTimeout(i),i=null},e},convertObjToUrlParams=function(t){return Object.keys(t).map(function(e){return e+"="+t[e]}).join("&")},createDomNode=function(e,t){var n=document.createElement(e);for(var r in t)n.setAttribute(r,t[r]);return n},_is_string=function(e){return"string"==typeof e&&0
<%= section_subheading %> <% for (var i = 0; i < section_content.length; ++i) { %>

<%= section_content[i] %>

<% } %>

',{section_link:a,section_id:c,section_subheading:n,section_content:i})},getHighlightListData=function(e,t){return!!_is_array(e[t])&&e[t]},get_domain_html=function(e,t){var n="".concat(t,"#").concat(e._source.anchor),r=e._source.role_name,i=e._source.type_display,o=e._source.doc_display,s=e._source.display_name,a=e._source.name;if(void 0!==e.highlight&&null!==e.highlight){var c=e.highlight,l=getHighlightListData(c,"domains.name"),u=getHighlightListData(c,"domains.display_name"),_=getHighlightListData(c,"domains.type_display");l&&(a=l[0]),u&&(s=u[0]),_&&(i=_[0])}var d="";_is_string(i)&&(d+=i+" -- "),_is_string(a)&&(d+=a+" "),_is_string(o)&&(d+="-- in "+o);var h="";h=_is_string(s)?"("+r+") "+s:r;var v="hit__"+COUNT;return $u.template('
<%= domain_subheading %>

<%= domain_content %>


',{domain_link:n,domain_id:v,domain_content:d,domain_subheading:h})},generateSingleResult=function(e,t){var n=createDomNode("div"),r="".concat(e.link).concat(DOCUMENTATION_OPTIONS.FILE_SUFFIX)+"?highlight="+encodeURIComponent(SEARCH_QUERY),i=e.title;void 0!==e.highlight&&null!==e.highlight&&void 0!==e.highlight.title&&null!==e.highlight.title&&(i=e.highlight.title),t!==e.project&&(i+=" "+$u.template(' (from project <%= project %>) ',{project:e.project})),i+="
",n.innerHTML+=$u.template('

<%= page_title %>

',{page_link:r,page_title:i});for(var o=0;oSearching ....",o.appendChild(t);var n=function(){$.ajax({url:e,crossDomain:!0,xhrFields:{withCredentials:!0},complete:function(e,t){if("success"===t||void 0!==e.responseJSON)if(0
\x3c!--?xml version="1.0" encoding="UTF-8"?--\x3e
'},showSearchModal=function(){removeResults(),getInputField().blur(),$(".search__outer__wrapper").fadeIn(400,function(){var e=document.querySelector(".search__outer__input");null!==e&&(e.value="",e.focus())})},removeSearchModal=function(){removeResults();var e=document.querySelector(".search__outer__input");null!==e&&(e.value="",e.blur()),$(".search__outer__wrapper").fadeOut(400)};window.addEventListener("DOMContentLoaded",function(e){if(void 0!==READTHEDOCS_DATA){var r=READTHEDOCS_DATA.project,i=READTHEDOCS_DATA.version,o=READTHEDOCS_DATA.language||"en",s=READTHEDOCS_DATA.api_host,t=generateAndReturnInitialHtml();document.body.innerHTML+=t;var n=document.querySelector(".search__outer__wrapper"),a=document.querySelector(".search__outer__input"),c=document.querySelector(".search__cross"),l=0,u=null,_=getInputField();_.addEventListener("focus",function(e){showSearchModal()}),a.addEventListener("input",function(e){SEARCH_QUERY=e.target.value,COUNT=0;var t={q:encodeURIComponent(SEARCH_QUERY),project:r,version:i,language:o},n=s+"/api/v2/docsearch/?"+convertObjToUrlParams(t);"string"==typeof SEARCH_QUERY&&0
<%= section_subheading %> <% for (var i = 0; i < section_content.length; ++i) { %>

<%= section_content[i] %>

<% } %>

',{section_link:s,section_id:c,section_subheading:r,section_content:i})},getHighlightListData=function(e,t){return!!_is_array(e[t])&&e[t]},get_domain_html=function(e,t){var r="".concat(t,"#").concat(e._source.anchor),n=e._source.role_name,i=e._source.type_display,a=e._source.doc_display,o=e._source.display_name,s=e._source.name;if(void 0!==e.highlight&&null!==e.highlight){var c=e.highlight,l=getHighlightListData(c,"domains.name"),u=getHighlightListData(c,"domains.display_name"),_=getHighlightListData(c,"domains.type_display");l&&(s=l[0]),u&&(o=u[0]),_&&(i=_[0])}var d="";_is_string(i)&&(d+=i+" -- "),_is_string(s)&&(d+=s+" "),_is_string(a)&&(d+="-- in "+a);var h="";h=_is_string(o)?"("+n+") "+o:n;var v="hit__"+COUNT;return $u.template('
<%= domain_subheading %>

<%= domain_content %>


',{domain_link:r,domain_id:v,domain_content:d,domain_subheading:h})},generateSingleResult=function(e,t){var r=createDomNode("div"),n="".concat(e.link).concat(DOCUMENTATION_OPTIONS.FILE_SUFFIX)+"?highlight="+encodeURIComponent(SEARCH_QUERY),i=e.title;void 0!==e.highlight&&null!==e.highlight&&void 0!==e.highlight.title&&null!==e.highlight.title&&(i=e.highlight.title),t!==e.project&&(i+=" "+$u.template(' (from project <%= project %>) ',{project:e.project})),i+="
",r.innerHTML+=$u.template('

<%= page_title %>

',{page_link:n,page_title:i});for(var a=0;aSearching ....",a.appendChild(t);var r=function(){$.ajax({url:e,crossDomain:!0,xhrFields:{withCredentials:!0},complete:function(e,t){if("success"===t||void 0!==e.responseJSON)if(0
\x3c!--?xml version="1.0" encoding="UTF-8"?--\x3e
'},showSearchModal=function(t){removeResults(),getInputField().blur(),$(".search__outer__wrapper").fadeIn(400,function(){var e=document.querySelector(".search__outer__input");null!==e&&(void 0!==t&&_is_string(t)?e.value=t:e.value="",e.focus())})},removeSearchModal=function(){removeResults();var e=document.querySelector(".search__outer__input");null!==e&&(e.value="",e.blur()),SEARCH_QUERY="",updateUrl(),$(".search__outer__wrapper").fadeOut(400)};window.addEventListener("DOMContentLoaded",function(e){if(void 0!==READTHEDOCS_DATA){var r=READTHEDOCS_DATA.project,n=READTHEDOCS_DATA.version,i=READTHEDOCS_DATA.language||"en",a=READTHEDOCS_DATA.api_host,t=generateAndReturnInitialHtml();document.body.innerHTML+=t;var o=document.querySelector(".search__outer__wrapper"),s=document.querySelector(".search__outer__input"),c=document.querySelector(".search__cross"),l=0,u=null,_=getInputField();_.addEventListener("focus",function(e){showSearchModal()}),s.addEventListener("input",function(e){SEARCH_QUERY=e.target.value,COUNT=0;var t=a+"/api/v2/docsearch/?"+convertObjToUrlParams({q:SEARCH_QUERY,project:r,version:n,language:i});"string"==typeof SEARCH_QUERY&&0 - $.ajax = function(params) { - return params.complete( - { - responseJSON: { - results: [] - } - }, - 'success' - ) - } - - ''' - + ajax_func = get_ajax_overwrite_func('zero_results') injected_script = SCRIPT_TAG + ajax_func with InjectJsManager(path, injected_script) as _: @@ -294,18 +276,7 @@ def test_error_msg(selenium, app, status, warning): path = app.outdir / 'index.html' # to test this, we need to override the $.ajax function - ajax_func = ''' - - ''' - + ajax_func = get_ajax_overwrite_func('error') injected_script = SCRIPT_TAG + ajax_func with InjectJsManager(path, injected_script) as _: @@ -342,24 +313,7 @@ def test_searching_msg(selenium, app, status, warning): path = app.outdir / 'index.html' # to test this, we need to override the $.ajax function - # setTimeout is used here to give a real feel of the API call - ajax_func = ''' - - ''' - + ajax_func = get_ajax_overwrite_func('timeout__zero_results') injected_script = SCRIPT_TAG + ajax_func with InjectJsManager(path, injected_script) as _: @@ -406,22 +360,8 @@ def test_results_displayed_to_user(selenium, app, status, warning): app.build() path = app.outdir / 'index.html' - with open(DUMMY_RESULTS, 'r') as f: - dummy_res = f.read() - # to test this, we need to override the $.ajax function - ajax_func = f''' - - ''' - + ajax_func = get_ajax_overwrite_func('dummy_results') injected_script = SCRIPT_TAG + ajax_func with InjectJsManager(path, injected_script) as _: @@ -470,22 +410,8 @@ def test_navigate_results_with_arrow_up_and_down(selenium, app, status, warning) app.build() path = app.outdir / 'index.html' - with open(DUMMY_RESULTS, 'r') as f: - dummy_res = f.read() - # to test this, we need to override the $.ajax function - ajax_func = f''' - - ''' - + ajax_func = get_ajax_overwrite_func('dummy_results') injected_script = SCRIPT_TAG + ajax_func with InjectJsManager(path, injected_script) as _: @@ -548,18 +474,7 @@ def test_enter_button_on_input_field_when_no_result_active(selenium, app, status path = app.outdir / 'index.html' # to test this, we need to override the $.ajax function - ajax_func = ''' - - ''' - + ajax_func = get_ajax_overwrite_func('error') injected_script = SCRIPT_TAG + ajax_func with InjectJsManager(path, injected_script) as _: @@ -571,9 +486,8 @@ def test_enter_button_on_input_field_when_no_result_active(selenium, app, status ) search_outer_input.send_keys('i am searching') search_outer_input.send_keys(Keys.ENTER) - WebDriverWait(selenium, 10).until( - EC.url_matches(app.outdir / 'search.html') + EC.url_contains(app.outdir / 'search.html') ) # enter button should redirect the user to search page @@ -635,3 +549,121 @@ def test_position_search_modal(selenium, app, status, warning): assert ( abs(actual_y - calculated_y) < 10 ), f'difference between calculated and actual y coordinate should not be greater than 10 pixels for {"x".join(map(str, window_size))}' + + +@pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) +def test_writing_query_adds_rtd_search_as_url_param(selenium, app, status, warning): + """Test if the `rtd_search` query param is added to the url when user is searching.""" + app.build() + path = app.outdir / 'index.html' + + # to test this, we need to override the $.ajax function + ajax_func = get_ajax_overwrite_func('error') + injected_script = SCRIPT_TAG + ajax_func + + with InjectJsManager(path, injected_script) as _: + selenium.get(f'file://{path}') + open_search_modal(selenium) + query = 'i am searching' + query_len = len(query) + + assert ( + 'rtd_search=' not in parse.unquote(selenium.current_url) + ), 'rtd_search param must not be present in the url when page loads' + + search_outer_input = selenium.find_element_by_class_name( + 'search__outer__input' + ) + search_outer_input.send_keys(query) + query_param = f'rtd_search={query}' + + assert ( + query_param in parse.unquote(selenium.current_url) + ), 'query param must be present in the url' + + # deleting query from input field + for i in range(query_len): + search_outer_input.send_keys(Keys.BACK_SPACE) + + if i != query_len -1: + + current_query = query[:query_len - i - 1] + current_url = parse.unquote(selenium.current_url) + query_in_url = current_url[current_url.find('rtd_search'):] + + assert ( + f'rtd_search={current_query}' == query_in_url + ) + + assert ( + 'rtd_search=' not in parse.unquote(selenium.current_url) + ), 'rtd_search param must not be present in the url if query is empty' + + +@pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) +def test_modal_open_if_rtd_search_is_present(selenium, app, status, warning): + """Test if search modal opens if `rtd_search` query param is present in the URL.""" + app.build() + path = app.outdir / 'index.html' + + # to test this, we need to override the $.ajax function + ajax_func = get_ajax_overwrite_func('error') + injected_script = SCRIPT_TAG + ajax_func + + with InjectJsManager(path, injected_script) as _: + selenium.get(f'file://{path}?rtd_search=i am searching') + time.sleep(3) # give time to open modal and start searching + + search_outer_wrapper = selenium.find_element_by_class_name( + 'search__outer__wrapper' + ) + search_result_box = selenium.find_element_by_class_name( + 'search__result__box' + ) + + assert ( + search_outer_wrapper.is_displayed() is True + ), 'search modal should displayed when the page loads' + assert ( + search_result_box.text == 'Error Occurred. Please try again.' + ), 'user should be notified that there is error while searching' + assert ( + len(search_result_box.find_elements_by_css_selector('*')) == 0 + ), 'search result box should not have any child elements because there are no results' + + +@pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) +def test_rtd_search_remove_from_url_when_modal_closed(selenium, app, status, warning): + """Test if `rtd_search` query param is removed when the modal is closed.""" + app.build() + path = app.outdir / 'index.html' + + # to test this, we need to override the $.ajax function + ajax_func = get_ajax_overwrite_func('error') + injected_script = SCRIPT_TAG + ajax_func + + with InjectJsManager(path, injected_script) as _: + selenium.get(f'file://{path}?rtd_search=i am searching') + time.sleep(3) # give time to open modal and start searching + + # closing modal + search_outer = selenium.find_element_by_class_name('search__outer') + search_outer_wrapper = selenium.find_element_by_class_name( + 'search__outer__wrapper' + ) + actions = webdriver.common.action_chains.ActionChains(selenium) + actions.move_to_element_with_offset( + search_outer, -10, -10 # -ve offsets to move the mouse away from the search modal + ) + actions.click() + actions.perform() + WebDriverWait(selenium, 10).until( + EC.invisibility_of_element(search_outer_wrapper) + ) + + assert ( + search_outer_wrapper.is_displayed() is False + ), 'search modal should disappear after clicking on backdrop' + assert ( + 'rtd_search=' not in parse.unquote(selenium.current_url) + ), 'rtd_search url param should not be present in the url when the modal is closed.' diff --git a/tests/utils.py b/tests/utils.py index eb1152f..32fa9d5 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,6 +4,11 @@ import os +DUMMY_RESULTS = os.path.join( + os.path.abspath(os.path.dirname(__file__)), + 'dummy_results.json' +) + class InjectJsManager: """ @@ -47,3 +52,96 @@ def set_viewport_size(driver, width, height): height ) driver.set_window_size(*window_size) + + +def get_ajax_overwrite_func(type_, **kwargs): + possible_types = [ + + # return ajax func which results in zero results + 'zero_results', + + # return ajax func which results in error while searching + 'error', + + # return ajax func with a setTimeout of 2000ms (default) and + # results in zero results. + # A possible `timeout` argument can be passed to change the + # waiting time of setTimeout + 'timeout__zero_results', + + # return ajax func with dummy results + 'dummy_results', + ] + + # check if current type_ is passed + assert ( + type_ in possible_types + ), 'wrong type is specified' + + if type_ == 'zero_results': + ajax_func = ''' + + ''' + + elif type_ == 'error': + ajax_func = ''' + + ''' + + elif type_ == 'timeout__zero_results': + timeout = kwargs.get('timeout') or 2000 + + # setTimeout is used here to give a real feel of the API call + ajax_func = f''' + + ''' + + elif type_ == 'dummy_results': + with open(DUMMY_RESULTS, 'r') as f: + dummy_res = f.read() + + ajax_func = f''' + + ''' + + return ajax_func