diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php index d88bc23745..6d148e7381 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php @@ -120,6 +120,7 @@ static function ( array $javascripts ): array { $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/header.js'; $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/discard.js'; $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/settings.js'; + $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/users.js'; return $javascripts; } diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Public/css/site.css b/freescout.wordpress.net/Modules/WPOrgSite/Public/css/site.css index 7e3541ddd4..6745e524c2 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/Public/css/site.css +++ b/freescout.wordpress.net/Modules/WPOrgSite/Public/css/site.css @@ -39,6 +39,48 @@ margin-left: 0; } +/* The Users page's filters, from users.js, left of the search and as tall as it. */ +.wporgsite-users-filters { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-right: 10px; +} + +.wporgsite-users-filters .btn { + height: 30px; + padding: 0 10px; + font-size: 13px; + line-height: 28px; +} + +/* As light as the search's border, with gray text, in every state Bootstrap darkens them. */ +.wporgsite-users-filters .btn, +.wporgsite-users-filters .btn:hover, +.wporgsite-users-filters .btn:focus, +.wporgsite-users-filters .btn.active, +.wporgsite-users-filters .btn.active:hover, +.wporgsite-users-filters .btn.active:focus { + border-color: #d5dbe0; + color: #666; +} + +/* Bootstrap's focus border, which is its only focus indicator, for keyboard focus. */ +.wporgsite-users-filters .btn:focus-visible, +.wporgsite-users-filters .btn.active:focus-visible { + border-color: #a5a9ac; +} + +/* Bootstrap's pressed shadow, lighter. */ +.wporgsite-users-filters .btn.active { + box-shadow: inset 0 1px 2px rgba( 0, 0, 0, 0.075 ); +} + +/* Core's search shows and hides cards inline, so the filters' hiding needs !important. */ +#users-list > .card.wporgsite-users-filtered { + display: none !important; +} + /* A conversation's number, which copies it. */ .wporgsite-number { cursor: pointer; diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Public/js/users.js b/freescout.wordpress.net/Modules/WPOrgSite/Public/js/users.js new file mode 100644 index 0000000000..0edc4ee574 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Public/js/users.js @@ -0,0 +1,197 @@ +/** + * Filters the Users page by role and status. The filters are kept in the URL, so going back from a profile, or opening + * a shared link, shows the same users. The heading's count is of the users shown, after core's search too. + * + * @param {jQuery} $ jQuery. + */ +( function ( $ ) { + /** + * The class of the cards the filters hide; site.css hides them over core's search, which shows and hides inline. + * + * @type {string} + */ + const HIDDEN = 'wporgsite-users-filtered'; + + /** + * The filters: each group's key in the URL, its label, and its choices, each with its value in the URL, its text, + * and which users it matches. + * + * @type {Object[]} + */ + const GROUPS = [ + { + key: 'role', + label: 'Role', + options: [ + { value: '', text: 'All', test: () => true }, + { + value: 'admin', + text: 'Admins', + test: ( user ) => user.admin, + }, + { + value: 'user', + text: 'Users', + test: ( user ) => ! user.admin, + }, + ], + }, + { + key: 'status', + label: 'Status', + options: [ + { value: '', text: 'All', test: () => true }, + { + value: 'active', + text: 'Active', + test: ( user ) => ! user.inactive, + }, + { + value: 'inactive', + text: 'Inactive', + test: ( user ) => user.inactive, + }, + ], + }, + ]; + + $( function () { + const $cards = $( '#users-list > .card' ); + const $search = $( '#search-users' ).closest( '.flexy-item' ); + + // Core shows the search only on the Users page, with more than one user; the Teams module reuses #users-list. + if ( $cards.length < 2 || ! $search.length ) { + return; + } + + const $count = $( '.heading small' ).first(); + const users = $cards.toArray().map( function ( card ) { + const $card = $( card ); + + return { + $card, + admin: $card.children( '.user-admin-badge' ).length > 0, + // Invites not accepted yet, which core grays out, and disabled users. + inactive: + $card.hasClass( 'card-inactive' ) || + $card.children( '.glyphicon-ban-circle' ).length > 0, + }; + } ); + + // Agents who aren't admins don't see any admins, so there's no role to choose. + const groups = GROUPS.filter( + ( group ) => + 'role' !== group.key || users.some( ( user ) => user.admin ) + ); + + const params = new window.URLSearchParams( window.location.search ); + const selected = {}; + groups.forEach( function ( group ) { + selected[ group.key ] = + group.options.find( + ( option ) => option.value === params.get( group.key ) + ) || group.options[ 0 ]; + } ); + + const $filters = $( + '
' + ); + groups.forEach( function ( group ) { + const $group = $( + '
' + ).attr( 'aria-label', group.label ); + + group.options.forEach( function ( option ) { + option.$button = $( + '' + ) + .text( option.text ) + .on( 'click', function () { + selected[ group.key ] = option; + updateUrl(); + apply(); + } ) + .appendTo( $group ); + } ); + + $filters.append( $group ); + } ); + + const $empty = $( + '' + ).text( 'No users match.' ); + const $live = $( '' ); + + /** + * Shows the users the filters match, marks the chosen filters, and counts the users shown. + */ + function apply() { + users.forEach( ( user ) => + user.$card.toggleClass( + HIDDEN, + ! groups.every( ( group ) => + selected[ group.key ].test( user ) + ) + ) + ); + + groups.forEach( function ( group ) { + group.options.forEach( function ( option ) { + const on = selected[ group.key ] === option; + + option.$button + .toggleClass( 'active', on ) + .attr( 'aria-pressed', String( on ) ); + } ); + } ); + + count(); + } + + /** + * Counts the users shown in the heading, after core's search, which hides cards inline. + */ + function count() { + const shown = users.filter( + ( user ) => + ! user.$card.hasClass( HIDDEN ) && + 'none' !== user.$card[ 0 ].style.display + ).length; + const text = shown + ( 1 === shown ? ' user' : ' users' ); + + $count.text( '(' + shown + ')' ); + $empty.prop( 'hidden', shown > 0 ); + if ( $live.text() !== text ) { + $live.text( text ); + } + } + + /** + * Keeps the filters in the URL, without a history entry each. + */ + function updateUrl() { + const url = new window.URL( window.location.href ); + + // Every group, so a role from a shared link goes for agents who don't get the role filter. + GROUPS.forEach( function ( group ) { + const choice = selected[ group.key ]; + + if ( choice && choice.value ) { + url.searchParams.set( group.key, choice.value ); + } else { + url.searchParams.delete( group.key ); + } + } ); + + window.history.replaceState( window.history.state, '', url ); + } + + // Delegated, so it runs after core's search has shown and hidden the cards; the clear button triggers keypress. + $( document ).on( 'keyup keypress', '#search-users', count ); + + // Before the live region is in the page, so the first count isn't announced. + apply(); + $search.before( $filters ); + $( '#users-list' ).after( $empty, $live ); + } ); +} )( jQuery ); diff --git a/freescout.wordpress.net/Modules/WPOrgSite/README.md b/freescout.wordpress.net/Modules/WPOrgSite/README.md index 15db135982..a8d029eba5 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/README.md +++ b/freescout.wordpress.net/Modules/WPOrgSite/README.md @@ -7,6 +7,7 @@ Adapts FreeScout to how WordPress.org runs it. Small changes to FreeScout's own - **Modules page:** only installed modules are listed, without FreeScout's Modules Directory and Marketplace. They can't be updated or deleted there; that happens on the server. Switching them on and off still works. - **Modules page layout:** the module cards are laid out in columns, as many as the window fits. - **Settings kept in `.env`:** FreeScout saves some of Manage » Settings to `.env`, like the time zone, the language, conversation numbers, and the fetching interval, and so do modules for some of theirs. The server's `.env` is read-only, so saving one failed, and with it the rest of its section. They show read-only, with their value and a note to ask the systems team, and saving a section leaves them out, so the rest of it saves. It's in `Services/EnvSettings.php` and `Public/js/settings.js`. +- **Users page:** the users can be filtered by role, admins or users, and by status, active or inactive: users who haven’t accepted their invite or logged in yet, which core grays out, and disabled users. The heading counts the users shown, after the search too. The filters are kept in the URL, like `?role=admin&status=active`, so going back from a profile, or a shared link, shows the same users. Agents who aren't admins don't see admins, so they only get the status filter. It's in `Public/js/users.js`. - **Missing scripts and stylesheets:** a module's file in core's script or style bundle that isn't on the server is left out, and logged. Otherwise core's Minify throws, and core leaves the whole bundle out of the page, jQuery and all, so nothing that needs JavaScript works. It's in `Services/Assets.php`. - **Recipients:** a reply, forward, or new conversation to more than 100 people, in To, Cc, Bcc, and the mailbox's Auto Bcc together, from a mailbox that sends through Gmail (`smtp.gmail.com`), isn't sent, and the form says why, naming the Auto Bcc when it's what puts the email over. Gmail refuses more than 100 people in To and Cc over SMTP, and counts every recipient, Bcc included, towards the mailbox's daily limits ([Google's limits](https://support.google.com/a/answer/166852)). Notes aren't emailed, so they aren't limited. It's in `Http/Middleware/LimitRecipients.php`. - **Replies Gmail stopped partway through aren't sent again:** FreeScout's mailer sends To and Cc one email, then each Bcc address one of its own, Auto Bcc included. When Gmail's daily sending limit is reached partway through, FreeScout would retry the whole reply every hour for a week, so the people it already reached would get it again each time, and the mailbox couldn't send anything else meanwhile. Once Gmail accepted any of a reply's emails, such a reply fails instead and shows as not sent, with a note listing everyone who didn't get it, ready to paste into Bcc. One that Gmail refused from the start, like any reply while the mailbox is over its limit, is still retried. It's in `Services/PartialSends.php`, which listens to Swift's SMTP conversation for whom Gmail accepted the reply for, and then adds Gmail's error to core's `app.no_retry_mail_errors` for that reply. diff --git a/freescout.wordpress.net/Modules/WPOrgSite/module.json b/freescout.wordpress.net/Modules/WPOrgSite/module.json index 5e50fffcf0..92f719b0c4 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/module.json +++ b/freescout.wordpress.net/Modules/WPOrgSite/module.json @@ -3,7 +3,7 @@ "alias": "wporgsite", "img": "/modules/wporgsite/img/icon.svg", "description": "Adapts FreeScout to how WordPress.org runs it, e.g. modules come from deploys, not from updates here.", - "version": "1.3.0", + "version": "1.4.0", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgSite", "author": "WordPress.org", "authorUrl": "https://wordpress.org/", diff --git a/freescout.wordpress.net/Modules/WPOrgSite/tests/AssetsTest.php b/freescout.wordpress.net/Modules/WPOrgSite/tests/AssetsTest.php index d19cdf98f1..b5383dff80 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/tests/AssetsTest.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/tests/AssetsTest.php @@ -37,7 +37,7 @@ protected function setUp(): void { public function test_adds_assets(): void { $assets = array( 'stylesheets' => array( '/css/style.css', '/css/site.css' ), - 'javascripts' => array( '/js/main.js', '/js/modules.js', '/js/alt-conversation.js', '/js/conversation.js', '/js/saved-replies.js', '/js/sidebar.js', '/js/header.js', '/js/discard.js', '/js/settings.js' ), + 'javascripts' => array( '/js/main.js', '/js/modules.js', '/js/alt-conversation.js', '/js/conversation.js', '/js/saved-replies.js', '/js/sidebar.js', '/js/header.js', '/js/discard.js', '/js/settings.js', '/js/users.js' ), ); foreach ( $assets as $filter => $files ) {