/* eslint complexity: ["error", 100] */
// current complexity is the only way to achieve desired results, pull request solutions appreciated.
/* global _mainwpThemeSettings, confirm */
window.wp = window.wp || {};
(function ($) {
// Set up our namespace...
let themes, l10n;
themes = wp.themes = wp.themes || {};
// Store the theme data and settings for organized and quick access
// themes.data.settings, themes.data.themes, themes.data.l10n
themes.data = _mainwpThemeSettings;
l10n = themes.data.l10n;
// Shortcut for isInstall check
themes.isInstall = !!themes.data.settings.isInstall;
// Setup app structure
_.extend(themes, { model: {}, view: {}, routes: {}, router: {}, template: wp.template });
themes.Model = Backbone.Model.extend({
// Adds attributes to the default data coming through the .org themes api
// Map `id` to `slug` for shared code
initialize: function () {
let description;
// If theme is already installed, set an attribute.
if (_.indexOf(themes.data.installedThemes, this.get('slug')) !== -1) {
this.set({ installed: true });
}
// Set the attributes
this.set({
// slug is for installation, id is for existing.
id: this.get('slug') || this.get('id')
});
// Map `section.description` to `description`
// as the API sometimes returns it differently
if (this.has('sections')) {
description = this.get('sections').description;
this.set({ description: description });
}
}
});
// Main view controller for themes.php
// Unifies and renders all available views
themes.view.Appearance = wp.Backbone.View.extend({
el: '#wpbody-content .mainwp-browse-themes',
window: $(window),
// Pagination instance
page: 0,
// Sets up a throttler for binding to 'scroll'
initialize: function (options) {
// Scroller checks how far the scroll position is
_.bindAll(this, 'scroller');
this.SearchView = options.SearchView ? options.SearchView : themes.view.Search;
// Bind to the scroll event and throttle
// the results from this.scroller
this.window.on('scroll', _.throttle(this.scroller, 300));
},
// Main render control
render: function () {
// Setup the main theme view
// with the current theme collection
this.view = new themes.view.Themes({
collection: this.collection,
parent: this
});
// Render search form.
this.search();
// Render and append
this.view.render();
this.$el.empty().append(this.view.el).addClass('rendered');
this.$el.append('
');
},
// Defines search element container
searchContainer: $('#wpbody'), // init container
// Search input and view
// for current theme collection
search: function () {
let view,
self = this;
// Don't render the search if there is only one theme
if (themes.data.themes.length === 1) {
return;
}
view = new this.SearchView({
collection: self.collection,
parent: this
});
// Render and append after screen title
view.render();
this.searchContainer
.append(view.el)
.append('');
},
// Checks when the user gets close to the bottom
// of the mage and triggers a theme:scroll event
scroller: function () {
let self = this,
bottom, threshold;
bottom = this.window.scrollTop() + self.window.height();
threshold = self.$el.offset().top + self.$el.outerHeight(false) - self.window.height();
threshold = Math.round(threshold * 0.9);
if (bottom > threshold) {
this.trigger('theme:scroll');
}
}
});
// Set up the Collection for our theme data
// @has 'id' 'name' 'screenshot' 'author' 'authorURI' 'version' 'active' ...
themes.Collection = Backbone.Collection.extend({
model: themes.Model,
// Search terms
terms: '',
// Controls searching on the current theme collection
// and triggers an update event
doSearch: function (value) {
// Don't do anything if we've already done this search
// Useful because the Search handler fires multiple times per keystroke
if (this.terms === value) {
return;
}
// Updates terms with the value passed
this.terms = value;
// If we have terms, run a search...
if (this.terms.length > 0) {
this.search(this.terms);
}
// If search is blank, show all themes
// Useful for resetting the views when you clean the input
if (this.terms === '') {
this.reset(themes.data.themes);
$('body').removeClass('no-results');
}
// Trigger an 'update' event
this.trigger('update');
},
// Performs a search within the collection
// @uses RegExp
search: function (term) {
let match, results, haystack, name, description, author;
// Start with a full collection
this.reset(themes.data.themes, { silent: true });
// Escape the term string for RegExp meta characters
term = term.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
// Consider spaces as word delimiters and match the whole string
// so matching terms can be combined
term = term.replace(/ /g, ')(?=.*');
match = new RegExp('^(?=.*' + term + ').+', 'i');
// Find results
// _.filter and .test
results = this.filter(function (data) {
name = data.get('name').replace(/(<([^>]+)>)/ig, ''); // NOSONAR - safe pattern from WP.
description = data.get('description').replace(/(<([^>]+)>)/ig, ''); // NOSONAR - safe pattern from WP.
author = data.get('author').replace(/(<([^>]+)>)/ig, ''); // NOSONAR - safe pattern from WP.
haystack = _.union(name, data.get('id'), description, author, data.get('tags'));
if (match.test(data.get('author')) && term.length > 2) {
data.set('displayAuthor', true);
}
return match.test(haystack);
});
if (results.length === 0) {
this.trigger('query:empty');
} else {
$('body').removeClass('no-results');
}
this.reset(results);
},
// Paginates the collection with a helper method
// that slices the collection
paginate: function (instance) {
let collection = this;
instance = instance || 0;
// Themes per instance are set at 20
collection = _(collection.rest(20 * instance));
collection = _(collection.first(20));
return collection;
},
count: false,
// Handles requests for more themes
// and caches results
//
// When we are missing a cache object we fire an apiCall()
// which triggers events of `query:success` or `query:fail`
query: function (request) {
/**
* @static
* @type Array
*/
let queries = this.queries,
self = this,
query, isPaginated, count;
// Store current query request args
// for later use with the event `theme:end`
this.currentQuery.request = request;
// Search the query cache for matches.
query = _.find(queries, function (query) {
return _.isEqual(query.request, request);
});
// If the request matches the stored currentQuery.request
// it means we have a paginated request.
isPaginated = _.has(request, 'page');
// Reset the internal api page counter for non paginated queries.
if (!isPaginated) {
this.currentQuery.page = 1;
}
// Otherwise, send a new API call and add it to the cache.
if (!query && !isPaginated) {
this.apiCall(request).done(function (data) {
// Update the collection with the queried data.
if (data.themes) {
self.reset(data.themes);
count = data.info.results;
// Store the results and the query request
queries.push({ themes: data.themes, request: request, total: count });
}
// Trigger a collection refresh event
// and a `query:success` event with a `count` argument.
self.trigger('update');
self.trigger('query:success', count);
if (data.themes && data.themes.length === 0) {
self.trigger('query:empty');
}
}).fail(function () {
self.trigger('query:fail');
});
} else {
// If it's a paginated request we need to fetch more themes...
if (isPaginated) {
return this.apiCall(request, isPaginated).done(function (data) {
// Add the new themes to the current collection
// @devtodo update counter
self.add(data.themes);
self.trigger('query:success');
// We are done loading themes for now.
self.loadingThemes = false;
}).fail(function () {
self.trigger('query:fail');
});
}
if (query.themes.length === 0) {
self.trigger('query:empty');
} else {
$('body').removeClass('no-results');
}
// Only trigger an update event since we already have the themes
// on our cached object
if (_.isNumber(query.total)) {
this.count = query.total;
}
this.reset(query.themes);
if (!query.total) {
this.count = this.length;
}
this.trigger('update');
this.trigger('query:success', this.count);
}
},
// Local cache array for API queries
queries: [],
// Keep track of current query so we can handle pagination
currentQuery: {
page: 1,
request: {}
},
// Send request to api.wordpress.org/themes
apiCall: function (request, paginated) {
return wp.ajax.send('query-themes', {
data: {
// Request data
request: _.extend({
per_page: 100,
fields: {
description: true,
tested: true,
requires: true,
rating: true,
downloaded: true,
downloadLink: true,
last_updated: true,
homepage: true,
num_ratings: true
}
}, request)
},
beforeSend: function () {
if (!paginated) {
// Spin it
$('body').addClass('loading-content').removeClass('no-results');
}
}
}).done(function (response) {
if (response?.themes) {
let favThemes = jQuery('#mainwp-favorites-themes').attr('favorites-themes');
if ('' !== favThemes) {
try {
let decoded_favThemes = JSON.parse(favThemes);
response.themes.forEach(function (part, index) {
if (decoded_favThemes.hasOwnProperty(this[index].slug)) {
this[index].added_fav = 1;
} else {
this[index].added_fav = 0;
}
}, response.themes); // use second param as this.
} catch (e) {
console.log('Invalid favorites themes data.');
}
}
}
});
},
// Static status controller for when we are loading themes.
loadingThemes: false
});
// This is the view that controls each theme item
// that will be displayed on the screen
themes.view.Theme = wp.Backbone.View.extend({
// Wrap theme data on a div.theme element
className: 'theme card',
// Reflects which theme view we have
// 'grid' (default) or 'detail'
state: 'grid',
// The HTML template for each element to be rendered
html: themes.template('theme'),
events: {
'click': themes.isInstall ? 'preview' : 'expand',
'keydown': themes.isInstall ? 'preview' : 'expand',
'touchend': themes.isInstall ? 'preview' : 'expand',
'keyup': 'addFocus',
'touchmove': 'preventExpand'
},
touchDrag: false,
render: function () {
let data = this.model.toJSON();
// Render themes using the html template
this.$el.html(this.html(data)).attr({
tabindex: 0,
'aria-describedby': data.id + '-action ' + data.id + '-name'
});
// Renders active theme styles
this.activeTheme();
if (this.model.get('displayAuthor')) {
this.$el.addClass('display-author');
}
if (this.model.get('installed')) {
this.$el.addClass('is-installed');
}
},
// Adds a class to the currently active theme
// and to the overlay in detailed view mode
activeTheme: function () {
if (this.model.get('active')) {
this.$el.addClass('active');
}
},
// Add class of focus to the theme we are focused on.
addFocus: function () {
let $themeToFocus = ($(':focus').hasClass('theme')) ? $(':focus') : $(':focus').parents('.theme');
$('.theme.focus').removeClass('focus');
$themeToFocus.addClass('focus');
},
// Single theme overlay screen
// It's shown when clicking a theme
expand: function (event) {
let self = this;
event = event || window.event; // NOSONAR - compatible.
// 'enter' and 'space' keys expand the details view when a theme is :focused
if (event.type === 'keydown' && (event.which !== 13 && event.which !== 32)) {
return;
}
// Bail if the user scrolled on a touch device
if (this.touchDrag === true) {
this.touchDrag = false;
return this.touchDrag;
}
// Prevent the modal from showing when the user clicks
// one of the direct action buttons
if ($(event.target).is('.theme-actions a')) {
return;
}
// Set focused theme to current element
themes.focusedTheme = this.$el;
this.trigger('theme:expand', self.model.cid);
},
preventExpand: function () {
this.touchDrag = true;
},
preview: function (event) {
let self = this,
current, preview;
// Bail if the user scrolled on a touch device
if (this.touchDrag === true) {
this.touchDrag = false;
return this.touchDrag;
}
// Allow direct link path to installing a theme.
if ($(event.target).hasClass('button-primary')) {
return;
}
if ($(event.target).is('.mainwp-theme-lnks input[type="radio"]')) {
return;
}
if (!$(event.target).is('.mainwp-theme-preview')) {
return;
}
// 'enter' and 'space' keys expand the details view when a theme is :focused
if (event.type === 'keydown' && (event.which !== 13 && event.which !== 32)) {
return;
}
// pressing enter while focused on the buttons shouldn't open the preview
if (event.type === 'keydown' && event.which !== 13 && $(':focus').hasClass('button')) {
return;
}
event.preventDefault();
event = event || window.event; // NOSONAR - compatible.
// Set focus to current theme.
themes.focusedTheme = this.$el;
// Construct a new Preview view.
preview = new themes.view.Preview({
model: this.model
});
// Render the view and append it.
preview.render();
this.setNavButtonsState();
// Hide previous/next navigation if there is only one theme
if (this.model.collection.length === 1) {
preview.$el.addClass('no-navigation');
} else {
preview.$el.removeClass('no-navigation');
}
// Append preview
$('div.mainwp-content-wrap').append(preview.el);
// Listen to our preview object
// for `theme:next` and `theme:previous` events.
this.listenTo(preview, 'theme:next', function () {
// Keep local track of current theme model.
current = self.model;
// If we have ventured away from current model update the current model position.
if (!_.isUndefined(self.current)) {
current = self.current;
}
// Get next theme model.
self.current = self.model.collection.at(self.model.collection.indexOf(current) + 1);
// If we have no more themes, bail.
if (_.isUndefined(self.current)) {
self.options.parent.parent.trigger('theme:end');
self.current = current;
return self.current;
}
preview.model = self.current;
// Render and append.
preview.render();
this.setNavButtonsState();
$('.next-theme').trigger('focus');
})
.listenTo(preview, 'theme:previous', function () {
// Keep track of current theme model.
current = self.model;
// Bail early if we are at the beginning of the collection
if (self.model.collection.indexOf(self.current) === 0) {
return;
}
// If we have ventured away from current model update the current model position.
if (!_.isUndefined(self.current)) {
current = self.current;
}
// Get previous theme model.
self.current = self.model.collection.at(self.model.collection.indexOf(current) - 1);
// If we have no more themes, bail.
if (_.isUndefined(self.current)) {
return;
}
preview.model = self.current;
// Render and append.
preview.render();
this.setNavButtonsState();
$('.previous-theme').trigger('focus');
});
this.listenTo(preview, 'preview:close', function () {
self.current = self.model;
});
},
// Handles .disabled classes for previous/next buttons in theme installer preview
setNavButtonsState: function () {
let $themeInstaller = $('.theme-install-overlay'),
current = _.isUndefined(this.current) ? this.model : this.current;
// Disable previous at the zero position
if (0 === this.model.collection.indexOf(current)) {
$themeInstaller.find('.previous-theme').addClass('disabled');
}
// Disable next if the next model is undefined
if (_.isUndefined(this.model.collection.at(this.model.collection.indexOf(current) + 1))) {
$themeInstaller.find('.next-theme').addClass('disabled');
}
}
});
// Theme Details view
// Set ups a modal overlay with the expanded theme data
themes.view.Details = wp.Backbone.View.extend({
// Wrap theme data on a div.theme element
className: 'theme-overlay',
events: {
'click': 'collapse',
'click .delete-theme': 'deleteTheme',
'click .left': 'previousTheme',
'click .right': 'nextTheme'
},
// The HTML template for the theme overlay
html: themes.template('theme-single'),
render: function () {
let data = this.model.toJSON();
this.$el.html(this.html(data));
// Renders active theme styles
this.activeTheme();
// Set up navigation events
this.navigation();
// Checks screenshot size
this.screenshotCheck(this.$el);
// Contain "tabbing" inside the overlay
this.containFocus(this.$el);
},
// Adds a class to the currently active theme
// and to the overlay in detailed view mode
activeTheme: function () {
// Check the model has the active property
this.$el.toggleClass('active', this.model.get('active'));
},
// Keeps :focus within the theme details elements
containFocus: function ($el) {
let $target;
// Move focus to the primary action
_.delay(function () {
$('.theme-wrap a.button-primary:visible').trigger('focus');
}, 500);
$el.on('keydown.wp-themes', function (event) {
// Tab key
if (event.which === 9) {
$target = $(event.target);
// Keep focus within the overlay by making the last link on theme actions
// switch focus to button.left on tabbing and vice versa
if ($target.is('button.left') && event.shiftKey) {
$el.find('.theme-actions a:last-child').trigger('focus');
event.preventDefault();
} else if ($target.is('.theme-actions a:last-child')) {
$el.find('button.left').trigger('focus');
event.preventDefault();
}
}
});
},
// Single theme overlay screen
// It's shown when clicking a theme
collapse: function (event) {
let self = this,
scroll;
event = event || window.event; // NOSONAR - compatible.
// Prevent collapsing detailed view when there is only one theme available
if (themes.data.themes.length === 1) {
return;
}
// Detect if the click is inside the overlay
// and don't close it unless the target was
// the div.back button
if ($(event.target).is('.theme-backdrop') || $(event.target).is('.close') || event.keyCode === 27) {
// Add a temporary closing class while overlay fades out
$('body').addClass('closing-overlay');
// With a quick fade out animation
this.$el.fadeOut(130, function () {
// Clicking outside the modal box closes the overlay
$('body').removeClass('closing-overlay');
// Handle event cleanup
self.closeOverlay();
// Get scroll position to avoid jumping to the top
scroll = document.body.scrollTop;
// Clean the url structure
themes.router.navigate(themes.router.baseUrl(''));
// Restore scroll position
document.body.scrollTop = scroll;
// Return focus to the theme div
if (themes.focusedTheme) {
themes.focusedTheme.trigger('focus');
}
});
}
},
// Handles .disabled classes for next/previous buttons
navigation: function () {
// Disable Left/Right when at the start or end of the collection
if (this.model.cid === this.model.collection.at(0).cid) {
this.$el.find('.left').addClass('disabled');
}
if (this.model.cid === this.model.collection.at(this.model.collection.length - 1).cid) {
this.$el.find('.right').addClass('disabled');
}
},
// Performs the actions to effectively close
// the theme details overlay
closeOverlay: function () {
$('body').removeClass('modal-open');
this.remove();
this.unbind();
this.trigger('theme:collapse');
},
// Confirmation dialog for deleting a theme
deleteTheme: function () {
return confirm(themes.data.settings.confirmDelete);
},
nextTheme: function () {
let self = this;
self.trigger('theme:next', self.model.cid);
return false;
},
previousTheme: function () {
let self = this;
self.trigger('theme:previous', self.model.cid);
return false;
},
// Checks if the theme screenshot is the old 300px width version
// and adds a corresponding class if it's true
screenshotCheck: function (el) {
let screenshot, image;
screenshot = el.find('.screenshot img');
image = new Image();
image.src = screenshot.attr('src');
// Width check
if (image.width && image.width <= 300) {
el.addClass('small-screenshot');
}
}
});
// Theme Preview view
// Set ups a modal overlay with the expanded theme data
themes.view.Preview = themes.view.Details.extend({
className: 'wp-full-overlay expanded',
el: '.theme-install-overlay',
events: {
'click .close-full-overlay': 'close',
'click .collapse-sidebar': 'collapse',
'click .previous-theme': 'previousTheme',
'click .next-theme': 'nextTheme',
'keyup': 'keyEvent'
},
// The HTML template for the theme preview
html: themes.template('theme-preview'),
render: function () {
let data = this.model.toJSON();
this.$el.html(this.html(data));
themes.router.navigate(themes.router.baseUrl(themes.router.themePath + this.model.get('id')), { replace: true });
this.$el.fadeIn(200, function () {
$('body').addClass('theme-installer-active full-overlay-active');
$('.close-full-overlay').trigger('focus');
});
},
close: function () {
this.$el.fadeOut(200, function () {
$('body').removeClass('theme-installer-active full-overlay-active');
// Return focus to the theme div
if (themes.focusedTheme) {
themes.focusedTheme.trigger('focus');
}
});
themes.router.navigate(themes.router.baseUrl(''));
this.trigger('preview:close');
this.undelegateEvents();
this.unbind();
return false;
},
collapse: function (event) {
let $button = $(event.currentTarget);
if ('true' === $button.attr('aria-expanded')) {
$button.attr({ 'aria-expanded': 'false', 'aria-label': l10n.expandSidebar });
} else {
$button.attr({ 'aria-expanded': 'true', 'aria-label': l10n.collapseSidebar });
}
this.$el.toggleClass('collapsed').toggleClass('expanded');
return false;
},
keyEvent: function (event) {
// The escape key closes the preview
if (event.keyCode === 27) {
this.undelegateEvents();
this.close();
}
// The right arrow key, next theme
if (event.keyCode === 39) {
_.once(this.nextTheme());
}
// The left arrow key, previous theme
if (event.keyCode === 37) {
this.previousTheme();
}
}
});
// Controls the rendering of div.themes,
// a wrapper that will hold all the theme elements
themes.view.Themes = wp.Backbone.View.extend({
className: 'themes ui four cards',
$overlay: $('div.theme-overlay'),
// Number to keep track of scroll position
// while in theme-overlay mode
index: 0,
// The theme count element
count: $('.wp-core-ui .theme-count'),
// The live themes count
liveThemeCount: 0,
initialize: function (options) {
let self = this;
// Set up parent
this.parent = options.parent;
// Set current view to [grid]
this.setView('grid');
// Move the active theme to the beginning of the collection
self.currentTheme();
// When the collection is updated by user input...
this.listenTo(self.collection, 'update', function () {
self.parent.page = 0;
self.currentTheme();
self.render(this);
});
// Update theme count to full result set when available.
this.listenTo(self.collection, 'query:success', function (count) {
if (_.isNumber(count)) {
self.count.text(count);
self.announceSearchResults(count);
} else {
self.count.text(self.collection.length);
self.announceSearchResults(self.collection.length);
}
});
this.listenTo(self.collection, 'query:empty', function () {
$('body').addClass('no-results');
});
this.listenTo(this.parent, 'theme:scroll', function () {
self.renderThemes(self.parent.page);
});
this.listenTo(this.parent, 'theme:close', function () {
if (self.overlay) {
self.overlay.closeOverlay();
}
});
// Bind keyboard events.
$('body').on('keyup', function (event) {
if (!self.overlay) {
return;
}
// Pressing the right arrow key fires a theme:next event
if (event.keyCode === 39) {
self.overlay.nextTheme();
}
// Pressing the left arrow key fires a theme:previous event
if (event.keyCode === 37) {
self.overlay.previousTheme();
}
// Pressing the escape key fires a theme:collapse event
if (event.keyCode === 27) {
self.overlay.collapse(event);
}
});
},
// Manages rendering of theme pages
// and keeping theme count in sync
render: function () {
// Clear the DOM, please
this.$el.empty();
// If the user doesn't have switch capabilities
// or there is only one theme in the collection
// render the detailed view of the active theme
if (themes.data.themes.length === 1) {
// Constructs the view
this.singleTheme = new themes.view.Details({
model: this.collection.models[0]
});
// Render and apply a 'single-theme' class to our container
this.singleTheme.render();
this.$el.addClass('single-theme');
this.$el.append(this.singleTheme.el);
}
// Generate the themes
// Using page instance
// While checking the collection has items
if (this.options.collection.length > 0) {
this.renderThemes(this.parent.page);
}
// Display a live theme count for the collection
this.liveThemeCount = this.collection.count ? this.collection.count : this.collection.length;
this.count.text(this.liveThemeCount);
this.announceSearchResults(this.liveThemeCount);
},
// Iterates through each instance of the collection
// and renders each theme module
renderThemes: function (page) {
let self = this;
self.instance = self.collection.paginate(page);
// If we have no more themes bail
if (self.instance.length === 0) {
// Fire a no-more-themes event.
this.parent.trigger('theme:end');
return;
}
// Make sure the add-new stays at the end
if (page >= 1) {
$('.add-new-theme').remove();
}
// Loop through the themes and setup each theme view
self.instance.each(function (theme) {
self.theme = new themes.view.Theme({
model: theme,
parent: self
});
// Render the views...
self.theme.render();
// and append them to div.themes
self.$el.append(self.theme.el);
// Binds to theme:expand to show the modal box
// with the theme details
self.listenTo(self.theme, 'theme:expand', self.expand, self);
});
// 'Add new theme' element shown at the end of the grid
if (themes.data.settings.canInstall) {
this.$el.append('
' + l10n.error + '