Simplify the RelatedArticles extension to use Codex CSS components

Changes:
- Removes redundant styles now inside Codex
- With the new component, it's not possible to display 3 cards in a
single line at a tablet resolution, so the media query responsible
is bumped to apply only at the desktop threshold
- Decisions are documented in ADR

Bug: T286835
Change-Id: I493e8e601ccc31b3cf1f16c0b5a8975f12ef336c
This commit is contained in:
Jon Robson 2023-11-08 14:06:07 -08:00
parent ed9c2ce120
commit 71de06a682
35 changed files with 4107 additions and 12624 deletions

View file

@ -8,5 +8,15 @@
"version": ">=14",
"ignores": []
} ]
}
},
"overrides": [
{
"files": "**/**/*.vue",
"extends": "wikimedia/vue-es6",
"rules": {
"es/no-block-scoped-variables": "off",
"es/no-object-assign": "off"
}
}
]
}

2
.nvmrc
View file

@ -1 +1 @@
12.21.0
18.17.0

View file

@ -1,14 +1,14 @@
[
{
"resourceModule": "ext.relatedArticles.styles",
"maxSize": "0.2 kB"
"maxSize": "0.3 kB"
},
{
"resourceModule": "ext.relatedArticles.readMore.bootstrap",
"maxSize": "1.3 kB"
"maxSize": "0.5 kB"
},
{
"resourceModule": "ext.relatedArticles.readMore",
"maxSize": "1.9 kB"
"maxSize": "3.0 kB"
}
]

View file

@ -0,0 +1,32 @@
# 1. Use ADRs in RelatedArticles
Date: 2023-11-08
## Status
To discuss.
## Context
ADRs are being used in a variety of web team extensions (Popups and Vector). They
help us reflect on decisions we've made. RelatedArticles is seldom worked on, and
refactors and modifications are rare, meaning developers can go long periods of time
without touching the codebase, so it would be useful that decisions here are documented.
## Decision
We are adopting architecture decision records as we are in other code repositories.
## Consequences
When an decision that is deemed important to the codebase or the ecosystem
is made inside this repository, it is up to the deciding individuals
to create a new markdown file in this directory and add an ADR with regards
to that decision.The ADR should be brief and may link to Phabricator or
other places that hold more context around the decision.
ADRs may also be used to propose changes by framing the ADR as a proposal
and marking the status as "proposed". If there is agreement to adopt the
ADR, it's status should be change to "accepted".
ADRs can also be marked "deprecated" or "superseded".

View file

@ -0,0 +1,30 @@
# 1. Use Codex CSS components
Date: 2023-11-08
## Status
To discuss and refine once https://phabricator.wikimedia.org/T248718 has been resolved.
## Context
RelatedArticles is loaded when the user scrolls to the end of the page. Since it was
first made, the Codex library has come into existence and RelatedArticle's looks dated. By using Codex we can keep the extension in line with design best practices and the rest
of the UI and minimize the amount of modifications we have to make to it in future.
Given RelatedArticles can be loaded in the article namespace, when a user scrolls to the
bottom of the page, it is preferable to make use of Codex CSS components rather than
pulling in the full Vue.js library as the library is large. Loading the entirety of Vue
and Codex without a clear user interaction is prohibited (https://phabricator.wikimedia.org/T248718).
## Decision
* RelatedArticles uses Codex components.
* We are using Codex CSS components rather than Vue+Codex.
## Consequences
* Markup will need to be kept in sync with Codex CSS components.
* In future it may make sense to use Vue.js if there is greater adoption in the skin.
At the time of writing for skin only the search widget inside Vector is built in Vue.js.

View file

@ -0,0 +1,27 @@
# 1. Do not server side render
Date: 2023-11-08
## Status
To discuss.
## Context
From a product perspective we do not wish to display related articles until the user has scrolled to the bottom of the page after finishing the reading of an article (which is seen as an intention of "wanting to read more" [1]) and its click-through rates don't merit a feature that needs to be available to all users [2]. I think if we were to server-side render it, we would still add CSS to hide it (visibility: hidden) by default and reveal it on scroll.
Server side rendering would require an instance of CirrusSearch to be installed which would make development more complicated. Currently developers can point RelatedArticles at a production API when working on the extension locally.
Given there are no clear benefits of moving to server side rendering and significant work would be required to improve the development workflow in that scenario we decided to continue to not server side render (for now).
[1] https://www.mediawiki.org/wiki/Reading/Web/Projects/Related_pages
[2] https://www.mediawiki.org/wiki/Reading/Web/Projects/Related_pages#Metrics_analysis
## Decision
We do not server render Vue.js. Instead the widget is rendered via JavaScript. To avoid
the article reflowing, space is reserved for the widget given we know it's height (by proxy of knowing the number of cards that will be displayed) beforehand.
## Consequences
* Space reserved may need to be modified with Codex releases.

View file

@ -42,11 +42,35 @@
"manifest_version": 2,
"ResourceModules": {
"ext.relatedArticles.styles": {
"styles": "resources/ext.relatedArticles.styles.less"
"styles": "resources/ext.relatedArticles.styles.less",
"skinStyles": {
"default": [ "skinStyles/ext.relatedArticles.styles/default.less" ],
"minerva": []
}
},
"ext.relatedArticles.readMore.bootstrap": {
"localBasePath": "resources/ext.relatedArticles.readMore.bootstrap/",
"remoteExtPath": "RelatedArticles",
"packageFiles": [
"index.js"
],
"dependencies": [
"mediawiki.user",
"mediawiki.api",
"mediawiki.Uri",
"mediawiki.util"
]
},
"ext.relatedArticles.readMore": {
"class": "MediaWiki\\ResourceLoader\\CodexModule",
"codexStyleOnly": "true",
"codexComponents": [
"CdxCard"
],
"dependencies": [
"mediawiki.util"
],
"localBasePath": "resources/ext.relatedArticles.readMore/",
"packageFiles": [
"index.js",
"RelatedPagesGateway.js",
@ -58,34 +82,12 @@
"onlyUseCirrusSearch": "RelatedArticlesOnlyUseCirrusSearch",
"descriptionSource": "RelatedArticlesDescriptionSource"
}
}
],
"dependencies": [
"mediawiki.user",
"mediawiki.api",
"mediawiki.Uri",
"mediawiki.util"
]
},
"ext.relatedArticles.readMore": {
"dependencies": [
"mediawiki.util",
"oojs"
],
"packageFiles": [
"resources/ext.relatedArticles.readMore/index.js",
"resources/ext.relatedArticles.readMore/CardModel.js",
"resources/ext.relatedArticles.readMore/CardView.js",
"resources/ext.relatedArticles.readMore/CardListView.js"
},
"RelatedArticles.js"
],
"styles": [
"resources/ext.relatedArticles.readMore/styles.less",
"resources/ext.relatedArticles.readMore/readMore.less"
"styles.less"
],
"skinStyles": {
"default": "resources/ext.relatedArticles.readMore/readMore.default.less",
"minerva": "resources/ext.relatedArticles.readMore/readMore.minerva.less"
},
"messages": [
"relatedarticles-read-more-heading"
]
@ -96,11 +98,7 @@
"remoteExtPath": "RelatedArticles",
"packageFiles": [
"tests/qunit/index.js",
"resources/ext.relatedArticles.readMore.bootstrap/RelatedPagesGateway.js",
"resources/ext.relatedArticles.readMore/CardView.js",
"resources/ext.relatedArticles.readMore/CardModel.js",
"tests/qunit/CardModel.test.js",
"tests/qunit/CardView.test.js",
"resources/ext.relatedArticles.readMore/RelatedPagesGateway.js",
"tests/qunit/RelatedPagesGateway.test.js"
]
},
@ -111,7 +109,7 @@
},
"RelatedArticlesCardLimit": {
"description": "Maximum number of articles that should be shown in RelatedArticles widget. This limit is derived from limits in TextExtracts and PageImages extensions. Number should be between 1 and 20.",
"value": 3
"value": 5
},
"RelatedArticlesUseCirrusSearch": {
"value": false

View file

@ -2,22 +2,32 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// 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/ext.relatedArticles.readMore/index.js'
'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/'
'/node_modules/',
// currently covered by QUnit
'/resources/ext.relatedArticles.readMore/RelatedPagesGateway.js',
// Should not need test coverage. Covered by visual regression testing.
'/resources/ext.relatedArticles.readMore.bootstrap/index.js'
],
// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
@ -27,11 +37,22 @@ module.exports = {
statements: 100
}
},
// A set of global variables that need to be available in all test environments
globals: {
'vue-jest': {
babelConfig: false,
hideStyleWarn: true,
experimentalCSSCompile: true
}
},
// An array of file extensions your modules use
moduleFileExtensions: [
'js',
'json'
],
// The paths to modules that run some code to configure or
// set up the testing environment before each test
setupFiles: [

View file

@ -1,20 +1,6 @@
'use strict';
const wikimediaTestingUtils = require( '@wikimedia/mw-node-qunit' );
const fn = () => {};
global.CSS = {
escape: ( str ) => str
};
global.OO = {
inheritClass: ( ClassNameObject ) => {
ClassNameObject.super = fn;
ClassNameObject.prototype.on = fn;
},
initClass: fn,
EventEmitter: fn
};
const { TextEncoder, TextDecoder } = require( 'util' );
global.TextEncoder = TextEncoder;

15579
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -20,13 +20,15 @@
"@wdio/local-runner": "7.16.13",
"@wdio/mocha-framework": "7.16.13",
"@wdio/spec-reporter": "7.16.13",
"@wikimedia/codex": "0.13.0",
"@wikimedia/mw-node-qunit": "7.0.0",
"@wikimedia/types-wikimedia": "0.3.4",
"eslint-config-wikimedia": "0.25.1",
"grunt-banana-checker": "0.11.0",
"jest": "27.4.7",
"stylelint-config-wikimedia": "0.16.1",
"typescript": "4.3.5",
"ts-jest": "27.1.3",
"typescript": "4.5.5",
"wdio-mediawiki": "2.1.0"
}
}

View file

@ -1,22 +1,4 @@
( function () {
const data = require( './data.json' ),
RelatedPagesGateway = require( './RelatedPagesGateway.js' ),
relatedPages = new RelatedPagesGateway(
new mw.Api( {
ajax: {
url: data.searchUrl
}
} ),
mw.config.get( 'wgPageName' ),
mw.config.get( 'wgRelatedArticles' ),
data.useCirrusSearch,
data.onlyUseCirrusSearch,
data.descriptionSource
),
// Make sure this is never undefined as I'm paranoid
LIMIT = mw.config.get( 'wgRelatedArticlesCardLimit', 3 );
/**
* Load related articles when the user scrolls past half of the window height.
*
@ -37,17 +19,13 @@
*/
function initRelatedArticlesModule( container ) {
$.when(
mw.loader.using( 'ext.relatedArticles.readMore' ),
relatedPages.getForCurrentPage( LIMIT )
).then( function ( require, pages ) {
if ( pages.length ) {
require( 'ext.relatedArticles.readMore' ).render(
pages,
readMore
);
} else if ( container.parentNode ) {
container.parentNode.removeChild( container );
}
mw.loader.using( 'ext.relatedArticles.readMore' )
).then( function (
/** @type {Function} */ require
) {
require( 'ext.relatedArticles.readMore' ).init(
container
);
} );
}

View file

@ -1,30 +0,0 @@
'use strict';
/**
* View that renders multiple {@link mw.cards.CardView cards}
*
* @class mw.cards.CardListView
* @param {mw.cards.CardView[]} cardViews
*/
function CardListView( cardViews ) {
const self = this;
/**
* @property {mw.cards.CardView[]|Array}
*/
this.cardViews = cardViews || [];
/**
* @property {jQuery}
*/
this.$el = $( '<ul>' ).attr( { class: 'ext-related-articles-card-list' } );
// We don't want to use template partials because we want to
// preserve event handlers of each card view.
this.cardViews.forEach( function ( cardView ) {
self.$el.append( cardView.$el );
} );
}
OO.initClass( CardListView );
module.exports = CardListView;

View file

@ -1,54 +0,0 @@
'use strict';
/**
* Model for an article
* It is the single source of truth about a Card, which is a representation
* of a wiki article. It emits a 'change' event when its attribute changes.
* A View can listen to this event and update the UI accordingly.
*
* @class mw.cards.CardModel
* @extends OO.EventEmitter
* @param {Object} attributes article data, such as title, url, etc. about
* an article
*/
function CardModel( attributes ) {
CardModel.super.apply( this, arguments );
/**
* @property {Object} attributes of the model
*/
this.attributes = attributes;
}
OO.inheritClass( CardModel, OO.EventEmitter );
/**
* Set a model attribute.
* Emits a 'change' event with the object whose key is the attribute
* that's being updated and value is the value that's being set. The event
* can also be silenced.
*
* @param {string} key attribute that's being set
* @param {Mixed} value the value of the key param
* @param {boolean} [silent] whether to emit the 'change' event. By default
* the 'change' event will be emitted.
*/
CardModel.prototype.set = function ( key, value, silent ) {
const event = {};
this.attributes[ key ] = value;
if ( !silent ) {
event[ key ] = value;
this.emit( 'change', event );
}
};
/**
* Get the model attribute's value.
*
* @param {string} key attribute that's being looked up
* @return {Mixed}
*/
CardModel.prototype.get = function ( key ) {
return this.attributes[ key ];
};
module.exports = CardModel;

View file

@ -1,85 +0,0 @@
/* eslint-disable no-underscore-dangle */
'use strict';
/**
* Renders a Card model and updates when it does.
*
* @class mw.cards.CardView
* @param {mw.cards.CardModel} model
*/
function CardView( model ) {
/**
* @property {mw.cards.CardModel}
*/
this.model = model;
// listen to model changes and re-render the view
this.model.on( 'change', this.render.bind( this ) );
/**
* @property {jQuery}
*/
this.$el = $( this._render() );
}
OO.initClass( CardView );
/**
* Replace the html of this.$el with a newly rendered html using the model
* attributes.
*/
CardView.prototype.render = function () {
this.$el.replaceWith( this._render() );
};
/**
* Renders the template using the model attributes.
*
* @ignore
* @return {string}
*/
CardView.prototype._render = function () {
const $listItem = $( '<li>' ),
attributes = $.extend( {}, this.model.attributes );
attributes.thumbnailUrl = CSS.escape( attributes.thumbnailUrl );
$listItem.attr( {
title: attributes.title,
class: 'ext-related-articles-card'
} );
$listItem.append(
$( '<div>' )
.addClass( 'ext-related-articles-card-thumb' )
.addClass( attributes.hasThumbnail ?
'mw-no-invert' :
'ext-related-articles-card-thumb-placeholder'
)
.css( 'background-image', attributes.hasThumbnail ?
'url(' + attributes.thumbnailUrl + ')' :
null
),
$( '<a>' )
.attr( {
href: attributes.url,
'aria-hidden': 'true',
tabindex: -1
} ),
$( '<div>' )
.attr( { class: 'ext-related-articles-card-detail' } )
.append(
$( '<h3>' ).append(
$( '<a>' )
.attr( { href: attributes.url } )
.text( attributes.title )
),
$( '<p>' )
.attr( { class: 'ext-related-articles-card-extract' } )
.text( attributes.extract )
)
);
return $listItem;
};
module.exports = CardView;

View file

@ -0,0 +1,40 @@
/* eslint-disable indent, quotes */
// eslint-disable-next-line spaced-comment
/// <reference path="./codex.ts" />
/**
* @param {Object} options
* @param {string} options.heading
* @param {boolean} options.isContainerSmall
* @param {Codex.ListTitleObject[]} options.cards
* @return {string}
*/
const RelatedArticles = ( options ) => {
return [
`<div class="read-more-container ${( options.isContainerSmall ) ? 'read-more-container-small' : 'read-more-container-large'}">`,
`<aside class="noprint">`,
( options.heading ) ?
`<h2 class="read-more-container-heading">${options.heading}</h2>` : ``,
`<ul class="read-more-container-card-list">`,
options.cards.map( ( card ) => `<li title="${card.label}">
<a href="${card.url}"><span class="cdx-card">
<span class="cdx-card__thumbnail cdx-thumbnail">
${( card.thumbnail && card.thumbnail.url ) ?
`<span class="cdx-thumbnail__image" style="background-image: url('${card.thumbnail.url}')"></span>` :
`<span class="cdx-thumbnail__placeholder">
<span class="cdx-thumbnail__placeholder__icon"></span>
</span>`}
</span>
<span class="cdx-card__text">
<span class="cdx-card__text__title">${card.label}</span>
<span class="cdx-card__text__description">${card.description}</span>
</span>
</a>
</li>` ).join( '\n' ),
`</ul>`,
`</aside>`,
`</div>`
].join( '\n' );
};
module.exports = RelatedArticles;

View file

@ -31,6 +31,14 @@ function RelatedPagesGateway(
}
/**
* @param {JQuery.Promise<any>} jQP
* @return {Promise<any>}
*/
const toPromise = ( jQP ) => new Promise( ( resolve, reject ) => {
jQP.then( ( pages ) => resolve( pages ), ( e ) => reject( e ) );
} );
/**
* @ignore
* @param {MwApiQueryResponse} result
@ -41,7 +49,7 @@ function getPages( result ) {
}
/**
* Gets the related pages for the current page.
* Gets the related pages for the list of pages
*
* If there are related pages assigned to this page using the `related`
* parser function, then they are returned.
@ -58,21 +66,17 @@ function getPages( result ) {
* * The thumbnail, if any
* * The page description, if any
*
* @method
* @param {number} limit of pages to get. Should be between 1-20.
* @return {JQuery.Promise<MwApiPageObject[]>}
* @param {MwApiActionQuery} params for api
* @return {Promise<MwApiPageObject[]>}
*/
RelatedPagesGateway.prototype.getForCurrentPage = function ( limit ) {
const parameters = /** @type {MwApiActionQuery} */ ( {
action: 'query',
formatversion: 2,
origin: '*',
prop: 'pageimages',
piprop: 'thumbnail',
pithumbsize: 160 // FIXME: Revert to 80 once pithumbmode is implemented
} ),
// Enforce limit
relatedPages = this.editorCuratedPages.slice( 0, limit );
RelatedPagesGateway.prototype.getPagesFromApi = function ( params ) {
const parameters = /** @type {MwApiActionQuery} */ Object.assign( {
formatversion: 2,
origin: '*',
prop: 'pageimages',
piprop: 'thumbnail',
pithumbsize: 160 // FIXME: Revert to 80 once pithumbmode is implemented
}, params );
switch ( this.descriptionSource ) {
case 'wikidata':
@ -90,16 +94,45 @@ RelatedPagesGateway.prototype.getForCurrentPage = function ( limit ) {
break;
}
if ( relatedPages.length ) {
parameters.pilimit = relatedPages.length;
parameters.continue = '';
return toPromise(
this.api.get( parameters )
.then( getPages )
);
};
parameters.titles = relatedPages;
/**
* Gets the related pages for the list of pages
*
* @param {string[]} titles
* @return {Promise<MwApiPageObject[]>}
*/
RelatedPagesGateway.prototype.getPages = function ( titles ) {
return this.getPagesFromApi( {
action: 'query',
pilimit: titles.length,
continue: '',
titles
} );
};
/**
* Gets the related pages for the list of pages
*
* @param {number} limit of pages to get. Should be between 1-20.
* @return {Promise<MwApiPageObject[]>}
*/
RelatedPagesGateway.prototype.getForCurrentPage = function ( limit ) {
const relatedPages = this.editorCuratedPages.slice( 0, limit );
if ( relatedPages.length ) {
return this.getPages( relatedPages );
} else if ( this.useCirrusSearch ) {
const parameters = /** @type {MwApiActionQuery} */( {
action: 'query'
} );
parameters.pilimit = limit;
parameters.generator = 'search';
parameters.gsrsearch = 'morelike:' + this.currentPage;
parameters.gsrsearch = `morelike:${this.currentPage}`;
parameters.gsrnamespace = '0';
parameters.gsrlimit = limit;
parameters.gsrqiprofile = 'classic_noboostlinks';
@ -119,12 +152,10 @@ RelatedPagesGateway.prototype.getForCurrentPage = function ( limit ) {
// Instruct the browser that the response will become stale in 24 hours.
parameters.maxage = 86400;
return this.getPagesFromApi( parameters ).then( ( pages ) => Promise.resolve( pages ) );
} else {
return $.Deferred().resolve( [] );
return Promise.resolve( [] );
}
return this.api.get( parameters )
.then( getPages );
};
module.exports = RelatedPagesGateway;

View file

@ -0,0 +1,14 @@
declare namespace Codex {
export interface CodexSuggestionThumbnail {
width: number
height: number
url: string
}
export interface ListTitleObject {
label: string
url: string
description: string
thumbnail?: CodexSuggestionThumbnail
}
}

View file

@ -1,48 +1,85 @@
const CardModel = require( './CardModel.js' ),
CardView = require( './CardView.js' ),
CardListView = require( './CardListView.js' );
// eslint-disable-next-line spaced-comment
/// <reference path="./codex.ts" />
const RelatedArticles = require( './RelatedArticles.js' );
const data = require( './data.json' );
const RelatedPagesGateway = require( './RelatedPagesGateway.js' );
const relatedPages = new RelatedPagesGateway(
new mw.Api( {
ajax: {
url: data.searchUrl
}
} ),
mw.config.get( 'wgPageName' ),
mw.config.get( 'wgRelatedArticles' ),
data.useCirrusSearch,
data.onlyUseCirrusSearch,
data.descriptionSource
);
const LIMIT = mw.config.get( 'wgRelatedArticlesCardLimit', 3 );
/**
* Generates `mw.cards.CardView`s from pages
* Generates suggestion objects from pages
*
* @param {Object[]} pages
* @return {mw.cards.CardView[]}
* @param {MwApiPageObject[]} pages
* @return {Codex.ListTitleObject[]}
*/
function getCards( pages ) {
return pages.map( function ( page ) {
const result = {
title: page.title,
id: page.title,
label: page.title,
url: mw.util.getUrl( page.title ),
hasThumbnail: false,
extract: ( page.description || page.extract ||
thumbnail: page.thumbnail ? {
width: page.thumbnail.width,
height: page.thumbnail.height,
url: page.thumbnail.source
} : undefined,
description: ( page.description || page.extract ||
( page.pageprops ? page.pageprops.description : '' ) )
};
if ( page.thumbnail ) {
result.hasThumbnail = true;
result.thumbnailUrl = page.thumbnail.source;
result.isThumbnailPortrait = page.thumbnail.height >= page.thumbnail.width;
}
return new CardView( new CardModel( result ) );
return result;
} );
}
/**
* Renders the related articles.
*
* @param {Object[]} pages
* @param {MwApiPageObject[]} pages
* @param {Element} el
* @param {string} heading
* @param {boolean} isContainerSmall
*/
function render( pages, el ) {
const cards = new CardListView( getCards( pages ) ),
$readMore = $( '<aside>' ).addClass( 'ra-read-more noprint' )
.append( $( '<h2>' ).text( mw.msg( 'relatedarticles-read-more-heading' ) ) )
.append( cards.$el );
function render( pages, el, heading, isContainerSmall ) {
el.innerHTML = RelatedArticles( {
isContainerSmall,
heading,
cards: getCards( pages )
} );
}
$( el ).append( $readMore );
/**
* @param {HTMLElement} container to initialize into
*/
function init( container ) {
relatedPages.getForCurrentPage( LIMIT ).then( ( /** @type {MwApiPageObject[]} */ pages ) => {
if ( pages.length ) {
render(
pages,
container,
mw.msg( 'relatedarticles-read-more-heading' ),
// Take up multiple columns if possible
false
);
} else if ( container.parentNode ) {
container.parentNode.removeChild( container );
}
} );
}
module.exports = {
init,
render,
getCards
getCards,
test: {
relatedPages
}
};

View file

@ -1,11 +0,0 @@
@import 'mediawiki.skin.variables.less';
.ra-read-more h2 {
color: @color-subtle;
border-bottom: 0;
padding-bottom: 0.5em;
font-size: 0.8em;
font-weight: normal;
letter-spacing: 1px;
text-transform: uppercase;
}

View file

@ -1 +0,0 @@
/* We don't want any over-rides. */

View file

@ -5,155 +5,52 @@
@thumbWidth: 80px;
@cardBorder: 1px solid rgba( 0, 0, 0, 0.2 );
.ext-related-articles-card-list {
.flex-display();
flex-flow: row wrap;
.read-more-container-heading {
color: @color-subtle;
border-bottom: 0;
padding-bottom: 0.5em;
font-size: 0.8em;
font-weight: normal;
letter-spacing: 1px;
text-transform: uppercase;
}
.read-more-container-card-list {
font-size: @baseFontSize;
list-style: none;
overflow: hidden;
position: relative;
padding-left: 0;
display: grid;
row-gap: 10px;
h3 {
@fontSize: 1em;
@lineHeight: 1.3;
@lineHeightEm: @lineHeight * @fontSize;
font-family: inherit;
font-size: @fontSize;
// max 2 lines
max-height: 2 * @lineHeightEm;
line-height: @lineHeight;
margin: 0;
overflow: hidden;
padding: 0;
position: relative;
font-weight: 500;
a {
color: @color-emphasized;
}
&::after {
content: ' ';
position: absolute;
right: 0;
bottom: 0;
width: 25%;
height: @lineHeightEm;
background-color: transparent;
background-image: linear-gradient( to right, rgba( 255, 255, 255, 0 ), rgba( 255, 255, 255, 1 ) 50% );
}
li {
margin-bottom: 0;
}
// flex-item
.ext-related-articles-card {
background-color: @background-color-base;
box-sizing: border-box;
margin: 0;
height: @thumbWidth;
position: relative;
width: 100%;
border: @cardBorder;
& + .ext-related-articles-card {
border-top: 0;
}
// Apply radius to top & bottom cards when stacked
&:first-child {
border-radius: @border-radius-base @border-radius-base 0 0;
}
&:last-child {
border-radius: 0 0 @border-radius-base @border-radius-base;
}
a,
a:hover {
color: inherit;
text-decoration: none;
}
.ext-related-articles-card > a {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
&:hover {
a:hover {
.cdx-card {
box-shadow: 0 1px 1px rgba( 0, 0, 0, 0.1 );
}
}
.ext-related-articles-card-detail {
// Vertically center the element using the technique described at
// http://zerosixthree.se/vertical-align-anything-with-just-3-lines-of-css/.
//
// This technique is ideal because:
// * it's easy to reason about,
// * `position: relative` means that the element is laid out as if it weren't
// positioned, allowing for `text-overflow: ellipsis` to work (see below)
// * it supports more browsers than flexbox does, and
// * we don't deliver RelatedArticles assets to those browsers that don't
// support CSS 2D transforms
position: relative;
top: 50%;
transform: translateY( -50% );
}
.ext-related-articles-card-extract {
color: @color-subtle;
font-size: 0.8em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.ext-related-articles-card-thumb {
background-color: #eaecf0;
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
float: left;
height: 100%;
width: @thumbWidth;
margin-right: 10px;
}
.ext-related-articles-card-thumb-placeholder {
background-image: url( article.svg );
background-size: 40px 40px;
}
}
@media all and ( min-width: @min-width-breakpoint-tablet ) {
.ext-related-articles-card-list {
border-top: 0;
@supports ( display: grid ) {
@media all and ( min-width: @width-breakpoint-desktop ) {
.read-more-container-card-list {
grid-template-columns: repeat( 3, 1fr );
column-gap: 10px;
.ext-related-articles-card {
border: @cardBorder;
@rightMargin: 1%;
margin-right: @rightMargin;
margin-bottom: 10px;
// max space is 100-2/3
width: ( 100 - ( 2 * @rightMargin ) ) / 3;
// Individual border-radius when cards side by side (not stacked)
&,
&:first-child,
&:last-child {
border-radius: @border-radius-base;
.read-more-container-card {
width: 100%;
}
&:last-child {
margin-right: 0;
}
& + .ext-related-articles-card {
border: @cardBorder;
}
// every 3rd child drop the right margin
&:nth-child( 3n+3 ) {
margin-right: 0;
.cdx-card {
height: 100%;
box-sizing: border-box;
}
}
}

View file

@ -3,17 +3,12 @@
// Reserve space for the related articles cards.
// https://phabricator.wikimedia.org/T223844
// Note this is optimized for 3 related articles which is the majority of cases.
// There will be a jump when:
// - a page has no related articles,
// - a page has more than 3 related articles
// and there will be visible whitespace on mobile for a page with less than 3.
.client-js {
.read-more-container {
// Assumes 3 cards at 78px.
min-height: 274px;
min-height: 320px;
@media ( min-width: @min-width-breakpoint-desktop ) {
min-height: 124px;
min-height: 163px;
}
}
}

5
resources/vue.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
declare module "*.vue" {
import type { DefineComponent } from 'vue'
const component: DefineComponent<any, any, any>
export default component
}

View file

@ -1,12 +1,13 @@
.ra-read-more {
.client-js .read-more-container {
padding: 1em 0 0;
box-sizing: border-box;
// Hide RelatedArticles when VE is activated, see T120443
.ve-activated & {
display: none;
}
.ext-related-articles-card-list {
&-card-list {
margin-left: 0;
}
}

View file

@ -0,0 +1,22 @@
let RelatedArticles;
describe( 'RelatedArticles', () => {
beforeEach( () => {
RelatedArticles = require( '../../resources/ext.relatedArticles.readMore/RelatedArticles.js' );
} );
it( 'renders with cards', () => {
const html = RelatedArticles( {
cards: [
{
id: '4',
label: 'Title',
value: 'Title',
description: 'Description'
}
]
} );
expect(
html
).toContain( 'class="cdx-card"' );
} );
} );

View file

@ -1,3 +1,76 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ext.relatedArticles.readMore.bootstrap renders without error 1`] = `"<aside class=\\"ra-read-more noprint\\"><h2>relatedarticles-read-more-heading</h2><ul class=\\"ext-related-articles-card-list\\"><li title=\\"Hello no description\\" class=\\"ext-related-articles-card\\"><div class=\\"ext-related-articles-card-thumb ext-related-articles-card-thumb-placeholder\\"></div><a href=\\"Hello no description\\" aria-hidden=\\"true\\" tabindex=\\"-1\\"></a><div class=\\"ext-related-articles-card-detail\\"><h3><a href=\\"Hello no description\\">Hello no description</a></h3><p class=\\"ext-related-articles-card-extract\\"></p></div></li><li title=\\"Hello\\" class=\\"ext-related-articles-card\\"><div class=\\"ext-related-articles-card-thumb mw-no-invert\\" style=\\"background-image: url(hello.gif);\\"></div><a href=\\"Hello\\" aria-hidden=\\"true\\" tabindex=\\"-1\\"></a><div class=\\"ext-related-articles-card-detail\\"><h3><a href=\\"Hello\\">Hello</a></h3><p class=\\"ext-related-articles-card-extract\\">Description</p></div></li><li title=\\"Hello with extract\\" class=\\"ext-related-articles-card\\"><div class=\\"ext-related-articles-card-thumb mw-no-invert\\" style=\\"background-image: url(helloEx.gif);\\"></div><a href=\\"Hello with extract\\" aria-hidden=\\"true\\" tabindex=\\"-1\\"></a><div class=\\"ext-related-articles-card-detail\\"><h3><a href=\\"Hello with extract\\">Hello with extract</a></h3><p class=\\"ext-related-articles-card-extract\\">Extract</p></div></li><li title=\\"Hello with pageprops\\" class=\\"ext-related-articles-card\\"><div class=\\"ext-related-articles-card-thumb ext-related-articles-card-thumb-placeholder\\"></div><a href=\\"Hello with pageprops\\" aria-hidden=\\"true\\" tabindex=\\"-1\\"></a><div class=\\"ext-related-articles-card-detail\\"><h3><a href=\\"Hello with pageprops\\">Hello with pageprops</a></h3><p class=\\"ext-related-articles-card-extract\\">Page props desc</p></div></li></ul></aside>"`;
exports[`ext.relatedArticles.readMore.bootstrap init with pages 1`] = `""`;
exports[`ext.relatedArticles.readMore.bootstrap init with zero pages and parent container 1`] = `"<div></div>"`;
exports[`ext.relatedArticles.readMore.bootstrap init with zero pages without parent container 1`] = `""`;
exports[`ext.relatedArticles.readMore.bootstrap renders with small container and custom heading 1`] = `
"<div class=\\"read-more-container read-more-container-small\\">
<aside class=\\"noprint\\">
<h2 class=\\"read-more-container-heading\\">Hello world</h2>
<ul class=\\"read-more-container-card-list\\">
</ul>
</aside>
</div>"
`;
exports[`ext.relatedArticles.readMore.bootstrap renders without error 1`] = `
"<div class=\\"read-more-container read-more-container-small\\">
<aside class=\\"noprint\\">
<ul class=\\"read-more-container-card-list\\">
<li title=\\"Hello no description\\">
<a href=\\"/wiki/Hello no description\\"><span class=\\"cdx-card\\">
<span class=\\"cdx-card__thumbnail cdx-thumbnail\\">
<span class=\\"cdx-thumbnail__placeholder\\">
<span class=\\"cdx-thumbnail__placeholder__icon\\"></span>
</span>
</span>
<span class=\\"cdx-card__text\\">
<span class=\\"cdx-card__text__title\\">Hello no description</span>
<span class=\\"cdx-card__text__description\\"></span>
</span>
</span></a>
</li>
<li title=\\"Hello\\">
<a href=\\"/wiki/Hello\\"><span class=\\"cdx-card\\">
<span class=\\"cdx-card__thumbnail cdx-thumbnail\\">
<span class=\\"cdx-thumbnail__image\\" style=\\"background-image: url('hello.gif')\\"></span>
</span>
<span class=\\"cdx-card__text\\">
<span class=\\"cdx-card__text__title\\">Hello</span>
<span class=\\"cdx-card__text__description\\">Description</span>
</span>
</span></a>
</li>
<li title=\\"Hello with extract\\">
<a href=\\"/wiki/Hello with extract\\"><span class=\\"cdx-card\\">
<span class=\\"cdx-card__thumbnail cdx-thumbnail\\">
<span class=\\"cdx-thumbnail__image\\" style=\\"background-image: url('helloEx.gif')\\"></span>
</span>
<span class=\\"cdx-card__text\\">
<span class=\\"cdx-card__text__title\\">Hello with extract</span>
<span class=\\"cdx-card__text__description\\">Extract</span>
</span>
</span></a>
</li>
<li title=\\"Hello with pageprops\\">
<a href=\\"/wiki/Hello with pageprops\\"><span class=\\"cdx-card\\">
<span class=\\"cdx-card__thumbnail cdx-thumbnail\\">
<span class=\\"cdx-thumbnail__placeholder\\">
<span class=\\"cdx-thumbnail__placeholder__icon\\"></span>
</span>
</span>
<span class=\\"cdx-card__text\\">
<span class=\\"cdx-card__text__title\\">Hello with pageprops</span>
<span class=\\"cdx-card__text__description\\">Page props desc</span>
</span>
</span></a>
</li>
</ul>
</aside>
</div>"
`;

View file

@ -1,22 +1,69 @@
const { render } = require( '../../resources/ext.relatedArticles.readMore/index.js' );
const { render, init, getCards, test } = require( '../../resources/ext.relatedArticles.readMore/index.js' );
const { createApp } = require( 'vue' );
const PAGE = {
title: 'Hello no description'
};
const PAGE_WITH_DESCRIPTION = {
title: 'Hello',
description: 'Description',
thumbnail: {
source: 'hello.gif',
width: 200,
height: 200
}
};
describe( 'ext.relatedArticles.readMore.bootstrap', () => {
beforeEach( () => {
mw.util.getUrl = jest.fn( ( title ) => `/wiki/${title}` );
} );
it( 'init with zero pages and parent container', () => {
test.relatedPages.getForCurrentPage = jest.fn( () => Promise.resolve( [] ) );
const parent = document.createElement( 'div' );
const element = document.createElement( 'div' );
parent.appendChild( element );
init( element );
expect( parent.innerHTML ).toMatchSnapshot();
} );
it( 'init with zero pages without parent container', () => {
test.relatedPages.getForCurrentPage = jest.fn( () => Promise.resolve( [] ) );
const element = document.createElement( 'div' );
init( element );
expect( element.innerHTML ).toMatchSnapshot();
} );
it( 'init with pages', () => {
test.relatedPages.getForCurrentPage = jest.fn( () => Promise.resolve( [
PAGE_WITH_DESCRIPTION
] ) );
const element = document.createElement( 'div' );
init( element );
expect( element.innerHTML ).toMatchSnapshot();
} );
it( 'renders with small container and custom heading', () => {
const element = document.createElement( 'div' );
render( [], element, 'Hello world', function ( options ) {
const app = createApp( options );
return app;
}, true );
expect( element.innerHTML ).toMatchSnapshot();
} );
it( 'renders without error', () => {
const element = document.createElement( 'div' );
const plugin = {
install: function ( app ) {
app.config.globalProperties.$i18n = () => ( {
text: ( key ) => `<${key}>`
} );
}
};
render( [
{
title: 'Hello no description'
},
{
title: 'Hello',
description: 'Description',
thumbnail: {
source: 'hello.gif',
width: 200,
height: 200
}
},
PAGE,
PAGE_WITH_DESCRIPTION,
{
title: 'Hello with extract',
extract: 'Extract',
@ -32,7 +79,76 @@ describe( 'ext.relatedArticles.readMore.bootstrap', () => {
description: 'Page props desc'
}
}
], element );
], element, '', function ( options ) {
const app = createApp( options );
app.use( plugin );
return app;
}, false );
expect( element.innerHTML ).toMatchSnapshot();
} );
const DEFAULT_CARD = {
title: 'Title',
thumbnail: {
source: 'puppy.gif',
width: 100,
height: 100
}
};
it( 'maps cards', () => {
[
[
Object.assign( {}, DEFAULT_CARD, {
description: 'Description'
} )
],
[
Object.assign( {}, DEFAULT_CARD, {
extract: 'Description'
} )
],
[
Object.assign( {}, DEFAULT_CARD, {
pageprops: {
description: 'Description'
}
} )
]
].forEach( ( testCase ) => {
const cards = getCards( testCase );
expect( cards ).toEqual( [
{
id: 'Title',
label: 'Title',
url: '/wiki/Title',
thumbnail: {
width: 100,
height: 100,
url: 'puppy.gif'
},
description: 'Description'
}
] );
} );
} );
it( 'maps cards with missing fields', () => {
const cards = getCards(
[
{
title: 'Title'
}
]
);
expect( cards ).toEqual( [
{
id: 'Title',
label: 'Title',
url: '/wiki/Title',
description: '',
thumbnail: undefined
}
] );
} );
} );

View file

@ -1,35 +0,0 @@
( function () {
'use strict';
const CardModel = require( '../../resources/ext.relatedArticles.readMore/CardModel.js' );
QUnit.module( 'ext.relatedArticles.cards/CardModel' );
QUnit.test( '#set', function ( assert ) {
let model = new CardModel( {} );
model.on( 'change', function ( attributes ) {
assert.strictEqual(
attributes.foo,
'bar',
'It emits an event with the attribute that has changed.'
);
} );
model.set( 'foo', 'bar' );
model = new CardModel( {} );
model.on( 'change', function () {
assert.true( false, 'It doesn\'t emit an event when silenced.' );
} );
model.set( 'foo', 'bar', true );
} );
QUnit.test( '#get', function ( assert ) {
const model = new CardModel( {} );
model.set( 'foo', 'bar' );
assert.strictEqual( model.get( 'foo' ), 'bar', 'Got the correct value.' );
assert.strictEqual( model.get( 'x' ), undefined, 'Got the correct value.' );
} );
}() );

View file

@ -1,28 +0,0 @@
( function () {
'use strict';
const CardModel = require( '../../resources/ext.relatedArticles.readMore/CardModel.js' ),
CardView = require( '../../resources/ext.relatedArticles.readMore/CardView.js' );
QUnit.module( 'ext.relatedArticles.cards/CardView' );
QUnit.test( '#_render escapes the thumbnailUrl model attribute', function ( assert ) {
const model = new CardModel( {
title: 'One',
url: mw.util.getUrl( 'One' ),
hasThumbnail: true,
thumbnailUrl: 'http://foo.bar/\');display:none;"//baz.jpg',
isThumbnailProtrait: false
} ),
view = new CardView( model );
const style = view.$el.find( '.ext-related-articles-card-thumb' )
.eq( 0 )
.attr( 'style' );
assert.strictEqual(
style,
"background-image: url(\"http://foo.bar/');display:none;\\\"//baz.jpg\");"
);
} );
}() );

View file

@ -1,5 +1,5 @@
( function () {
const RelatedPagesGateway = require( '../../resources/ext.relatedArticles.readMore.bootstrap/RelatedPagesGateway.js' ),
const RelatedPagesGateway = require( '../../resources/ext.relatedArticles.readMore/RelatedPagesGateway.js' ),
lotsaRelatedPages = [ 'A', 'B', 'C', 'D', 'E', 'F' ],
relatedPages = {
query: {

View file

@ -1,4 +1,2 @@
// Run tests
require( './CardModel.test.js' );
require( './CardView.test.js' );
require( './RelatedPagesGateway.test.js' );

View file

@ -1,7 +1,7 @@
'use strict';
const CARD_SELECTOR = '.ext-related-articles-card',
Page = require( 'wdio-mediawiki/Page' ),
READ_MORE_MODULE_NAME = 'ext.relatedArticles.readMore';
READ_MORE_MODULE_NAME = 'ext.relatedArticles.readMore.bootstrap';
class ReadMorePage extends Page {

View file

@ -1,7 +1,6 @@
{
"include": [
"resources/ext.relatedArticles.readMore.bootstrap/index.js",
"resources/ext.relatedArticles.readMore.bootstrap/RelatedPagesGateway.js"
"resources/**/*"
],
"compilerOptions": {
"resolveJsonModule": true,