Mattflaschen has uploaded a new change for review. ( https://gerrit.wikimedia.org/r/378827 )
Change subject: Allow directly launching tour from server without ?tour= or cookies ...................................................................... Allow directly launching tour from server without ?tour= or cookies Bug: T167262 Change-Id: I313edff5efb51724c75bd95351dab402154f28c6 --- M GuidedTourLauncher.php M extension.json M modules/ext.guidedTour.lib.internal.js M modules/ext.guidedTour.lib/ext.guidedTour.lib.main.js M tests/phpunit/GuidedTourLauncherTest.php 5 files changed, 289 insertions(+), 133 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/GuidedTour refs/changes/27/378827/1 diff --git a/GuidedTourLauncher.php b/GuidedTourLauncher.php index 6ce257c..79d9096 100644 --- a/GuidedTourLauncher.php +++ b/GuidedTourLauncher.php @@ -5,6 +5,44 @@ */ class GuidedTourLauncher { /** + * State used to tell the client to directly launch tours using a client-side $wg + * + * @var array|null $directLaunchState + */ + protected static $directLaunchState = null; + + // This matches the format used on the client-side (e.g. + // mw.guidedTour.internal.getInitialUserStateObject, + // mw.guidedTour.launchTourFromUserState, etc. + /** + * Get new state from old state. The state describes the user's progress + * in the tour, and which step they are expected to see next. + * + * @param {array|null} $oldState Previous state + * @param {string} $tourName Tour name + * @param {string} $step Step to start at + * @return {array} New state + */ + public static function getNewState( $oldState, $tourName, $step ) { + $newState = $oldState; + + if ( $newState === null ) { + $newState = []; + } + + $newState = array_replace_recursive( $newState, [ + 'version' => 1, + 'tours' => [ + $tourName => [ + 'step' => $step, + ], + ] + ] ); + + return $newState; + } + + /** * Adds a tour to the cookie * * @param {string|null} $oldCookieValue Previous value of cookie @@ -17,23 +55,41 @@ $oldCookieValue = '{}'; } - $newCookie = FormatJson::decode( $oldCookieValue, true ); - if ( $newCookie === null ) { - $newCookie = []; + $oldState = FormatJson::decode( $oldCookieValue, true ); + if ( $oldState === null ) { + $oldState = []; } - $newCookie = array_replace_recursive( $newCookie, [ - 'version' => 1, - 'tours' => [ - $tourName => [ - 'step' => $step, - ], - ] - ] ); + $newState = self::getNewState( $oldState, $tourName, $step ); - return FormatJson::encode( $newCookie ); + return FormatJson::encode( $newState ); } + public static function launchTour( $tourName, $step ) { + global $wgOut; + + self::$directLaunchState = self::getNewState( + self::$directLaunchState, + $tourName, + $step + ); + + GuidedTourHooks::addTour( $wgOut, $tourName ); + } + + /** + * Adds a client-side $wg variable to control tour launch + * + * @param array &$vars Array of request-specific JavaScript config variables + * @param OutputPage $out + */ + public static function addLaunchVariable( array &$vars, OutputPage $out ) { + if ( self::$directLaunchState !== null ) { + $vars['wgGuidedTourLaunchState'] = self::$directLaunchState; + } + } + + /** * Sets a tour to auto-launch on this view using a cookie. */ diff --git a/extension.json b/extension.json index 7aa3ee0..e1545fa 100644 --- a/extension.json +++ b/extension.json @@ -166,6 +166,9 @@ ], "EventLoggingRegisterSchemas": [ "GuidedTourHooks::addEventLogging" + ], + "MakeGlobalVariablesScript": [ + "GuidedTourLauncher::addLaunchVariable" ] }, "manifest_version": 1 diff --git a/modules/ext.guidedTour.lib.internal.js b/modules/ext.guidedTour.lib.internal.js index 8d2f86d..7d4ae08 100644 --- a/modules/ext.guidedTour.lib.internal.js +++ b/modules/ext.guidedTour.lib.internal.js @@ -68,6 +68,7 @@ return dfd.promise(); }, + // This should match GuidedTourLauncher::getNewState /** * Returns object used for an initial user state, optionally populating it with one * tour's data. @@ -216,7 +217,8 @@ }, /** - * Parses user state (as used in the cookie), which is passed in as JSON + * Parses user state (as used in the cookie and wgGuidedTourLaunchState), which + * is passed in as JSON * * @param {string} userStateJson User state, as JSON * diff --git a/modules/ext.guidedTour.lib/ext.guidedTour.lib.main.js b/modules/ext.guidedTour.lib/ext.guidedTour.lib.main.js index 5b953cd..6b56db4 100644 --- a/modules/ext.guidedTour.lib/ext.guidedTour.lib.main.js +++ b/modules/ext.guidedTour.lib/ext.guidedTour.lib.main.js @@ -41,7 +41,7 @@ * invalid, returns a skeleton state object from * mw.guidedTour.internal#getInitialUserStateObject */ - function getUserState() { + function getCookieState() { var cookieValue, parsed; cookieValue = mw.cookie.get( cookieName ); parsed = internal.parseUserState( cookieValue ); @@ -50,6 +50,31 @@ } else { return internal.getInitialUserStateObject(); } + } + + /** + * Returns the current combined user state (cookie state and server-launched state) + * Basically, this acts like the cookie state, except the server can specify + * tours that take precedence via a $wg. + * + * @private + * + * @return {Object} combined state object. If there is none, or the format was + * invalid, returns a skeleton state object from + * mw.guidedTour.internal#getInitialUserStateObject + */ + function getUserState() { + var + cookieState = getCookieState(), + serverState = mw.config.get( 'wgGuidedTourLaunchState' ), + state = cookieState; + + if ( serverState !== null ) { + state = $.extend( true, state, serverState ); + } + + return state; + } /** @@ -62,11 +87,76 @@ * @return {void} */ function removeTourFromUserStateByName( tourName ) { - var parsedCookie = getUserState(); + var parsedCookie = getCookieState(); delete parsedCookie.tours[tourName]; mw.cookie.set( cookieName, JSON.stringify( parsedCookie ), cookieParams ); } + /** + * Launch tour from given user state + * + * @private + * + * @param {Object} state State that specifies the tour progress + * + * @return {boolean} Whether a tour was launched + */ + function launchTourFromState( state ) { + var tourName, tourNames, + candidateTours = []; + + for ( tourName in state.tours ) { + candidateTours.push( { + name: tourName, + step: state.tours[tourName].step + } ); + } + + tourNames = $.map( candidateTours, function ( el ) { + return el.name; + } ); + + internal.loadMultipleTours( tourNames ) + .always( function () { + var tourName, max, currentStart; + + // This value is before 1970, but is a simple way + // to ensure the comparison below always works. + max = { + startTime: -1 + }; + + // Not all the tours in the cookie necessarily + // loaded successfully, but the defined tours did. + // So we make sure it is defined and in the user + // state. + for ( tourName in internal.definedTours ) { + if ( state.tours[tourName] !== undefined && + gt.shouldShowTour( { + tourName: tourName, + userState: state, + pageName: mw.config.get( 'wgPageName' ), + articleId: mw.config.get( 'wgArticleId' ), + condition: internal.definedTours[tourName].showConditionally + } ) ) { + currentStart = state.tours[tourName].startTime || 0; + if ( currentStart > max.startTime ) { + max = { + name: tourName, + step: state.tours[tourName].step, + startTime: currentStart + }; + } + } + } + + if ( max.name !== undefined ) { + // Launch the most recently started tour + // that meets the conditions. + gt.launchTour( max.name, gt.makeTourId( max ) ); + } + } ); + } // TODO (mattflaschen, 2013-07-10): Known issue: This runs too early on a direct // visit to a veaction=edit page. This probably affects other JS-generated @@ -358,67 +448,14 @@ }, /** - * Attempts to launch a tour from the user state (cookie) + * Attempts to launch a tour from combined user state (cookie + tours launched + * directly by server) * * @return {boolean} Whether a tour was launched */ launchTourFromUserState: function () { - var tourName, tourNames, - userState, candidateTours = []; - - - userState = getUserState(); - - for ( tourName in userState.tours ) { - candidateTours.push( { - name: tourName, - step: userState.tours[tourName].step - } ); - } - - tourNames = $.map( candidateTours, function ( el ) { - return el.name; - } ); - internal.loadMultipleTours( tourNames ) - .always( function () { - var tourName, max, currentStart; - - // This value is before 1970, but is a simple way - // to ensure the comparison below always works. - max = { - startTime: -1 - }; - - // Not all the tours in the cookie necessarily - // loaded successfully, but the defined tours did. - // So we make sure it is defined and in the user - // state. - for ( tourName in internal.definedTours ) { - if ( userState.tours[tourName] !== undefined && - gt.shouldShowTour( { - tourName: tourName, - userState: userState, - pageName: mw.config.get( 'wgPageName' ), - articleId: mw.config.get( 'wgArticleId' ), - condition: internal.definedTours[tourName].showConditionally - } ) ) { - currentStart = userState.tours[tourName].startTime || 0; - if ( currentStart > max.startTime ) { - max = { - name: tourName, - step: userState.tours[tourName].step, - startTime: currentStart - }; - } - } - } - - if ( max.name !== undefined ) { - // Launch the most recently started tour - // that meets the conditions. - gt.launchTour( max.name, gt.makeTourId( max ) ); - } - } ); + var state = getUserState(); + return launchTourFromState( state ); }, /** @@ -438,7 +475,6 @@ // Tour is either in the query string or cookie (prefer query string) if ( this.launchTourFromQueryString() ) { - return; } @@ -707,7 +743,7 @@ userState = getUserState(); if ( ( step === 0 ) && userState.tours[tourName] !== undefined ) { - // start from cookie position + // start from user state position showTour( tourName, gt.makeTourId( { name: tourName, step: userState.tours[tourName].step @@ -741,7 +777,7 @@ }, /** - * Updates a single tour in the user state. The tour must already be loaded. + * Updates a single tour in the user cookie state. The tour must already be loaded. * * @private * @@ -754,34 +790,34 @@ * @return {void} */ updateUserStateForTour: function ( args ) { - var userState = getUserState(), tourName, tourSpec, articleId, pageName, - cookieValue; + var cookieState = getCookieState(), tourName, tourSpec, articleId, pageName, + cookieValue; tourName = args.tourInfo.name; // It should be defined, except when wasShown is false. tourSpec = internal.definedTours[tourName] || {}; // Ensure there's a sub-object for this tour - if ( userState.tours[tourName] === undefined ) { - userState.tours[tourName] = {}; + if ( cookieState.tours[tourName] === undefined ) { + cookieState.tours[tourName] = {}; - userState.tours[tourName].startTime = new Date().getTime(); + cookieState.tours[tourName].startTime = new Date().getTime(); } if ( args.wasShown && tourSpec.showConditionally === 'stickToFirstPage' && - userState.tours[tourName].firstArticleId === undefined && - userState.tours[tourName].firstSpecialPageName === undefined ) { + cookieState.tours[tourName].firstArticleId === undefined && + cookieState.tours[tourName].firstSpecialPageName === undefined ) { articleId = mw.config.get( 'wgArticleId' ); if ( articleId !== 0 ) { - userState.tours[tourName].firstArticleId = articleId; + cookieState.tours[tourName].firstArticleId = articleId; } else { pageName = mw.config.get( 'wgPageName' ); - userState.tours[tourName].firstSpecialPageName = pageName; + cookieState.tours[tourName].firstSpecialPageName = pageName; } } - userState.tours[tourName].step = String( args.tourInfo.step ); - cookieValue = JSON.stringify( userState ); + cookieState.tours[tourName].step = String( args.tourInfo.step ); + cookieValue = JSON.stringify( cookieState ); mw.cookie.set( cookieName, cookieValue, cookieParams ); }, @@ -821,20 +857,20 @@ * @throws {mw.guidedTour.TourDefinitionError} On invalid conditions */ shouldShowTour: function ( args ) { - var subCookie = args.userState.tours[args.tourName]; + var subState = args.userState.tours[args.tourName]; if ( args.condition !== undefined ) { // TODO (mattflaschen, 2013-07-09): Allow having multiple // conditions ANDed together in an array. switch ( args.condition ) { case 'stickToFirstPage': - if ( subCookie === undefined ) { + if ( subState === undefined ) { // Not yet shown return true; } - if ( subCookie.firstArticleId !== undefined ) { - return subCookie.firstArticleId === args.articleId; - } else if ( subCookie.firstSpecialPageName !== undefined ) { - return subCookie.firstSpecialPageName === args.pageName; + if ( subState.firstArticleId !== undefined ) { + return subState.firstArticleId === args.articleId; + } else if ( subState.firstSpecialPageName !== undefined ) { + return subState.firstSpecialPageName === args.pageName; } break; case 'wikitext': diff --git a/tests/phpunit/GuidedTourLauncherTest.php b/tests/phpunit/GuidedTourLauncherTest.php index 511528b..d38c774 100644 --- a/tests/phpunit/GuidedTourLauncherTest.php +++ b/tests/phpunit/GuidedTourLauncherTest.php @@ -1,9 +1,108 @@ <?php +use Wikimedia\TestingAccessWrapper; + class GuidedTourLauncherTest extends MediaWikiTestCase { + protected $wrappedLauncher; + + public function setUp() { + parent::setUp(); + + $this->wrappedLauncher = TestingAccessWrapper::newFromClass( 'GuidedTourLauncher' ); + } + + /** + * @dataProvider getNewStateProvider + */ + public function testGetNewState( $oldStateValue, $tourName, $step, $expectedNewStateValue ) { + $newStateValue = $this->wrappedLauncher->getNewState( $oldStateValue, $tourName, $step ); + $this->assertSame( + $expectedNewStateValue, + $newStateValue + ); + } + + public function getNewStateProvider() { + $simpleExpectedState = [ + 'version' => 1, + 'tours' => [ + 'example' => [ + 'step' => 'bar', + ], + ], + ]; + + return [ + [ + [ + 'version' => 1, + 'tours' => [ + 'example' => [ + 'step' => 'foo', + 'firstArticleId' => 123, + ], + ], + ], + 'example', + 'bar', + [ + 'version' => 1, + 'tours' => [ + 'example' => [ + 'step' => 'bar', + 'firstArticleId' => 123, + ], + ] + ] + ], + + [ + null, + 'example', + 'bar', + $simpleExpectedState, + ], + + [ + [], + 'example', + 'bar', + $simpleExpectedState + ], + + [ + [ + 'version' => 1, + 'tours' => [ + 'someOtherTour' => [ + 'step' => 'baz', + 'firstSpecialPageName' => 'Special:Watchlist', + ], + ] + ], + 'example', + 'bar', + [ + 'version' => 1, + 'tours' => [ + 'someOtherTour' => [ + 'step' => 'baz', + 'firstSpecialPageName' => 'Special:Watchlist', + ], + 'example' => [ + 'step' => 'bar', + ], + ] + ], + ], + ]; + } + + // This should mostly be covered by testGetNewState; these are a couple tests to + // handle edge cases. /** * @dataProvider getNewCookieProvider - */ + */ public function testGetNewCookie( $oldCookieValue, $tourName, $step, $expectedNewCookieValue ) { $newCookieValue = GuidedTourLauncher::getNewCookie( $oldCookieValue, $tourName, $step ); $this->assertSame( @@ -47,50 +146,10 @@ ], [ - null, - 'example', - 'bar', - $simpleExpectedCookieString, - ], - - [ '', 'example', 'bar', $simpleExpectedCookieString - ], - - [ - '{}', - 'example', - 'bar', - $simpleExpectedCookieString - ], - - [ - FormatJson::encode( [ - 'version' => 1, - 'tours' => [ - 'someOtherTour' => [ - 'step' => 'baz', - 'firstSpecialPageName' => 'Special:Watchlist', - ], - ] - ] ), - 'example', - 'bar', - FormatJson::encode( [ - 'version' => 1, - 'tours' => [ - 'someOtherTour' => [ - 'step' => 'baz', - 'firstSpecialPageName' => 'Special:Watchlist', - ], - 'example' => [ - 'step' => 'bar', - ], - ] - ] ), ], ]; } -- To view, visit https://gerrit.wikimedia.org/r/378827 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I313edff5efb51724c75bd95351dab402154f28c6 Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/extensions/GuidedTour Gerrit-Branch: master Gerrit-Owner: Mattflaschen <mflasc...@wikimedia.org> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits