Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
42 changes: 42 additions & 0 deletions freescout.wordpress.net/Modules/WPOrgSite/Public/css/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
197 changes: 197 additions & 0 deletions freescout.wordpress.net/Modules/WPOrgSite/Public/js/users.js
Original file line number Diff line number Diff line change
@@ -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 = $(
'<div class="flexy-item wporgsite-users-filters"></div>'
);
groups.forEach( function ( group ) {
const $group = $(
'<div class="btn-group" role="group"></div>'
).attr( 'aria-label', group.label );

group.options.forEach( function ( option ) {
option.$button = $(
'<button type="button" class="btn btn-default"></button>'
)
.text( option.text )
.on( 'click', function () {
selected[ group.key ] = option;
updateUrl();
apply();
} )
.appendTo( $group );
} );

$filters.append( $group );
} );

const $empty = $(
'<p class="text-help wporgsite-users-empty" hidden></p>'
).text( 'No users match.' );
const $live = $( '<span class="sr-only" aria-live="polite"></span>' );

/**
* 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 );
1 change: 1 addition & 0 deletions freescout.wordpress.net/Modules/WPOrgSite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion freescout.wordpress.net/Modules/WPOrgSite/module.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
Loading