Show referencePreviews on click

Introducing the REFERENCE_CLICK action that will fetch and show the
preview for the clicked reference right away without any delay.

The main goal of the new chain of events introduced with the reference
click is showing the reference preview right away. The actions triggered
by the dwelling include delays in multiple parts of the process.

If there's a dwell action-chain in progress when the click action is
executed, the related promise ( that might still include steps with
delays ) and the reference preview is retrieved and shown right away
re-using the token. 

In the case where there's no dwell action running ( e.g. when the click
was triggered via touch ) we create a new token and start from scratch.

In either case we want to avoid, that multiple clicks trigger multiple
actions and abort early when there's already a click action in progress.

Bug: T218765
Change-Id: I073a93be2d17a21178aebe12267765f60a2811b9
This commit is contained in:
WMDE-Fisch 2019-04-05 12:31:54 +02:00
parent e18e92dbf9
commit 1879a4d59e
10 changed files with 223 additions and 10 deletions

View file

@ -53,7 +53,7 @@
"bundlesize": [ "bundlesize": [
{ {
"path": "resources/dist/index.js", "path": "resources/dist/index.js",
"maxSize": "13.5KB" "maxSize": "13.6KB"
} }
] ]
} }

Binary file not shown.

Binary file not shown.

View file

@ -8,6 +8,7 @@ export default {
ABANDON_START: 'ABANDON_START', ABANDON_START: 'ABANDON_START',
ABANDON_END: 'ABANDON_END', ABANDON_END: 'ABANDON_END',
LINK_CLICK: 'LINK_CLICK', LINK_CLICK: 'LINK_CLICK',
REFERENCE_CLICK: 'REFERENCE_CLICK',
/** Precedes a fetch. */ /** Precedes a fetch. */
FETCH_START: 'FETCH_START', FETCH_START: 'FETCH_START',
/** Follows a successful fetch. */ /** Follows a successful fetch. */

View file

@ -308,6 +308,59 @@ export function linkClick( el ) {
} ); } );
} }
/**
* Represents the user clicking on a reference preview link with their mouse, keyboard, or an
* assistive device.
*
* @param {mw.Title} title
* @param {Element} el
* @param {Gateway} gateway
* @param {Function} generateToken
* @return {Redux.Thunk}
*/
export function referenceClick( title, el, gateway, generateToken ) {
return ( dispatch, getState ) => {
const {
activeLink,
activeToken: dwellToken,
promise: dwellPromise,
wasClicked
} = getState().preview;
if ( wasClicked ) {
return $.Deferred().resolve().promise();
}
const xhr = gateway.fetchPreviewForTitle( title, el );
function clickFollowsDwellEvent() {
return activeLink === el && dwellToken !== '';
}
let token = dwellToken;
if ( !clickFollowsDwellEvent() ) {
token = generateToken();
} else {
dwellPromise.abort();
}
dispatch( timedAction( {
type: types.REFERENCE_CLICK,
el,
token
} ) );
return xhr.then( ( result ) => {
dispatch( {
type: types.FETCH_COMPLETE,
el,
result,
token
} );
} );
};
}
/** /**
* Represents the user dwelling on a preview with their mouse. * Represents the user dwelling on a preview with their mouse.
* *

View file

@ -263,11 +263,25 @@ function registerChangeListeners(
boundActions.abandon(); boundActions.abandon();
} }
} ) } )
.on( 'click', validLinkSelector, function () { .on( 'click', validLinkSelector, function ( event ) {
const mwTitle = titleFromElement( this, mw.config ); const mwTitle = titleFromElement( this, mw.config );
if ( mwTitle ) { if ( mwTitle ) {
boundActions.linkClick( this ); const type = getPreviewType( this, mw.config, mwTitle );
switch ( type ) {
case previewTypes.TYPE_PAGE:
boundActions.linkClick( this );
break;
case previewTypes.TYPE_REFERENCE:
event.preventDefault();
boundActions.referenceClick(
mwTitle,
this,
referenceGateway,
generateToken
);
break;
}
} }
} ); } );
}() ); }() );

View file

@ -17,7 +17,8 @@ export default function preview( state, action ) {
activeEvent: undefined, activeEvent: undefined,
activeToken: '', activeToken: '',
shouldShow: false, shouldShow: false,
isUserDwelling: false isUserDwelling: false,
wasClicked: false
}; };
} }
@ -56,6 +57,14 @@ export default function preview( state, action ) {
isUserDwelling: true isUserDwelling: true
} ); } );
case actionTypes.REFERENCE_CLICK:
return nextState( state, {
activeLink: action.el,
activeToken: action.token,
isUserDwelling: true,
wasClicked: true
} );
case actionTypes.ABANDON_END: case actionTypes.ABANDON_END:
if ( action.token === state.activeToken && !state.isUserDwelling ) { if ( action.token === state.activeToken && !state.isUserDwelling ) {
return nextState( state, { return nextState( state, {
@ -75,7 +84,8 @@ export default function preview( state, action ) {
case actionTypes.ABANDON_START: case actionTypes.ABANDON_START:
return nextState( state, { return nextState( state, {
isUserDwelling: false isUserDwelling: false,
wasClicked: false
} ); } );
case actionTypes.FETCH_START: case actionTypes.FETCH_START:

View file

@ -613,3 +613,116 @@ QUnit.test( 'PREVIEW_SEEN action not called if preview type not page', function
); );
} ); } );
} ); } );
QUnit.module( 'ext.popups/actions#referenceClick @integration', {
beforeEach() {
this.state = {
preview: {}
};
this.getState = () => this.state;
this.gatewayDeferred = $.Deferred();
this.gateway = {
fetchPreviewForTitle: this.sandbox.stub().returns(
this.gatewayDeferred.resolve( {} ).promise()
)
};
// lets just set this to always return 0
mw.now = () => 0;
setupEl( this );
}
} );
QUnit.test( '#referenceClick', function ( assert ) {
const dispatch = this.sandbox.spy();
this.sandbox.stub( mw, 'now' ).returns( new Date() );
const referenceClicked = actions.referenceClick(
this.title, this.el, this.gateway, generateToken
)(
dispatch,
this.getState
);
assert.propEqual(
dispatch.getCall( 0 ).args[ 0 ], {
type: actionTypes.REFERENCE_CLICK,
el: this.el,
token: 'ABC',
timestamp: mw.now()
},
'The dispatcher was called with the correct REFERENCE_CLICK arguments.'
);
return referenceClicked.then( () => {
assert.propEqual(
dispatch.getCall( 1 ).args[ 0 ], {
type: actionTypes.FETCH_COMPLETE,
el: this.el,
result: {},
token: 'ABC'
},
'The dispatcher was called with the correct FETCH_COMPLETE arguments.'
);
} );
} );
QUnit.test( '#referenceClick doesn\'t continue when clicked several times', function ( assert ) {
const dispatch = this.sandbox.spy();
this.state.preview = {
wasClicked: true
};
const referenceClicked = actions.referenceClick(
this.title, this.el, this.gateway, generateToken
)(
dispatch,
this.getState
);
return referenceClicked.then( () => {
assert.ok(
dispatch.notCalled,
'The dispatcher was never called.'
);
} );
} );
QUnit.test( '#referenceClick re-uses the linkDwell token if present', function ( assert ) {
const dispatch = this.sandbox.spy(),
oldDeferred = $.Deferred().promise( { abort() {} } );
this.state.preview = {
activeLink: this.el,
activeToken: 'OLD_TOKEN',
promise: oldDeferred
};
const referenceClicked = actions.referenceClick(
this.title, this.el, this.gateway, generateToken
)(
dispatch,
this.getState
);
assert.propEqual(
dispatch.getCall( 0 ).args[ 0 ], {
type: actionTypes.REFERENCE_CLICK,
el: this.el,
token: 'OLD_TOKEN',
timestamp: mw.now()
},
'The dispatcher was called with the correct REFERENCE_CLICK arguments.'
);
return referenceClicked.then( () => {
assert.ok(
dispatch.calledTwice,
'The dispatcher was called twice.'
);
} );
} );

View file

@ -21,7 +21,8 @@ QUnit.test( '@@INIT', ( assert ) => {
activeEvent: undefined, activeEvent: undefined,
activeToken: '', activeToken: '',
shouldShow: false, shouldShow: false,
isUserDwelling: false isUserDwelling: false,
wasClicked: false
}, },
'The initial state is correct.' 'The initial state is correct.'
); );
@ -196,6 +197,7 @@ QUnit.test( 'FETCH_COMPLETE', ( assert ) => {
{ {
activeToken: token, activeToken: token,
isUserDwelling: false, // Set when ABANDON_START is reduced. isUserDwelling: false, // Set when ABANDON_START is reduced.
wasClicked: false,
fetchResponse: action.result, fetchResponse: action.result,
shouldShow: false shouldShow: false
@ -286,8 +288,28 @@ QUnit.test( 'ABANDON_START', ( assert ) => {
assert.deepEqual( assert.deepEqual(
preview( {}, action ), preview( {}, action ),
{ {
isUserDwelling: false isUserDwelling: false,
wasClicked: false
}, },
'ABANDON_START should mark the preview having been abandoned.' 'ABANDON_START should mark the preview having been abandoned.'
); );
} ); } );
QUnit.test( 'REFERENCE_CLICK updates the state for a click', function ( assert ) {
const action = {
type: actionTypes.REFERENCE_CLICK,
el: this.el,
token: '1234567890'
};
assert.deepEqual(
preview( {}, action ),
{
activeLink: action.el,
activeToken: action.token,
isUserDwelling: true,
wasClicked: true
},
'It should set active link and token as well as dwelling and click status.'
);
} );

View file

@ -110,8 +110,8 @@ module.exports = ( env, argv ) => ( {
// Minified uncompressed size limits for chunks / assets and entrypoints. Keep these numbers // Minified uncompressed size limits for chunks / assets and entrypoints. Keep these numbers
// up-to-date and rounded to the nearest 10th of a kibibyte so that code sizing costs are // up-to-date and rounded to the nearest 10th of a kibibyte so that code sizing costs are
// well understood. Related to bundlesize minified, gzipped compressed file size tests. // well understood. Related to bundlesize minified, gzipped compressed file size tests.
maxAssetSize: 41 * 1024, maxAssetSize: 41.5 * 1024,
maxEntrypointSize: 41 * 1024, maxEntrypointSize: 41.5 * 1024,
// The default filter excludes map files but we rename ours. // The default filter excludes map files but we rename ours.
assetFilter: ( filename ) => !filename.endsWith( srcMapExt ) assetFilter: ( filename ) => !filename.endsWith( srcMapExt )