Quick View: Add search results snippet to QuickView panel

- [x] Add snippets to QuickView
- [x] Add "Go to full article" link
- [x] Restore QuickView on Back button
- [x] Set Jest Mocks and configuration
- [x] Update unit tests

Bug: T311644
Change-Id: I2de94e97f6d2800f66b4fda0554e104aa16fcd75
这个提交包含在:
Simone Cuomo 2022-08-23 13:22:38 +01:00
父节点 19dc8f69ec
当前提交 5f308b27a7
共有 14 个文件被更改,包括 334 次插入45 次删除

1
.gitignore vendored
查看文件

@ -1,3 +1,4 @@
/coverage
/composer.lock
/vendor
/node_modules

查看文件

@ -45,7 +45,7 @@
"resources/components/App.vue",
"resources/components/QuickView.vue",
"resources/components/LeadImage.vue",
"resources/components/LeadText.vue",
"resources/components/QuickViewSnippet.vue",
"resources/store/index.js",
"resources/store/state.js",
"resources/store/mutations.js",
@ -72,7 +72,8 @@
"messages": [
"quickview-close",
"quickview-previous",
"quickview-next"
"quickview-next",
"quickview-snippet-gotofullarticle"
]
}
},

查看文件

@ -8,6 +8,7 @@
"quickview-close": "Close",
"quickview-next": "Next",
"quickview-previous": "previous",
"quickview-snippet-gotofullarticle": "Go to full article",
"searchpreview-label": "Enable search result preview",
"searchpreview-help": "Preview the contents of the article and related pages."
}

查看文件

@ -8,6 +8,7 @@
"quickview-close": "Title for the svg used to close the Quick View on mobile view",
"quickview-next": "Title of the svg used to move to the next result",
"quickview-previous": "Title of the svg used to move to the previous result",
"quickview-snippet-gotofullarticle": "Button shown at the bottom of the result snippet to navigate to the full article",
"searchpreview-label": "Used in [[Special:Preferences]], tab \"Search\". Offer the user the ability to enable or disable the search preview feature.",
"searchpreview-help": "Help text for user preference that offer the user the ability to enable or disable the search preview feature."
}

65
jest.config.js 普通文件
查看文件

@ -0,0 +1,65 @@
/*
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/en/configuration.html
*/
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/lw/zjjyns4n3n11bnzbgvqkh1_m0000gn/T/jest_dx",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: [
'resources/**/*.(js|vue)'
],
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
coveragePathIgnorePatterns: [
'/node_modules/'
],
// The paths to modules that run some code to configure or set up the testing environment before each test
setupFiles: [
'./jest.setup.js'
],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: 'jsdom',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: [
'/node_modules/'
],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
transform: {
'.*\\.(vue)$': '<rootDir>/node_modules/@vue/vue3-jest'
}
};

80
jest.setup.js 普通文件
查看文件

@ -0,0 +1,80 @@
const $ = require( 'jquery' );
/* global jest:false */
function Api() {}
Api.prototype.get = jest.fn().mockReturnValue( $.Deferred().resolve().promise() );
Api.prototype.post = jest.fn().mockResolvedValue( {} );
Api.prototype.getToken = jest.fn().mockResolvedValue( {} );
Api.prototype.postWithToken = jest.fn().mockResolvedValue( {} );
Api.prototype.saveOption = jest.fn();
function Title() {}
Title.prototype.getMainText = jest.fn().mockReturnValue( '' );
Title.prototype.getName = jest.fn().mockReturnValue( '' );
Title.prototype.getNamespaceId = jest.fn().mockReturnValue( 0 );
Title.prototype.getNamespacePrefix = jest.fn().mockReturnValue( '' );
Title.newFromText = jest.fn().mockReturnValue( {
fragment: null,
namespace: 0,
title: '',
getMainText: jest.fn().mockReturnValue( 'mock' ),
getName: jest.fn().mockReturnValue( 'mock' ),
getExtension: jest.fn().mockReturnValue( 'mock' ),
getNamespaceId: jest.fn().mockReturnValue( 0 )
} );
const mw = {
Api: Api,
config: {
get: jest.fn()
},
message: jest.fn().mockReturnValue( {
text: jest.fn(),
parse: jest.fn(),
params: jest.fn( () => mw.message() )
} ),
msg: jest.fn().mockReturnValue( '' ),
Uri: jest.fn().mockReturnValue( {
getQueryString: jest.fn(),
query: {
type: ''
}
} ),
Title: Title,
util: {
parseImageUrl: jest.fn().mockReturnValue( {
resizeUrl: jest.fn()
} )
},
language: {
convertNumber: jest.fn()
},
storage: {
get: jest.fn(),
setObject: jest.fn(),
getObject: jest.fn(),
remove: jest.fn()
},
notify: jest.fn(),
OgvJsSupport: {
basePath: jest.fn(),
loadIfNeeded: jest.fn()
},
loader: {
using: jest.fn()
},
user: {
options: {
get: jest.fn().mockReturnValue( 0 ),
set: jest.fn()
},
isAnon: jest.fn().mockReturnValue( false )
}
};
/*
* MW front-end code expects certain global variables to exist in the
* environment. This sets up things like "mw" object, jquery, etc. for use
* in tests.
*/
global.mw = mw;
global.$ = require( 'jquery' );

查看文件

@ -64,6 +64,16 @@ module.exports = exports = {
// Set the correct offset to align with the search results
this.offsetTop = element.offsetTop || 0 + 'px';
}
},
restoreQuickViewOnNavigation() {
const mwUri = new mw.Uri();
const queryHasQuickView = !!mwUri.query.quickView;
if ( queryHasQuickView ) {
const title = mwUri.query.quickView;
this.handleTitleChange( title );
}
}
}
),
@ -96,6 +106,10 @@ module.exports = exports = {
this.closeQuickView();
}
}.bind( this ) );
// Restore the quick view in the case in which the user has navigated back to a page
// that had a quickView open
this.restoreQuickViewOnNavigation();
}
};
</script>
@ -109,9 +123,8 @@ module.exports = exports = {
&__desktop {
border: solid 1px #c8CCd1;
right: 0px;
max-width: 30em;
width: 30em;
display: inline-block;
margin-left: 8%;
}
&__mobile {

查看文件

@ -1,22 +0,0 @@
<template>
<p>{{ title }}</p>
</template>
<script>
/**
* @file LeadText.vue
*
* Placeholder
*/
const mapState = require( 'vuex' ).mapState;
// @vue/component
module.exports = exports = {
name: 'LeadImage',
computed: $.extend( {},
mapState( [
'title'
] )
)
};
</script>

查看文件

@ -30,7 +30,10 @@
<header>
<lead-image></lead-image>
</header>
<lead-text></lead-text>
<quick-view-snippet
:text="currentResult.text"
:title="currentResult.prefixedText"
></quick-view-snippet>
</div>
</template>
@ -41,8 +44,9 @@
* Placeholder
*/
const LeadImage = require( './LeadImage.vue' ),
LeadText = require( './LeadText.vue' ),
QuickViewSnippet = require( './QuickViewSnippet.vue' ),
mapActions = require( 'vuex' ).mapActions,
mapGetters = require( 'vuex' ).mapGetters,
mapState = require( 'vuex' ).mapState;
// @vue/component
@ -50,7 +54,7 @@ module.exports = exports = {
name: 'QuickView',
components: {
'lead-image': LeadImage,
'lead-text': LeadText
'quick-view-snippet': QuickViewSnippet
},
data: function () {
return {};
@ -58,6 +62,9 @@ module.exports = exports = {
computed: $.extend( {},
mapState( [
'isMobile'
] ),
mapGetters( [
'currentResult'
] )
),
methods: $.extend( {},

查看文件

@ -0,0 +1,43 @@
<template>
<div class="quickViewSnippet">
<!-- eslint-disable vue/no-v-html -->
<p v-html="text"></p>
<a :href="url">{{ $i18n( 'quickview-snippet-gotofullarticle' ).text() }}</a>
</div>
</template>
<script>
/**
* @file LeadText.vue
*
* Placeholder
*/
// @vue/component
module.exports = exports = {
name: 'QuickViewSnippet',
props: {
title: {
type: String,
required: true
},
text: {
type: String,
required: true
}
},
computed: {
url() {
const title = new mw.Title( this.title );
return title.getUrl();
}
}
};
</script>
<style>
.quickViewSnippet{
width: 365px;
margin: 4px auto;
}
</style>

查看文件

@ -1,4 +1,39 @@
'use strict';
/**
* Push the current provided title to the browser's session history stack
*
* @param {string} title
*/
const pushTitleToHistoryState = ( title ) => {
const mwUri = new mw.Uri();
// update mw URI query object with the one currently available within the store
// In Vue 3, context.state.uriQuery is a Proxy, and passing it to replaceState()
// causes an error saying it can't be cloned. Work around this by cloning the uriQuery
// object ourselves, using JSON.parse( JSON.stringify() ) to convert the Proxy to Object.
const existingQuery = JSON.parse( JSON.stringify( mwUri.query ) );
mwUri.query = $.extend(
{},
existingQuery,
{ quickView: title }
);
const queryString = '?' + mwUri.getQueryString();
window.history.pushState( mwUri.query, null, queryString );
};
/**
* Remove the value of QuickView from the history State.
*/
const removeQuickViewFromHistoryState = () => {
const mwUri = new mw.Uri();
// update mw URI query object with the one currently available within the store
// In Vue 3, context.state.uriQuery is a Proxy, and passing it to replaceState()
// causes an error saying it can't be cloned. Work around this by cloning the uriQuery
// object ourselves, using JSON.parse( JSON.stringify() ) to convert the Proxy to Object.
mwUri.query = JSON.parse( JSON.stringify( mwUri.query ) );
delete mwUri.query.quickView;
const queryString = '?' + mwUri.getQueryString();
window.history.pushState( mwUri.query, null, queryString );
};
module.exports = {
/**
@ -15,13 +50,13 @@ module.exports = {
return;
}
// Close the quickview if the snippets clicked is current one
// Close the quickview if the snippet clicked is current one
if ( context.state.title === title ) {
context.commit( 'SET_TITLE', null );
context.commit( 'SET_SELECTED_INDEX', -1 );
context.dispatch( 'closeQuickView' );
} else {
// TODO -> Retrieve snippets information and save them in the store
// TODO -> Retrieve snippet information and save them in the store
context.commit( 'SET_TITLE', title );
pushTitleToHistoryState( title );
const selectedTitleIndex = context.state.results.findIndex( ( result ) => {
return result.prefixedText === title;
@ -38,6 +73,8 @@ module.exports = {
*/
closeQuickView: ( context ) => {
context.commit( 'SET_TITLE', null );
context.commit( 'SET_SELECTED_INDEX', -1 );
removeQuickViewFromHistoryState();
},
/**
* Navigate results

查看文件

@ -10,5 +10,21 @@ module.exports = {
*/
visible: ( state ) => {
return state.title !== null;
},
/**
* Returns the currently selected result.
*
* @param {Object} state
*
* @return {Object}
*/
currentResult: ( state ) => {
if ( state.selectedIndex === -1 ) {
return {};
}
return state.results[ state.selectedIndex ];
}
};

查看文件

@ -0,0 +1,2 @@
window.history.pushState = jest.fn();
window.history.replaceState = jest.fn();

查看文件

@ -1,5 +1,7 @@
const initialState = require( '../fixtures/initialVuexState.js' );
require( '../mocks/history.js' );
let context;
let actions;
@ -19,6 +21,7 @@ beforeEach( () => {
actions.getters = context.getters;
actions.state = context.state;
actions.commit = context.commit;
actions.dispatch = context.dispatch;
} );
afterEach( () => {
@ -38,22 +41,13 @@ describe( 'Actions', () => {
} );
} );
describe( 'when title provided is the same as state.title', () => {
it( 'Set title to null', () => {
it( 'Dispacth a call to closeQuickView', () => {
const title = 'dummy';
context.state.title = title;
actions.handleTitleChange( context, title );
expect( actions.commit ).toHaveBeenCalled();
expect( actions.commit ).toHaveBeenCalledWith( 'SET_TITLE', null );
} );
it( 'Set selected index to -1', () => {
const title = 'dummy';
context.state.title = title;
actions.handleTitleChange( context, title );
expect( actions.commit ).toHaveBeenCalled();
expect( actions.commit ).toHaveBeenCalledWith( 'SET_SELECTED_INDEX', -1 );
expect( actions.dispatch ).toHaveBeenCalled();
expect( actions.dispatch ).toHaveBeenCalledWith( 'closeQuickView' );
} );
} );
@ -86,6 +80,20 @@ describe( 'Actions', () => {
expect( actions.commit ).toHaveBeenCalledWith( 'SET_SELECTED_INDEX', 1 );
} );
it( 'Adds QuickView to history state', () => {
const title = 'dummy';
actions.handleTitleChange( context, title );
expect( window.history.pushState ).toHaveBeenCalled();
expect( window.history.pushState ).toHaveBeenCalledWith(
expect.objectContaining(
{ quickView: title }
),
null,
expect.anything()
);
} );
} );
} );
@ -182,4 +190,40 @@ describe( 'Actions', () => {
} );
} );
} );
describe( 'closeQuickView', () => {
it( 'Set title to null', () => {
const title = 'dummy';
context.state.title = title;
actions.closeQuickView( context, title );
expect( actions.commit ).toHaveBeenCalled();
expect( actions.commit ).toHaveBeenCalledWith( 'SET_TITLE', null );
} );
it( 'Set selected index to -1', () => {
const title = 'dummy';
context.state.title = title;
actions.closeQuickView( context, title );
expect( actions.commit ).toHaveBeenCalled();
expect( actions.commit ).toHaveBeenCalledWith( 'SET_SELECTED_INDEX', -1 );
} );
it( 'Removes QuickView to history state', () => {
const title = 'dummy';
context.state.title = title;
actions.closeQuickView( context, title );
expect( window.history.pushState ).toHaveBeenCalled();
expect( window.history.pushState ).toHaveBeenCalledWith(
expect.not.objectContaining(
{ quickView: title }
),
null,
expect.anything()
);
} );
} );
} );