Page MenuHomePhorge

No OneTemporary

Size
39 KB
Referenced Files
None
Subscribers
None
diff --git a/jsdoc.json b/jsdoc.json
index 058bd54b226..0bc73627efa 100644
--- a/jsdoc.json
+++ b/jsdoc.json
@@ -25,6 +25,7 @@
"resources/src/"
],
"exclude": [
+ "resources/src/mediawiki.skinning.typeaheadSearch"
]
},
"templates": {
diff --git a/languages/i18n/en.json b/languages/i18n/en.json
index 322f3d4dd8a..eb42a43c653 100644
--- a/languages/i18n/en.json
+++ b/languages/i18n/en.json
@@ -48,6 +48,8 @@
"search": "Search",
"search-ignored-headings": " #<!-- leave this line exactly as it is --> <pre>\n# Headings that will be ignored by search.\n# Changes to this take effect as soon as the page with the heading is indexed.\n# You can force page reindexing by doing a null edit.\n# The syntax is as follows:\n# * Everything from a \"#\" character to the end of the line is a comment.\n# * Every non-blank line is the exact title to ignore, case and everything.\nReferences\nExternal links\nSee also\n #</pre> <!-- leave this line exactly as it is -->",
"searchbutton": "Search",
+ "searchsuggest-containing-html": "Search for pages containing <strong class=\"cdx-typeahead-search__search-footer__query\">$1</strong>",
+ "search-loader": "Loading search suggestions",
"go": "Go",
"searcharticle": "Go",
"skin-view-history": "View history",
diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json
index 385b09bbd45..000aec32ce5 100644
--- a/languages/i18n/qqq.json
+++ b/languages/i18n/qqq.json
@@ -317,6 +317,8 @@
"search": "{{doc-special|Search}}\nNoun. Text of menu section shown on every page of the wiki above the search form.\n\nSee also:\n* {{msg-mw|Search}}\n* {{msg-mw|Accesskey-search}}\n* {{msg-mw|Tooltip-search}}\n{{Identical|Search}}",
"search-ignored-headings": "Headings that the search will ignore. You can translate the text, including \"Leave this line exactly as it is\". Some lines of this messages have one leading space.",
"searchbutton": "The button you can see in the sidebar, below the search input box. The \"Go\" button is {{msg-mw|Searcharticle}}.\n{{Identical|Search}}",
+ "searchsuggest-containing-html": "Label used in the special item of the search suggestions list which gives the user an option to perform a full text search for the term. Used in the Codex typeahead search component.",
+ "search-loader": "Text to display below search input while the search suggestion module is loading",
"go": "Appears next to the search button. Goes directly to the page with that name, if it exists.\n\nSee also:\n* {{msg-mw|Go}}\n* {{msg-mw|Accesskey-search-go}}\n* {{msg-mw|Tooltip-search-go}}\n{{Identical|Go}}",
"searcharticle": "Button description in the search menu displayed on every page. The \"Search\" button is {{msg-mw|Searchbutton}}.\n{{Identical|Go}}\nNote however that some wikis (e.g. French Wikipedia) have chosen to translate this search submission button differently as ''Read'', rather than ''Go'':\n{{Identical|Read}}",
"skin-view-history": "Tab label in the Vector skin. See for example {{canonicalurl:Translating:MediaWiki|useskin=vector}}\n{{Identical|View history}}",
diff --git a/package-lock.json b/package-lock.json
index 549e7251d5d..6f73e657ff4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,7 @@
"grunt-stylelint": "0.20.1",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
+ "jest-fetch-mock": "^3.0.3",
"jsdoc": "4.0.4",
"jsdoc-wmf-theme": "1.1.0",
"karma": "6.4.1",
@@ -11152,6 +11153,17 @@
"@types/yargs-parser": "*"
}
},
+ "node_modules/jest-fetch-mock": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz",
+ "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-fetch": "^3.0.4",
+ "promise-polyfill": "^8.1.3"
+ }
+ },
"node_modules/jest-get-type": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz",
@@ -14866,6 +14878,13 @@
"node": ">=0.4.0"
}
},
+ "node_modules/promise-polyfill": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
+ "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -26490,6 +26509,16 @@
}
}
},
+ "jest-fetch-mock": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz",
+ "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==",
+ "dev": true,
+ "requires": {
+ "cross-fetch": "^3.0.4",
+ "promise-polyfill": "^8.1.3"
+ }
+ },
"jest-get-type": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz",
@@ -29343,6 +29372,12 @@
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true
},
+ "promise-polyfill": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
+ "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
+ "dev": true
+ },
"prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
diff --git a/package.json b/package.json
index a65501246c3..240589fda28 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"grunt-stylelint": "0.20.1",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
+ "jest-fetch-mock": "3.0.3",
"jsdoc": "4.0.4",
"jsdoc-wmf-theme": "1.1.0",
"karma": "6.4.1",
diff --git a/resources/Resources.php b/resources/Resources.php
index 60d3b80d24b..ffdaf793940 100644
--- a/resources/Resources.php
+++ b/resources/Resources.php
@@ -113,6 +113,25 @@ return [
],
],
],
+ 'mediawiki.skinning.typeaheadSearch' => [
+ 'dependencies' => [
+ 'mediawiki.codex.typeaheadSearch'
+ ],
+ 'packageFiles' => [
+ 'resources/src/mediawiki.skinning.typeaheadSearch/index.js',
+ 'resources/src/mediawiki.skinning.typeaheadSearch/App.vue',
+ 'resources/src/mediawiki.skinning.typeaheadSearch/instrumentation.js',
+ 'resources/src/mediawiki.skinning.typeaheadSearch/fetch.js',
+ 'resources/src/mediawiki.skinning.typeaheadSearch/restSearchClient.js',
+ 'resources/src/mediawiki.skinning.typeaheadSearch/urlGenerator.js',
+ ],
+ 'messages' => [
+ 'searchbutton',
+ 'searchresults',
+ 'search-loader',
+ 'searchsuggest-containing-html'
+ ]
+ ],
/* Polyfills */
'web2017-polyfills' => [
@@ -657,6 +676,13 @@ return [
'codexStyleOnly' => true
],
+ 'mediawiki.codex.typeaheadSearch' => [
+ 'class' => 'MediaWiki\\ResourceLoader\\CodexModule',
+ 'codexComponents' => [
+ 'CdxTypeaheadSearch'
+ ]
+ ],
+
'@wikimedia/codex-search' => [
'deprecated' => '[1.43] Use a CodexModule with codexComponents to set your specific components used: '
. 'https://www.mediawiki.org/wiki/Codex#Using_a_limited_subset_of_components',
diff --git a/resources/src/mediawiki.skinning.typeaheadSearch/App.vue b/resources/src/mediawiki.skinning.typeaheadSearch/App.vue
new file mode 100644
index 00000000000..48518ad47e3
--- /dev/null
+++ b/resources/src/mediawiki.skinning.typeaheadSearch/App.vue
@@ -0,0 +1,281 @@
+<template>
+ <cdx-typeahead-search
+ :id="id"
+ ref="searchForm"
+ :class="rootClasses"
+ :search-results-label="$i18n( 'searchresults' ).text()"
+ :accesskey="searchAccessKey"
+ :autocapitalize="autocapitalizeValue"
+ :title="searchTitle"
+ :placeholder="searchPlaceholder"
+ :aria-label="searchPlaceholder"
+ :initial-input-value="searchQuery"
+ :button-label="$i18n( 'searchbutton' ).text()"
+ :form-action="action"
+ :show-thumbnail="showThumbnail"
+ :highlight-query="highlightQuery"
+ :auto-expand-width="autoExpandWidth"
+ :search-results="suggestions"
+ :search-footer-url="searchFooterUrl"
+ :visible-item-limit="visibleItemLimit"
+ @load-more="onLoadMore"
+ @input="onInput"
+ @search-result-click="instrumentation.onSuggestionClick"
+ @submit="onSubmit"
+ @focus="onFocus"
+ @blur="onBlur"
+ >
+ <template #default>
+ <input
+ type="hidden"
+ name="title"
+ :value="searchPageTitle"
+ >
+ <input
+ type="hidden"
+ name="wprov"
+ :value="wprov"
+ >
+ </template>
+ <template #search-results-pending>
+ {{ $i18n( 'search-loader' ).text() }}
+ </template>
+ <!-- eslint-disable-next-line vue/no-template-shadow -->
+ <template #search-footer-text="{ searchQuery }">
+ <span v-i18n-html:searchsuggest-containing-html="[ searchQuery ]"></span>
+ </template>
+ </cdx-typeahead-search>
+</template>
+
+<script>
+const { CdxTypeaheadSearch } = require( 'mediawiki.codex.typeaheadSearch' ),
+ { defineComponent, nextTick } = require( 'vue' ),
+ instrumentation = require( './instrumentation.js' );
+
+// @vue/component
+module.exports = exports = defineComponent( {
+ name: 'App',
+ compilerOptions: {
+ whitespace: 'condense'
+ },
+ components: { CdxTypeaheadSearch },
+ props: {
+ urlGenerator: {
+ type: Object,
+ required: true
+ },
+ restClient: {
+ type: Object,
+ required: true
+ },
+ prefixClass: {
+ type: String,
+ default: 'skin-'
+ },
+ id: {
+ type: String,
+ required: true
+ },
+ autocapitalizeValue: {
+ type: String,
+ default: undefined
+ },
+ searchPageTitle: {
+ type: String,
+ default: 'Special:Search'
+ },
+ autofocusInput: {
+ type: Boolean,
+ default: false
+ },
+ action: {
+ type: String,
+ default: ''
+ },
+ /** The keyboard shortcut to focus search. */
+ searchAccessKey: {
+ type: String,
+ default: undefined
+ },
+ /** The access key informational tip for search. */
+ searchTitle: {
+ type: String,
+ default: undefined
+ },
+ /** The ghost text shown when no search query is entered. */
+ searchPlaceholder: {
+ type: String,
+ default: undefined
+ },
+ /**
+ * The search query string taken from the server-side rendered input immediately before
+ * client render.
+ */
+ searchQuery: {
+ type: String,
+ default: undefined
+ },
+ showThumbnail: {
+ type: Boolean,
+ required: true,
+ default: false
+ },
+ showDescription: {
+ type: Boolean,
+ default: false
+ },
+ highlightQuery: {
+ type: Boolean,
+ default: false
+ },
+ autoExpandWidth: {
+ type: Boolean,
+ default: false
+ }
+ },
+ data() {
+ return {
+ // -1 here is the default "active suggestion index".
+ wprov: instrumentation.getWprovFromResultIndex( -1 ),
+
+ // Suggestions to be shown in the TypeaheadSearch menu.
+ suggestions: [],
+
+ // Link to the search page for the current search query.
+ searchFooterUrl: '',
+
+ // The current search query. Used to detect whether a fetch response is stale.
+ currentSearchQuery: '',
+
+ // Whether to apply a CSS class that disables the CSS transitions on the text input
+ disableTransitions: this.autofocusInput,
+
+ instrumentation: instrumentation.listeners,
+
+ isFocused: false
+ };
+ },
+ computed: {
+ rootClasses() {
+ const prefix = this.prefixClass;
+ return {
+ [ `${ prefix }typeahead-search` ]: true,
+ [ `${ prefix }search-box-disable-transitions` ]: this.disableTransitions,
+ [ `${ prefix }typeahead-search--active` ]: this.isFocused
+ };
+ },
+ visibleItemLimit() {
+ // if the search client supports loading more results,
+ // show 7 out of 10 results at first (arbitrary number),
+ // so that scroll events are fired and trigger onLoadMore()
+ return this.restClient.loadMore ? 7 : null;
+ }
+ },
+ methods: {
+ /**
+ * Fetch suggestions when new input is received.
+ *
+ * @param {string} value
+ */
+ onInput: function ( value ) {
+ const query = value.trim();
+
+ this.currentSearchQuery = query;
+
+ if ( query === '' ) {
+ this.suggestions = [];
+ this.searchFooterUrl = '';
+ return;
+ }
+
+ this.updateUIWithSearchClientResult(
+ this.restClient.fetchByTitle( query, 10, this.showDescription ),
+ true
+ );
+ },
+
+ /**
+ * Fetch additional suggestions.
+ *
+ * This should only be called if visibleItemLimit is non-null,
+ * i.e. if the search client supports loading more results.
+ */
+ onLoadMore() {
+ if ( !this.restClient.loadMore ) {
+ mw.log.warn( 'onLoadMore() should not have been called for this search client' );
+ return;
+ }
+
+ this.updateUIWithSearchClientResult(
+ this.restClient.loadMore(
+ this.currentSearchQuery,
+ this.suggestions.length,
+ 10,
+ this.showDescription
+ ),
+ false
+ );
+ },
+
+ /**
+ * @param {AbortableSearchFetch} search
+ * @param {boolean} replaceResults
+ */
+ updateUIWithSearchClientResult( search, replaceResults ) {
+ const query = this.currentSearchQuery;
+
+ search.fetch
+ .then( ( data ) => {
+ // Only use these results if they're still relevant
+ // If currentSearchQuery !== query, these results are for a previous search
+ // and we shouldn't show them.
+ if ( this.currentSearchQuery === query ) {
+ if ( replaceResults ) {
+ this.suggestions = [];
+ }
+ this.suggestions.push(
+ ...instrumentation.addWprovToSearchResultUrls(
+ data.results, this.suggestions.length
+ )
+ );
+ this.searchFooterUrl = this.urlGenerator.generateUrl( query );
+ }
+
+ const event = {
+ numberOfResults: data.results.length,
+ query: query
+ };
+ instrumentation.listeners.onFetchEnd( event );
+ } )
+ .catch( () => {
+ // TODO: error handling
+ } );
+ },
+
+ /**
+ * @param {SearchSubmitEvent} event
+ */
+ onSubmit( event ) {
+ this.wprov = instrumentation.getWprovFromResultIndex( event.index );
+
+ instrumentation.listeners.onSubmit( event );
+ },
+
+ onFocus() {
+ this.isFocused = true;
+ },
+
+ onBlur() {
+ this.isFocused = false;
+ }
+ },
+ mounted() {
+ if ( this.autofocusInput ) {
+ this.$refs.searchForm.focus();
+ nextTick( () => {
+ this.disableTransitions = false;
+ } );
+ }
+ }
+} );
+</script>
diff --git a/resources/src/mediawiki.skinning.typeaheadSearch/fetch.js b/resources/src/mediawiki.skinning.typeaheadSearch/fetch.js
new file mode 100644
index 00000000000..a70de4ef3e2
--- /dev/null
+++ b/resources/src/mediawiki.skinning.typeaheadSearch/fetch.js
@@ -0,0 +1,54 @@
+/**
+ * @typedef {Object} AbortableFetch
+ * @property {Promise<any>} fetch
+ * @property {Function} abort
+ */
+
+/**
+ * @typedef {Object} NullableAbortController
+ * @property {AbortSignal | undefined} signal
+ * @property {Function} abort
+ */
+const nullAbortController = {
+ signal: undefined,
+ abort: () => {} // Do nothing (no-op)
+};
+
+/**
+ * A wrapper which combines native fetch() in browsers and the following json() call.
+ *
+ * @param {string} resource
+ * @param {RequestInit} [init]
+ * @return {AbortableFetch}
+ */
+function fetchJson( resource, init ) {
+ // As of 2020, browser support for AbortController is limited:
+ // https://caniuse.com/abortcontroller
+ // so replacing it with no-op if it doesn't exist.
+ // eslint-disable-next-line compat/compat
+ const controller = window.AbortController ?
+ // eslint-disable-next-line compat/compat
+ new AbortController() :
+ nullAbortController;
+
+ const getJson = fetch( resource, Object.assign( {}, init, {
+ signal: controller.signal
+ } ) ).then( ( response ) => {
+ if ( !response.ok ) {
+ // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
+ return Promise.reject(
+ 'Network request failed with HTTP code ' + response.status
+ );
+ }
+ return response.json();
+ } );
+
+ return {
+ fetch: getJson,
+ abort: () => {
+ controller.abort();
+ }
+ };
+}
+
+module.exports = fetchJson;
diff --git a/resources/src/mediawiki.skinning.typeaheadSearch/index.js b/resources/src/mediawiki.skinning.typeaheadSearch/index.js
new file mode 100644
index 00000000000..b0f83ce7cd2
--- /dev/null
+++ b/resources/src/mediawiki.skinning.typeaheadSearch/index.js
@@ -0,0 +1,8 @@
+const urlGenerator = require( './urlGenerator.js' );
+const App = require( './App.vue' );
+const restSearchClient = require( './restSearchClient.js' );
+module.exports = {
+ App,
+ restSearchClient,
+ urlGenerator
+};
diff --git a/resources/src/mediawiki.skinning.typeaheadSearch/instrumentation.js b/resources/src/mediawiki.skinning.typeaheadSearch/instrumentation.js
new file mode 100644
index 00000000000..8ae412ca7dc
--- /dev/null
+++ b/resources/src/mediawiki.skinning.typeaheadSearch/instrumentation.js
@@ -0,0 +1,99 @@
+/**
+ * @param {FetchEndEvent} event
+ */
+function onFetchEnd( event ) {
+ mw.track( 'mediawiki.searchSuggest', {
+ action: 'impression-results',
+ numberOfResults: event.numberOfResults,
+ // resultSetType: '',
+ // searchId: '',
+ query: event.query,
+ inputLocation: 'header-moved'
+ } );
+}
+
+/**
+ * @param {SuggestionClickEvent|SearchSubmitEvent} event
+ */
+function onSuggestionClick( event ) {
+ mw.track( 'mediawiki.searchSuggest', {
+ action: 'click-result',
+ numberOfResults: event.numberOfResults,
+ index: event.index
+ } );
+}
+
+/**
+ * Generates the value of the `wprov` parameter to be used in the URL of a search result and the
+ * `wprov` hidden input.
+ *
+ * See https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/WikimediaEvents/+/refs/heads/master/modules/ext.wikimediaEvents/searchSatisfaction.js
+ * and also the top of that file for additional detail about the shape of the parameter.
+ *
+ * @param {number} index
+ * @return {string}
+ */
+function getWprovFromResultIndex( index ) {
+ // result looks like: acrw1_0, acrw1_1, acrw1_2, etc.;
+ // or acrw1_-1 for index -1 (user did not highlight an autocomplete result)
+ return 'acrw1_' + index;
+}
+
+/**
+ * @typedef {Object} SearchResultPartial
+ * @property {string} title
+ * @property {string} [url]
+ */
+
+/**
+ * Return a new list of search results,
+ * with the `wprov` parameter added to each result's url (if any).
+ *
+ * @param {SearchResultPartial[]} results Not modified.
+ * @param {number} offset Offset to add to the index of each result.
+ * @return {SearchResultPartial[]}
+ */
+function addWprovToSearchResultUrls( results, offset ) {
+ return results.map( ( result, index ) => {
+ if ( result.url ) {
+ const url = new URL( result.url, location.href );
+ url.searchParams.set( 'wprov', getWprovFromResultIndex( index + offset ) );
+ result = Object.assign( {}, result, { url: url.toString() } );
+ }
+ return result;
+ } );
+}
+
+/**
+ * @typedef {Object} Instrumentation
+ * @property {Object} listeners
+ * @property {Function} getWprovFromResultIndex
+ * @property {Function} addWprovToSearchResultUrls
+ */
+
+/**
+ * @type {Instrumentation}
+ */
+module.exports = {
+ listeners: {
+ onFetchEnd,
+ onSuggestionClick,
+
+ // As of writing (2020/12/08), both the "click-result" and "submit-form" kind of
+ // mediawiki.searchSuggestion events result in a "click" SearchSatisfaction event being
+ // logged [0]. However, when processing the "submit-form" kind of mediawiki.searchSuggestion
+ // event, the SearchSatisfaction instrument will modify the DOM, adding a hidden input
+ // element, in order to set the appropriate provenance parameter (see [1] for additional
+ // detail).
+ //
+ // In this implementation of the mediawiki.searchSuggestion protocol, we don't want to
+ // trigger the above behavior as we're using Vue.js, which doesn't expect the DOM to be
+ // modified underneath it.
+ //
+ // [0] https://gerrit.wikimedia.org/g/mediawiki/extensions/WikimediaEvents/+/df97aa9c9407507e8c48827666beeab492fd56a8/modules/ext.wikimediaEvents/searchSatisfaction.js#735
+ // [1] https://phabricator.wikimedia.org/T257698#6416826
+ onSubmit: onSuggestionClick
+ },
+ getWprovFromResultIndex,
+ addWprovToSearchResultUrls
+};
diff --git a/resources/src/mediawiki.skinning.typeaheadSearch/restSearchClient.js b/resources/src/mediawiki.skinning.typeaheadSearch/restSearchClient.js
new file mode 100644
index 00000000000..9ddd216a77a
--- /dev/null
+++ b/resources/src/mediawiki.skinning.typeaheadSearch/restSearchClient.js
@@ -0,0 +1,121 @@
+// / <reference lib="@wikimedia/types" />
+/** @module restSearchClient */
+/**
+ * @typedef {import('./urlGenerator.js').UrlGenerator} UrlGenerator
+ */
+
+const fetchJson = require( './fetch.js' );
+
+/**
+ * @typedef {Object} RestResponse
+ * @property {RestResult[]} pages
+ */
+
+/**
+ * @typedef {Object} SearchResponse
+ * @property {string} query
+ * @property {SearchResult[]} results
+ */
+
+/**
+ * Nullish coalescing operator (??) helper
+ *
+ * @param {any} a
+ * @param {any} b
+ * @return {any}
+ */
+function nullish( a, b ) {
+ return ( a !== null && a !== undefined ) ? a : b;
+}
+
+/**
+ * @param {UrlGenerator} urlGeneratorInstance
+ * @param {string} query
+ * @param {RestResponse} restResponse
+ * @param {boolean} showDescription
+ * @return {SearchResponse}
+ */
+function adaptApiResponse( urlGeneratorInstance, query, restResponse, showDescription ) {
+ return {
+ query,
+ results: restResponse.pages.map( ( page, index ) => {
+ const thumbnail = page.thumbnail;
+ return {
+ id: page.id,
+ value: page.id || -( index + 1 ),
+ label: page.title,
+ key: page.key,
+ title: page.title,
+ description: showDescription ? page.description : undefined,
+ url: urlGeneratorInstance.generateUrl( page ),
+ thumbnail: thumbnail ? {
+ url: thumbnail.url,
+ width: nullish( thumbnail.width, undefined ),
+ height: nullish( thumbnail.height, undefined )
+ } : undefined
+ };
+ } )
+ };
+}
+
+/**
+ * @typedef {Object} AbortableSearchFetch
+ * @property {Promise<SearchResponse>} fetch
+ * @property {Function} abort
+ */
+
+/**
+ * @callback fetchByTitle
+ * @param {string} query The search term.
+ * @param {number} [limit] Maximum number of results.
+ * @param {boolean} [showDescription] Whether descriptions should be added to the results.
+ * @return {AbortableSearchFetch}
+ */
+
+/**
+ * @callback loadMore
+ * @param {string} query The search term.
+ * @param {number} offset The number of search results that were already loaded.
+ * @param {number} [limit] How many further search results to load (at most).
+ * @param {boolean} [showDescription] Whether descriptions should be added to the results.
+ * @return {AbortableSearchFetch}
+ */
+
+/**
+ * @typedef {Object} SearchClient
+ * @property {fetchByTitle} fetchByTitle
+ * @property {loadMore} [loadMore]
+ */
+
+/**
+ * @param {string} searchApiUrl
+ * @param {UrlGenerator} urlGeneratorInstance
+ * @return {SearchClient}
+ */
+function restSearchClient( searchApiUrl, urlGeneratorInstance ) {
+ return {
+ /**
+ * @type {fetchByTitle}
+ */
+ fetchByTitle: ( q, limit = 10, showDescription = true ) => {
+ const params = { q, limit: limit.toString() };
+ const search = new URLSearchParams( params );
+ const url = `${ searchApiUrl }/v1/search/title?${ search.toString() }`;
+ const result = fetchJson( url, {
+ headers: {
+ accept: 'application/json'
+ }
+ } );
+ const searchResponsePromise = result.fetch
+ .then( ( /** @type {RestResponse} */ res ) => adaptApiResponse(
+ urlGeneratorInstance, q, res, showDescription
+ ) );
+ return {
+ abort: result.abort,
+ fetch: searchResponsePromise
+ };
+ }
+ };
+}
+
+module.exports = restSearchClient;
diff --git a/resources/src/mediawiki.skinning.typeaheadSearch/types.js b/resources/src/mediawiki.skinning.typeaheadSearch/types.js
new file mode 100644
index 00000000000..f7d2aaf77fd
--- /dev/null
+++ b/resources/src/mediawiki.skinning.typeaheadSearch/types.js
@@ -0,0 +1,49 @@
+/**
+ * @typedef {Object} FetchEndEvent
+ * @property {number} numberOfResults
+ * @property {string} query
+ */
+
+/**
+ * @typedef {Object} SuggestionClickEvent
+ * @property {number} numberOfResults
+ * @property {number} index
+ */
+
+/**
+ * @typedef {SuggestionClickEvent} SearchSubmitEvent
+ */
+
+/**
+ * @typedef {Object} RestResult
+ * @property {number} id
+ * @property {string} key
+ * @property {string} title
+ * @property {string} [description]
+ * @property {RestThumbnail | null} [thumbnail]
+ */
+
+/**
+ * @typedef {Object} RestThumbnail
+ * @property {string} url
+ * @property {number | null} [width]
+ * @property {number | null} [height]
+ */
+
+/**
+ * @typedef {Object} SearchResult
+ * @property {number} id
+ * @property {string} key
+ * @property {string} title
+ * @property {string} [description]
+ * @property {SearchResultThumbnail} [thumbnail]
+ */
+
+/**
+ * @typedef {Object} SearchResultThumbnail
+ * @property {string} url
+ * @property {number} [width]
+ * @property {number} [height]
+ */
+
+/* exported SuggestionClickEvent, SearchSubmitEvent, FetchEndEvent, RestResult, SearchResult */
diff --git a/resources/src/mediawiki.skinning.typeaheadSearch/urlGenerator.js b/resources/src/mediawiki.skinning.typeaheadSearch/urlGenerator.js
new file mode 100644
index 00000000000..b431458151a
--- /dev/null
+++ b/resources/src/mediawiki.skinning.typeaheadSearch/urlGenerator.js
@@ -0,0 +1,59 @@
+/**
+ * @typedef {Record<string,string>} UrlParams
+ * @param {string} title
+ * @param {string} fulltext
+ */
+
+/**
+ * @callback generateUrl
+ * @param {RestResult|SearchResult|string} searchResult
+ * @param {UrlParams} [params]
+ * @param {string} [articlePath]
+ * @return {string}
+ */
+
+/**
+ * @typedef {Object} UrlGenerator
+ * @property {generateUrl} generateUrl
+ */
+
+/**
+ * Generates URLs for suggestions like those in MediaWiki's mediawiki.searchSuggest implementation.
+ *
+ * @param {string} articlePath
+ * @return {UrlGenerator}
+ */
+function urlGenerator( articlePath ) {
+ return {
+ /**
+ * @param {RestResult|SearchResult|string} suggestion
+ * @param {UrlParams} params
+ * @return {string}
+ */
+ generateUrl(
+ suggestion,
+ params = {
+ title: 'Special:Search'
+ }
+ ) {
+ if ( typeof suggestion !== 'string' ) {
+ suggestion = suggestion.title;
+ } else {
+ // Add `fulltext` query param to search within pages and for navigation
+ // to the search results page (prevents being redirected to a certain
+ // article).
+ params = Object.assign( {}, params, {
+ fulltext: '1'
+ } );
+ }
+
+ const searchParams = new URLSearchParams(
+ Object.assign( {}, params, { search: suggestion } )
+ );
+ return `${ articlePath }?${ searchParams.toString() }`;
+ }
+ };
+}
+
+/** @module urlGenerator */
+module.exports = urlGenerator;
diff --git a/tests/jest/jest.config.js b/tests/jest/jest.config.js
index 6ca559fae85..927d6d8d35c 100644
--- a/tests/jest/jest.config.js
+++ b/tests/jest/jest.config.js
@@ -23,7 +23,8 @@ module.exports = {
// An array of glob patterns indicating a set of files for which coverage information should be
// collected
collectCoverageFrom: [
- 'resources/src/mediawiki.special.block/**/*.{js,vue}'
+ 'resources/src/mediawiki.special.block/**/*.{js,vue}',
+ 'resources/src/mediawiki.skinning.typeaheadSearch/**/*.{js,vue}'
],
// The directory where Jest should output its coverage files
@@ -87,7 +88,8 @@ module.exports = {
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
- 'icons.json$': '@wikimedia/codex-icons'
+ 'icons.json$': '@wikimedia/codex-icons',
+ 'mediawiki.codex.typeaheadSearch': '@wikimedia/codex'
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
diff --git a/tests/jest/mediawiki.skinning.typeaheadSearch/App.test.js b/tests/jest/mediawiki.skinning.typeaheadSearch/App.test.js
new file mode 100644
index 00000000000..2ac3f58651d
--- /dev/null
+++ b/tests/jest/mediawiki.skinning.typeaheadSearch/App.test.js
@@ -0,0 +1,47 @@
+const VueTestUtils = require( '@vue/test-utils' );
+const App = require( '../../../resources/src/mediawiki.skinning.typeaheadSearch/App.vue' );
+const urlGeneratorFn = require( '../../../resources/src/mediawiki.skinning.typeaheadSearch/urlGenerator.js' );
+const scriptPath = '/w/index.php';
+const urlGenerator = urlGeneratorFn( scriptPath );
+
+const defaultProps = {
+ prefixClass: 'vector-',
+ id: 'searchform',
+ searchAccessKey: 'f',
+ searchTitle: 'search',
+ showThumbnail: true,
+ showDescription: true,
+ highlightQuery: true,
+ urlGenerator,
+ restClient: {
+ loadMore: () => Promise.resolve(),
+ fetchByTitle: () => Promise.resolve()
+ },
+ searchPlaceholder: 'Search MediaWiki',
+ searchQuery: ''
+};
+
+const mount = ( /** @type {Object} */ customProps ) => VueTestUtils.shallowMount( App, {
+ props: Object.assign( {}, defaultProps, customProps ),
+ global: {
+ mocks: {
+ $i18n: ( /** @type {string} */ str ) => ( {
+ text: () => str
+ } )
+ },
+ directives: {
+ 'i18n-html': ( el, binding ) => {
+ el.innerHTML = `${ binding.arg } (${ binding.value })`;
+ }
+ }
+ }
+} );
+
+describe( 'App', () => {
+ it( 'renders a typeahead search component', () => {
+ const wrapper = mount();
+ expect(
+ wrapper.element
+ ).toMatchSnapshot();
+ } );
+} );
diff --git a/tests/jest/mediawiki.skinning.typeaheadSearch/__snapshots__/App.test.js.snap b/tests/jest/mediawiki.skinning.typeaheadSearch/__snapshots__/App.test.js.snap
new file mode 100644
index 00000000000..dd240c621d3
--- /dev/null
+++ b/tests/jest/mediawiki.skinning.typeaheadSearch/__snapshots__/App.test.js.snap
@@ -0,0 +1,24 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`App renders a typeahead search component 1`] = `
+<cdx-typeahead-search-stub
+ accesskey="f"
+ aria-label="Search MediaWiki"
+ autoexpandwidth="false"
+ buttonlabel="searchbutton"
+ class="vector-typeahead-search"
+ debounceinterval="120"
+ formaction=""
+ highlightquery="true"
+ id="searchform"
+ initialinputvalue=""
+ placeholder="Search MediaWiki"
+ search-results-label="searchresults"
+ searchfooterurl=""
+ searchresults=""
+ showthumbnail="true"
+ title="search"
+ usebutton="false"
+ visibleitemlimit="7"
+/>
+`;
diff --git a/tests/jest/mediawiki.skinning.typeaheadSearch/fetch.test.js b/tests/jest/mediawiki.skinning.typeaheadSearch/fetch.test.js
new file mode 100644
index 00000000000..e97331676e0
--- /dev/null
+++ b/tests/jest/mediawiki.skinning.typeaheadSearch/fetch.test.js
@@ -0,0 +1,97 @@
+/* global fetchMock, process */
+const fetchJson = require( '../../../resources/src/mediawiki.skinning.typeaheadSearch/fetch.js' );
+const jestFetchMock = require( 'jest-fetch-mock' );
+
+const mockedRequests = !process.env.TEST_LIVE_REQUESTS;
+const url = '//en.wikipedia.org/w/rest.php/v1/search/title?q=jfgkdajgioj&limit=10';
+
+describe( 'abort() using AbortController', () => {
+ test( 'Aborting an unfinished request throws an AbortError', async () => {
+ expect.assertions( 1 );
+
+ const { abort, fetch } = fetchJson( url );
+
+ abort();
+
+ return fetch.catch( ( e ) => {
+ expect( e.name ).toStrictEqual( 'AbortError' );
+ } );
+ } );
+} );
+
+describe( 'fetch() using window.fetch', () => {
+ beforeAll( () => {
+ jestFetchMock.enableFetchMocks();
+ } );
+
+ afterAll( () => {
+ jestFetchMock.disableFetchMocks();
+ } );
+
+ beforeEach( () => {
+ fetchMock.resetMocks();
+ if ( !mockedRequests ) {
+ fetchMock.disableMocks();
+ }
+ fetchMock.mockIf( /^\/\/en.wikipedia.org\//, async ( req ) => {
+ if ( req.url === url ) {
+ return {
+ body: JSON.stringify( { pages: [] } ),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ };
+ } else {
+ return {
+ status: 404,
+ body: 'Page not found'
+ };
+ }
+ } );
+ } );
+
+ test( '200 without init param passed', async () => {
+ const { fetch } = fetchJson( url );
+ const json = await fetch;
+ const controller = new AbortController();
+ expect( json ).toStrictEqual( { pages: [] } );
+
+ if ( mockedRequests ) {
+ expect( fetchMock ).toHaveBeenCalledTimes( 1 );
+ expect( fetchMock ).toHaveBeenCalledWith( url, { signal: controller.signal } );
+ }
+ } );
+
+ test( '200 with init param passed', async () => {
+ const { fetch } = fetchJson( url, { mode: 'cors' } );
+ const json = await fetch;
+
+ expect( json ).toStrictEqual( { pages: [] } );
+
+ if ( mockedRequests ) {
+ expect( fetchMock ).toHaveBeenCalledTimes( 1 );
+ expect( fetchMock ).toHaveBeenCalledWith(
+ url,
+ expect.objectContaining( { mode: 'cors' } )
+ );
+ }
+ } );
+
+ test( '404 response', async () => {
+ expect.assertions( 1 );
+ const { fetch } = fetchJson( '//en.wikipedia.org/doesNotExist' );
+
+ await expect( fetch )
+ .rejects.toStrictEqual( 'Network request failed with HTTP code 404' );
+
+ if ( mockedRequests ) {
+ const controller = new AbortController();
+ expect.assertions( 3 );
+ expect( fetchMock ).toHaveBeenCalledTimes( 1 );
+ expect( fetchMock ).toHaveBeenCalledWith(
+ '//en.wikipedia.org/doesNotExist', { signal: controller.signal }
+ );
+ }
+ } );
+
+} );
diff --git a/tests/jest/mediawiki.skinning.typeaheadSearch/instrumentation.test.js b/tests/jest/mediawiki.skinning.typeaheadSearch/instrumentation.test.js
new file mode 100644
index 00000000000..3c5d99b9ec4
--- /dev/null
+++ b/tests/jest/mediawiki.skinning.typeaheadSearch/instrumentation.test.js
@@ -0,0 +1,91 @@
+const instrumentation = require( '../../../resources/src/mediawiki.skinning.typeaheadSearch/instrumentation.js' );
+
+describe( 'instrumentation', () => {
+ test.each( [
+ [ 0, 'acrw1_0' ],
+ [ 1, 'acrw1_1' ],
+ [ -1, 'acrw1_-1' ]
+ ] )( 'getWprovFromResultIndex( %d ) = %s', ( index, expected ) => {
+ expect( instrumentation.getWprovFromResultIndex( index ) )
+ .toBe( expected );
+ } );
+
+ test( 'addWprovToSearchResultUrls without offset', () => {
+ const url1 = 'https://host/?title=Special%3ASearch&search=Aa',
+ url2Base = 'https://host/?title=Special%3ASearch&search=Ab',
+ url3 = 'https://host/Ac',
+ url5 = '/index.php?title=Special%3ASearch&search=Ad';
+ const results = [
+ {
+ title: 'Aa',
+ url: url1
+ },
+ {
+ title: 'Ab',
+ url: `${ url2Base }&wprov=xyz`
+ },
+ {
+ title: 'Ac',
+ url: url3
+ },
+ {
+ title: 'Ad'
+ },
+ {
+ title: 'Ae',
+ url: url5
+ }
+ ];
+
+ expect( instrumentation.addWprovToSearchResultUrls( results, 0 ) )
+ .toStrictEqual( [
+ {
+ title: 'Aa',
+ url: `${ url1 }&wprov=acrw1_0`
+ },
+ {
+ title: 'Ab',
+ url: `${ url2Base }&wprov=acrw1_1`
+ },
+ {
+ title: 'Ac',
+ url: `${ url3 }?wprov=acrw1_2`
+ },
+ {
+ title: 'Ad'
+ },
+ {
+ title: 'Ae',
+ url: `${ location.origin }${ url5 }&wprov=acrw1_4`
+ }
+ ] );
+ expect( results[ 0 ].url ).toStrictEqual( url1 );
+ } );
+
+ test( 'addWprovToSearchResultUrls with offset', () => {
+ const url1 = 'https://host/?title=Special%3ASearch&search=Ae',
+ url2 = 'https://host/?title=Special%3ASearch&search=Af';
+ const results = [
+ {
+ title: 'Ae',
+ url: url1
+ },
+ {
+ title: 'Af',
+ url: url2
+ }
+ ];
+
+ expect( instrumentation.addWprovToSearchResultUrls( results, 4 ) )
+ .toStrictEqual( [
+ {
+ title: 'Ae',
+ url: `${ url1 }&wprov=acrw1_4`
+ },
+ {
+ title: 'Af',
+ url: `${ url2 }&wprov=acrw1_5`
+ }
+ ] );
+ } );
+} );
diff --git a/tests/jest/mediawiki.skinning.typeaheadSearch/restSearchClient.test.js b/tests/jest/mediawiki.skinning.typeaheadSearch/restSearchClient.test.js
new file mode 100644
index 00000000000..ce90e101e28
--- /dev/null
+++ b/tests/jest/mediawiki.skinning.typeaheadSearch/restSearchClient.test.js
@@ -0,0 +1,116 @@
+/* global fetchMock, process */
+const restSearchClient = require( '../../../resources/src/mediawiki.skinning.typeaheadSearch/restSearchClient.js' );
+const jestFetchMock = require( 'jest-fetch-mock' );
+const urlGeneratorFn = require( '../../../resources/src/mediawiki.skinning.typeaheadSearch/urlGenerator.js' );
+const scriptPath = '/w/index.php';
+const urlGenerator = urlGeneratorFn( scriptPath );
+const searchApiUrl = 'https://en.wikipedia.org/w/rest.php';
+const mockedRequests = !process.env.TEST_LIVE_REQUESTS;
+
+describe( 'restApiSearchClient', () => {
+ beforeAll( () => {
+ jestFetchMock.enableFetchMocks();
+ } );
+
+ afterAll( () => {
+ jestFetchMock.disableFetchMocks();
+ } );
+
+ beforeEach( () => {
+ fetchMock.resetMocks();
+ if ( !mockedRequests ) {
+ fetchMock.disableMocks();
+ }
+ } );
+
+ test( '2 results', async () => {
+ const thumbUrl = '//upload.wikimedia.org/wikipedia/commons/0/01/MediaWiki-smaller-logo.png';
+ const restResponse = {
+ pages: [
+ {
+ id: 37298,
+ key: 'Media',
+ label: 'Media',
+ title: 'Media',
+ description: 'Wikimedia disambiguation page',
+ thumbnail: null,
+ url: '/w/index.php?title=Special%3ASearch&search=Media',
+ value: 37298
+ },
+ {
+ id: 323710,
+ key: 'MediaWiki',
+ label: 'MediaWiki',
+ title: 'MediaWiki',
+ description: 'wiki software',
+ thumbnail: {
+ width: 200,
+ height: 189,
+ url: thumbUrl
+ },
+ url: '/w/index.php?title=Special%3ASearch&search=MediaWiki',
+ value: 323710
+ }
+ ]
+ };
+ fetchMock.mockOnce( JSON.stringify( restResponse ) );
+
+ const searchResult = await restSearchClient( searchApiUrl, urlGenerator ).fetchByTitle(
+ 'media',
+ 2
+ ).fetch;
+
+ const controller = new AbortController();
+
+ expect( searchResult.query ).toStrictEqual( 'media' );
+ expect( searchResult.results ).toBeTruthy();
+ expect( searchResult.results.length ).toBe( 2 );
+
+ expect( searchResult.results[ 0 ] ).toStrictEqual(
+ Object.assign( {}, restResponse.pages[ 0 ], {
+ // thumbnail: null -> thumbnail: undefined
+ thumbnail: undefined
+ } ) );
+ expect( searchResult.results[ 1 ] ).toStrictEqual( restResponse.pages[ 1 ] );
+
+ if ( mockedRequests ) {
+ expect( fetchMock ).toHaveBeenCalledTimes( 1 );
+ expect( fetchMock ).toHaveBeenCalledWith(
+ 'https://en.wikipedia.org/w/rest.php/v1/search/title?q=media&limit=2',
+ { headers: { accept: 'application/json' }, signal: controller.signal }
+ );
+ }
+ } );
+
+ test( '0 results', async () => {
+ const restResponse = { pages: [] };
+ fetchMock.mockOnce( JSON.stringify( restResponse ) );
+
+ const searchResult = await restSearchClient( searchApiUrl, urlGenerator ).fetchByTitle(
+ 'thereIsNothingLikeThis'
+ ).fetch;
+
+ const controller = new AbortController();
+ expect( searchResult.query ).toStrictEqual( 'thereIsNothingLikeThis' );
+ expect( searchResult.results ).toBeTruthy();
+ expect( searchResult.results.length ).toBe( 0 );
+
+ if ( mockedRequests ) {
+ expect( fetchMock ).toHaveBeenCalledTimes( 1 );
+ expect( fetchMock ).toHaveBeenCalledWith(
+ 'https://en.wikipedia.org/w/rest.php/v1/search/title?q=thereIsNothingLikeThis&limit=10',
+ { headers: { accept: 'application/json' }, signal: controller.signal }
+ );
+ }
+ } );
+
+ if ( mockedRequests ) {
+ test( 'network error', async () => {
+ fetchMock.mockRejectOnce( new Error( 'failed' ) );
+
+ await expect( restSearchClient( searchApiUrl, urlGenerator ).fetchByTitle(
+ 'anything'
+ ).fetch ).rejects.toThrow( 'failed' );
+ } );
+ }
+} );
diff --git a/tests/jest/mediawiki.skinning.typeaheadSearch/urlGenerator.test.js b/tests/jest/mediawiki.skinning.typeaheadSearch/urlGenerator.test.js
new file mode 100644
index 00000000000..cb93f032e76
--- /dev/null
+++ b/tests/jest/mediawiki.skinning.typeaheadSearch/urlGenerator.test.js
@@ -0,0 +1,20 @@
+const urlGenerator = require( '../../../resources/src/mediawiki.skinning.typeaheadSearch/urlGenerator.js' );
+
+describe( 'urlGenerator', () => {
+ describe( 'default', () => {
+ test.each( [
+ [ 'string', 'title', '&fulltext=1' ],
+ [ 'object', { title: 'title', id: 0, key: '' } ]
+ ] )( 'suggestion as %s', ( _name, suggestion, extraParams = '' ) => {
+ expect( urlGenerator( '/w/index.php' ).generateUrl( suggestion ) )
+ .toBe( `/w/index.php?title=Special%3ASearch${ extraParams }&search=title` );
+ } );
+
+ test( 'custom params, articlePath', () => {
+ expect( urlGenerator( '/W/INDEX.PHP' ).generateUrl(
+ { title: 'title', id: 0, key: '' },
+ { TITLE: 'SPECIAL:SEARCH' }
+ ) ).toBe( '/W/INDEX.PHP?TITLE=SPECIAL%3ASEARCH&search=title' );
+ } );
+ } );
+} );

File Metadata

Mime Type
text/x-diff
Expires
Sat, Jul 5, 5:31 AM (15 h, 11 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
227491
Default Alt Text
(39 KB)

Event Timeline