diff options
author | David O'Regan <doregan@gitlab.com> | 2023-02-01 02:00:59 +0300 |
---|---|---|
committer | David O'Regan <doregan@gitlab.com> | 2023-02-01 02:00:59 +0300 |
commit | d807375a9da030040c5e89661fe9ce8b23c3f496 (patch) | |
tree | 8b2f7006f3aaebb1932c4b4c8e6845e2da1c7437 | |
parent | 03faa372aa443a7ce29c9165815039463d3b0d58 (diff) | |
parent | e67b7d42efefac4bbb566299287e62813a517e4d (diff) |
Merge branch 'sarahg/1369-gps-basic-forms' into 'main'
Add Google search forms
Closes #1369
See merge request https://gitlab.com/gitlab-org/gitlab-docs/-/merge_requests/3482
Merged-by: David O'Regan <doregan@gitlab.com>
Approved-by: David O'Regan <doregan@gitlab.com>
Reviewed-by: David O'Regan <doregan@gitlab.com>
Co-authored-by: Sarah German <sgerman@gitlab.com>
25 files changed, 255 insertions, 39 deletions
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index 1009bbcd..4c37aab1 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -132,6 +132,9 @@ default: - if: $CI_MERGE_REQUEST_ID && $CI_COMMIT_REF_NAME =~ /algolia/ variables: SEARCH_BACKEND: 'algolia' + - if: $CI_MERGE_REQUEST_ID && $CI_COMMIT_REF_NAME =~ /gps/ + variables: + SEARCH_BACKEND: 'google' - if: '$CI_MERGE_REQUEST_ID' - if: '$CI_COMMIT_BRANCH =~ /docs-preview/' # TODO: Remove once no projects create such branch - if: '$CI_PIPELINE_SOURCE == "schedule" && $CHORES_PIPELINE == "true"' @@ -51,7 +51,8 @@ preprocess do end end - config[:search_backend] = ENV['SEARCH_BACKEND'] || 'algolia' + config[:search_backend] = ENV.fetch('SEARCH_BACKEND', 'algolia') + config[:google_search_key] = ENV.fetch('GOOGLE_SEARCH_KEY', '') def get_online_versions uri = URI('https://gitlab.com/gitlab-org/gitlab-docs/-/raw/main/content/versions.json') diff --git a/content/assets/stylesheets/_landing.scss b/content/assets/stylesheets/_landing.scss index b3ad0d86..37f4217c 100644 --- a/content/assets/stylesheets/_landing.scss +++ b/content/assets/stylesheets/_landing.scss @@ -173,14 +173,8 @@ } } - .lunr-search { + .search-form { margin-bottom: 2em; - .btn:not(.gl-search-box-by-click-clear-button) { - background: $landing-gl-purple-500; - } - svg { - fill: $gds-white; - } } } diff --git a/content/assets/stylesheets/stylesheet.scss b/content/assets/stylesheets/stylesheet.scss index 945da539..a13e0248 100644 --- a/content/assets/stylesheets/stylesheet.scss +++ b/content/assets/stylesheets/stylesheet.scss @@ -292,7 +292,7 @@ ol { } } - .lunr-search { + .search-form { .input-group-append { background-color: inherit; } @@ -574,7 +574,7 @@ a.gl-tab-nav-item:hover { } } -.lunr-search { +.search-form { .input-group>.form-control:focus, .gl-search-box-by-click-clear-button { z-index: auto; diff --git a/content/frontend/search/search.js b/content/frontend/search/algolia.js index 65057f99..65057f99 100644 --- a/content/frontend/search/search.js +++ b/content/frontend/search/algolia.js diff --git a/content/frontend/search/components/google_results.vue b/content/frontend/search/components/google_results.vue new file mode 100644 index 00000000..07dca9de --- /dev/null +++ b/content/frontend/search/components/google_results.vue @@ -0,0 +1,116 @@ +<script> +/* global GOOGLE_SEARCH_KEY */ +import { GlSearchBoxByClick, GlLink, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { getSearchQueryFromURL, updateURLParams } from '../search_helpers'; +import { GPS_ENDPOINT, GPS_ID } from '../../services/google_search_api'; + +export default { + components: { + GlSearchBoxByClick, + GlLink, + }, + directives: { + SafeHtml, + }, + data() { + const queryParam = getSearchQueryFromURL(); + return { + query: queryParam || '', + submitted: false, + error: false, + response: {}, + results: [], + }; + }, + computed: { + resultCount() { + const { count, startIndex } = this.response.queries.request[0]; + const end = startIndex - 1 + count; + return `Showing ${startIndex}-${end} of ${this.response.searchInformation.formattedTotalResults} results`; + }, + noResults() { + return this.submitted && !this.results.length && !this.error; + }, + }, + mounted() { + if (this.query) { + this.search(this.query); + } + }, + methods: { + cleanTitle(title) { + return title.replace(' | GitLab', ''); + }, + async fetchGoogleResults() { + let data = {}; + try { + const response = await fetch( + GPS_ENDPOINT + + new URLSearchParams({ + key: GOOGLE_SEARCH_KEY, + cx: GPS_ID, + q: this.query, + }), + ); + data = await response.json(); + if (!response.ok) this.handleError(data.error); + } catch (error) { + this.handleError(error); + } + return data; + }, + handleError(error) { + this.error = true; + throw new Error(`Error code ${error.code}: ${error.message}`); + }, + onSubmit() { + if (this.query) { + this.search(this.query); + } + }, + async search(query) { + this.query = query; + this.response = await this.fetchGoogleResults(); + this.results = this.response.items ? this.response.items : []; + this.submitted = true; + updateURLParams(this.query); + }, + }, +}; +</script> + +<template> + <div class="google-search gl-mb-9"> + <h1>Search</h1> + <gl-search-box-by-click v-model="query" :value="query" @submit="onSubmit" /> + <div v-if="results.length" class="gl-font-sm gl-mb-5"> + {{ resultCount }} + </div> + + <ul + v-if="results.length" + class="gl-list-style-none gl-pl-2 gl-max-w-80" + data-testid="search-results" + > + <li v-for="result in results" :key="result.cacheId" class="gl-mb-5!"> + <gl-link + :href="`${result.link}`" + class="gl-font-lg gl-border-bottom-0! gl-hover-text-decoration-underline:hover gl-mb-2" + >{{ cleanTitle(result.title) }}</gl-link + > + <p v-safe-html="result.htmlSnippet" class="result-snippet"></p> + </li> + </ul> + + <p v-if="noResults" class="gl-py-5">No results found.</p> + <p v-if="error" class="gl-py-5" data-testid="search-error"> + Error fetching results. Please try again later. + </p> + </div> +</template> + +<style scoped> +.result-snippet { + font-size: 0.875rem; +} +</style> diff --git a/content/frontend/search/components/search_page.vue b/content/frontend/search/components/instantsearch_results.vue index 46129c6c..9ea11f00 100644 --- a/content/frontend/search/components/search_page.vue +++ b/content/frontend/search/components/instantsearch_results.vue @@ -4,7 +4,7 @@ import 'instantsearch.css/themes/satellite-min.css'; import { history as historyRouter } from 'instantsearch.js/es/lib/routers'; import { singleIndex as singleIndexMapping } from 'instantsearch.js/es/lib/stateMappings'; import { GlIcon } from '@gitlab/ui'; -import { rewriteAlgoliaResultLinks } from '../search'; +import { rewriteAlgoliaResultLinks } from '../algolia'; export default { components: { diff --git a/content/frontend/search/components/lunr_page.vue b/content/frontend/search/components/lunr_results.vue index 5edcb04c..0cef1b3d 100644 --- a/content/frontend/search/components/lunr_page.vue +++ b/content/frontend/search/components/lunr_results.vue @@ -1,6 +1,7 @@ <script> /* global lunr */ import { GlSearchBoxByClick, GlLink } from '@gitlab/ui'; +import { getSearchQueryFromURL, updateURLParams } from '../search_helpers'; export default { components: { @@ -37,9 +38,9 @@ export default { window.idx = idx; // If we have a query string in the URL, run the search. - const searchParams = new URLSearchParams(window.location.search); - if (searchParams.has('query')) { - this.search(searchParams.get('query')); + const queryParam = getSearchQueryFromURL(); + if (queryParam) { + this.search(queryParam); } } catch (e) { this.handleError(e); @@ -67,10 +68,7 @@ export default { this.results[key].title = contentItem.h1; }); - // Add the search term to the URL to allow linking to result pages. - const url = new URL(window.location); - url.searchParams.set('query', this.query); - window.history.pushState(null, '', url.toString()); + updateURLParams(this.query); }, handleError() { this.error = true; @@ -80,7 +78,7 @@ export default { </script> <template> - <div class="lunr-search"> + <div class="lunr-search gl-mb-9"> <h1>Search</h1> <gl-search-box-by-click v-model="query" :value="query" @submit="onSubmit" /> <div v-if="results.length" class="gl-font-sm gl-mb-6">{{ results.length }} results found</div> diff --git a/content/frontend/search/components/lunr_search_form.vue b/content/frontend/search/components/search_form.vue index 958e5b07..1c83878d 100644 --- a/content/frontend/search/components/lunr_search_form.vue +++ b/content/frontend/search/components/search_form.vue @@ -19,5 +19,5 @@ export default { </script> <template> - <gl-search-box-by-click v-model="query" class="lunr-search" @submit="onSubmit" /> + <gl-search-box-by-click v-model="query" class="search-form" @submit="onSubmit" /> </template> diff --git a/content/frontend/search/docsearch.js b/content/frontend/search/docsearch.js index 7679dfe3..29ae399e 100644 --- a/content/frontend/search/docsearch.js +++ b/content/frontend/search/docsearch.js @@ -1,6 +1,6 @@ import docsearch from '@docsearch/js'; import '@docsearch/css'; -import { rewriteAlgoliaResultLinks, getAlgoliaCredentials, getDocsVersion } from './search'; +import { rewriteAlgoliaResultLinks, getAlgoliaCredentials, getDocsVersion } from './algolia'; document.addEventListener('DOMContentLoaded', () => { const docsVersion = getDocsVersion(); diff --git a/content/frontend/search/google.js b/content/frontend/search/google.js new file mode 100644 index 00000000..91e3787b --- /dev/null +++ b/content/frontend/search/google.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import GoogleResults from './components/google_results.vue'; +import SearchForm from './components/search_form.vue'; + +const mountVue = (el, Component) => { + return new Vue({ + el, + components: { Component }, + render(createElement) { + return createElement(Component); + }, + }); +}; + +document.addEventListener('DOMContentLoaded', () => { + mountVue('.js-google-search', GoogleResults); + mountVue('.js-search-form', SearchForm); +}); diff --git a/content/frontend/search/instantsearch.js b/content/frontend/search/instantsearch.js index 1f26ad11..f8daa571 100644 --- a/content/frontend/search/instantsearch.js +++ b/content/frontend/search/instantsearch.js @@ -8,8 +8,8 @@ import { AisInfiniteHits, AisConfigure, } from 'vue-instantsearch'; -import SearchPage from './components/search_page.vue'; -import { getAlgoliaCredentials, getDocsVersion } from './search'; +import SearchPage from './components/instantsearch_results.vue'; +import { getAlgoliaCredentials, getDocsVersion } from './algolia'; Vue.component(AisInstantSearch.name, AisInstantSearch); Vue.component(AisSearchBox.name, AisSearchBox); diff --git a/content/frontend/search/lunrsearch.js b/content/frontend/search/lunrsearch.js index ae0a06d9..8d4eaab9 100644 --- a/content/frontend/search/lunrsearch.js +++ b/content/frontend/search/lunrsearch.js @@ -1,13 +1,13 @@ import Vue from 'vue'; -import LunrPage from './components/lunr_page.vue'; -import LunrSearchForm from './components/lunr_search_form.vue'; +import LunrResults from './components/lunr_results.vue'; +import SearchForm from './components/search_form.vue'; // Search results page (/search) document.addEventListener('DOMContentLoaded', () => { return new Vue({ el: '.js-lunrsearch', render(createElement) { - return createElement(LunrPage); + return createElement(LunrResults); }, }); }); @@ -15,12 +15,12 @@ document.addEventListener('DOMContentLoaded', () => { // Homepage and interior navbar search forms document.addEventListener('DOMContentLoaded', () => { return new Vue({ - el: '.js-lunr-form', + el: '.js-search-form', components: { - LunrSearchForm, + SearchForm, }, render(createElement) { - return createElement(LunrSearchForm); + return createElement(SearchForm); }, }); }); diff --git a/content/frontend/search/search_helpers.js b/content/frontend/search/search_helpers.js new file mode 100644 index 00000000..b1121fe4 --- /dev/null +++ b/content/frontend/search/search_helpers.js @@ -0,0 +1,22 @@ +/** + * Shared functions for Lunr and Google search. + */ + +/** + * Check URL parameters for search queries. + * + * @returns + * The query string if it exists, or an empty string. + */ +export const getSearchQueryFromURL = () => { + return new URLSearchParams(window.location.search).get('query') || ''; +}; + +/** + * Update URL parameters with search query strings. + * + * This allows users to copy a link to search result pages. + */ +export const updateURLParams = (query) => { + window.history.pushState(null, '', `${window.location.pathname}?query=${query}`); +}; diff --git a/content/frontend/services/google_search_api.js b/content/frontend/services/google_search_api.js new file mode 100644 index 00000000..f92558bd --- /dev/null +++ b/content/frontend/services/google_search_api.js @@ -0,0 +1,2 @@ +export const GPS_ENDPOINT = 'https://www.googleapis.com/customsearch/v1/siterestrict?'; +export const GPS_ID = '63d73a0a2aa0a4871'; diff --git a/content/index.erb b/content/index.erb index c9b73e11..4bd8ee99 100644 --- a/content/index.erb +++ b/content/index.erb @@ -20,7 +20,7 @@ title: GitLab Documentation <% if @config[:search_backend] == "algolia" %> <div id="docsearch" class="gl-mb-4 gl-display-flex gl-justify-content-center"></div> <% else %> - <div class="js-lunr-form"></div> + <div class="js-search-form"></div> <% end %> <% end %> <% end %> diff --git a/layouts/default.html b/layouts/default.html index 67085578..d4bdd2c6 100644 --- a/layouts/default.html +++ b/layouts/default.html @@ -96,6 +96,8 @@ <script src="<%= @items['/frontend/shared/global_imports.*'].path %>"></script> <% if @config[:search_backend] == "algolia" %> <script src="<%= @items['/frontend/search/docsearch.*'].path %>"></script> + <% elsif @config[:search_backend] == "google" %> + <script src="<%= @items['/frontend/search/google.*'].path %>"></script> <% else %> <script src="<%= @items['/frontend/search/lunrsearch.*'].path %>"></script> <% end %> diff --git a/layouts/head.html b/layouts/head.html index f47ef3e2..ad174b82 100644 --- a/layouts/head.html +++ b/layouts/head.html @@ -29,6 +29,10 @@ <link rel="stylesheet" href="/frontend/search/docsearch.css"> <% end %> +<% if @config[:search_backend] == "google" && @config[:google_search_key] %> +<script>const GOOGLE_SEARCH_KEY = "<%= @config[:google_search_key] %>";</script> +<% end %> + <!-- Enable CSP headers --> <% unless ENV['DISABLE_CSP'] %> <%= render '/csp.*' %> diff --git a/layouts/header.html b/layouts/header.html index 9395afe4..21b2cd6c 100644 --- a/layouts/header.html +++ b/layouts/header.html @@ -15,7 +15,7 @@ <% if @config[:search_backend] == "algolia" %> <div id="docsearch" class="gl-my-3 gl-md-mt-0 gl-md-mb-0"></div> <% else %> - <div class="js-lunr-form"></div> + <div class="js-search-form"></div> <% end %> <% end %> <% end %> diff --git a/layouts/home.html b/layouts/home.html index 3b3afc2b..9b081c7c 100644 --- a/layouts/home.html +++ b/layouts/home.html @@ -19,6 +19,8 @@ <script src="<%= @items['/assets/javascripts/badges.*'].path %>"></script> <% if @config[:search_backend] == "algolia" %> <script src="<%= @items['/frontend/search/docsearch.*'].path %>"></script> + <% elsif @config[:search_backend] == "google" %> + <script src="<%= @items['/frontend/search/google.*'].path %>"></script> <% else %> <script src="<%= @items['/frontend/search/lunrsearch.*'].path %>"></script> <% end %> diff --git a/layouts/search.html b/layouts/search.html index 83a35221..bf9aea2b 100644 --- a/layouts/search.html +++ b/layouts/search.html @@ -6,6 +6,8 @@ <% if @config[:search_backend] == "algolia" %> <link rel="stylesheet" href="/frontend/search/instantsearch.css"> <script src="<%= @items['/frontend/search/instantsearch.*'].path %>"></script> + <% elsif @config[:search_backend] == "google" %> + <script src="<%= @items['/frontend/search/google.*'].path %>"></script> <% else %> <script src="/assets/javascripts/lunr.min.js"></script> <script src="<%= @items['/frontend/search/lunrsearch.*'].path %>"></script> @@ -20,6 +22,8 @@ <div class="main class pl-lg-4"> <% if @config[:search_backend] == "algolia" %> <div class="js-instantsearch"></div> + <% elsif @config[:search_backend] == "google" %> + <div class="js-google-search"></div> <% else %> <div class="js-lunrsearch"></div> <% end %> diff --git a/scripts/check-lunr-index.sh b/scripts/check-lunr-index.sh index 591b1fcd..2fd4b68e 100755 --- a/scripts/check-lunr-index.sh +++ b/scripts/check-lunr-index.sh @@ -9,18 +9,18 @@ COLOR_RESET="\e[39m" # # - index_check: checks if the lunr.js index is built. There's two files when the # index is built: 'lunr-index.json' and 'lunr-map.json'. -# - dev_check: when SEARCH_BACKEND is set to 'lunr' the div we're looking for -# is set to 'js-lunr-form'. Otherwise, it's set to 'docsearch' by default. +# - dev_check: when SEARCH_BACKEND is set to 'lunr' the string we're looking for +# is 'lunr.min.js'. Otherwise, it's set to 'docsearch' by default. # This is defined in # https://gitlab.com/gitlab-org/gitlab-docs/-/blob/83ebc0de813c6e916b522a9203a6182d7425dd20/content/index.erb#L20-24. # if [ "$CI" = "true" ]; then - div_check=$(docker run --rm "$IMAGE_NAME" grep -o js-lunr-form "/usr/share/nginx/html/$GITLAB_VERSION/index.html") + lunr_check=$(docker run --rm "$IMAGE_NAME" grep -o lunr.min.js "/usr/share/nginx/html/$GITLAB_VERSION/search/index.html") index_check=$(docker run --rm "$IMAGE_NAME" find "/usr/share/nginx/html/$GITLAB_VERSION/assets/javascripts/lunr-index.json" | wc -l) else - div_check=$(grep -o js-lunr-form public/index.html) + lunr_check=$(grep -o lunr.min.js public/search/index.html) index_check=$(find public/assets/javascripts/lunr-index.json | wc -l) fi @@ -33,7 +33,7 @@ then printf " For more information, see https://gitlab.com/gitlab-org/gitlab-docs/-/blob/main/doc/docsearch.md#lunrjs-search${COLOR_RESET}\n" exit 1; else - if [ "$div_check" != "js-lunr-form" ]; + if [ "$lunr_check" != "lunr.min.js" ]; then # shellcheck disable=2059 printf "${COLOR_RED}ERROR: lunr.js index is found, but not enabled!\n" diff --git a/spec/frontend/search/google_search_spec.js b/spec/frontend/search/google_search_spec.js new file mode 100644 index 00000000..3d2554b9 --- /dev/null +++ b/spec/frontend/search/google_search_spec.js @@ -0,0 +1,50 @@ +/** + * @jest-environment jsdom + */ + +import { shallowMount } from '@vue/test-utils'; +import SearchPage from '../../../content/frontend/search/components/google_results.vue'; +import { GPS_ENDPOINT } from '../../../content/frontend/services/google_search_api'; + +describe('content/frontend/search/components/google_results.vue', () => { + it('Search form renders', () => { + const wrapper = shallowMount(SearchPage); + expect(wrapper.findComponent(SearchPage).isVisible()).toBe(true); + }); + + it('API request failure shows an error', async () => { + const wrapper = shallowMount(SearchPage); + const fetch = jest.fn(() => { + Promise.reject(new Error('HTTP error')).catch(() => null); + }); + try { + await fetch(GPS_ENDPOINT); + } catch (e) { + expect(wrapper.find('[data-testid="search-error"]').isVisible()).toBe(true); + } + }); + + it('Google authentication failure shows an error', async () => { + const wrapper = shallowMount(SearchPage); + const fetch = jest.fn(() => { + Promise.resolve({ e: { error: { code: 400 } } }).catch(() => null); + }); + try { + await fetch(GPS_ENDPOINT); + } catch (e) { + expect(wrapper.find('[data-testid="search-error"]').isVisible()).toBe(true); + } + }); + + it('Google successful request shows results', async () => { + const wrapper = shallowMount(SearchPage); + const fetch = jest.fn(() => { + Promise.resolve({ items: [{ title: 'GitLab Docs' }] }).catch(() => null); + }); + try { + await fetch(GPS_ENDPOINT); + } catch (e) { + expect(wrapper.find('[data-testid="search-results"]').isVisible()).toBe(true); + } + }); +}); diff --git a/spec/frontend/search/lunr_search_spec.js b/spec/frontend/search/lunr_search_spec.js index 234fa794..9f890b35 100644 --- a/spec/frontend/search/lunr_search_spec.js +++ b/spec/frontend/search/lunr_search_spec.js @@ -3,9 +3,9 @@ */ import { shallowMount } from '@vue/test-utils'; -import SearchPage from '../../../content/frontend/search/components/lunr_page.vue'; +import SearchPage from '../../../content/frontend/search/components/lunr_results.vue'; -describe('content/frontend/search/components/lunr_page.vue', () => { +describe('content/frontend/search/components/lunr_results.vue', () => { it('Search form renders', () => { const wrapper = shallowMount(SearchPage); expect(wrapper.findComponent(SearchPage).isVisible()).toBe(true); diff --git a/spec/frontend/search/search_spec.js b/spec/frontend/search/search_spec.js index 2d80473d..0809f639 100644 --- a/spec/frontend/search/search_spec.js +++ b/spec/frontend/search/search_spec.js @@ -3,7 +3,7 @@ */ import { mount } from '@vue/test-utils'; -import SearchPage from '../../../content/frontend/search/components/search_page.vue'; +import SearchPage from '../../../content/frontend/search/components/instantsearch_results.vue'; const propsData = { docsVersion: 'main', algoliaCredentials: {} }; const searchFormSelector = '[data-testid="docs-search"]'; |