REFACTOR: user directories without <table>, second attempt (#20515)

This commit is contained in:
Kris 2023-03-02 15:10:19 -05:00 committed by GitHub
parent 435761ef58
commit fac78413c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 965 additions and 737 deletions

View File

@ -15,7 +15,7 @@ module.exports = {
"directory-item-value", "directory-item-value",
"directory-table-header-title", "directory-table-header-title",
"loading-spinner", "loading-spinner",
"mobile-directory-item-label", "directory-item-label",
], ],
}, },
"no-implicit-this": { "no-implicit-this": {

View File

@ -31,6 +31,21 @@ export default Controller.extend(CanCheckEmails, {
return I18n.t("admin.users.titles." + query); return I18n.t("admin.users.titles." + query);
}, },
@discourseComputed("showEmails")
columnCount(showEmails) {
let colCount = 7; // note that the first column is hardcoded in the template
if (showEmails) {
colCount += 1;
}
if (this.siteSettings.must_approve_users) {
colCount += 1;
}
return colCount;
},
@observes("listFilter") @observes("listFilter")
_filterUsers() { _filterUsers() {
discourseDebounce(this, this.resetFilters, INPUT_DELAY); discourseDebounce(this, this.resetFilters, INPUT_DELAY);

View File

@ -26,140 +26,204 @@
@title={{this.searchHint}} @title={{this.searchHint}}
/> />
</div> </div>
<LoadMore <LoadMore
@class="users-list-container" @class="users-list-container"
@selector=".users-list tr" @selector=".users-list tr"
@action={{action "loadMore"}} @action={{action "loadMore"}}
> >
{{#if this.model}} {{#if this.model}}
<table class="table users-list grid" role="table" aria-label={{this.title}}>
<thead>
<tr>
<TableHeaderToggle
@field="username"
@labelKey="username"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@class={{if this.showEmails "" "hidden"}}
@field="email"
@labelKey="email"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="last_emailed"
@labelKey="admin.users.last_emailed"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="seen"
@labelKey="last_seen"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="topics_viewed"
@labelKey="admin.user.topics_entered"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="posts_read"
@labelKey="admin.user.posts_read_count"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="read_time"
@labelKey="admin.user.time_read"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="created"
@labelKey="created"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<PluginOutlet
@name="admin-users-list-thead-after"
@outletArgs={{hash order=this.order asc=this.asc}}
/>
{{#if this.siteSettings.must_approve_users}} <ResponsiveTable
<th>{{i18n "admin.users.approved"}}</th> @className="users-list"
{{/if}} @aria-label={{this.title}}
<th>&nbsp;</th> @style={{html-safe
</tr> (concat
</thead> "grid-template-columns: minmax(min-content, 2fr) repeat("
<tbody> (html-safe this.columnCount)
", minmax(min-content, 1fr))"
)
}}
@updates={{this.model.email}}
>
<:header>
<TableHeaderToggle
@class="directory-table__column-header--username"
@field="username"
@labelKey="username"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@class={{if
this.showEmails
"directory-table__column-header--email"
"hidden"
}}
@field="email"
@labelKey="email"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="last_emailed"
@labelKey="admin.users.last_emailed"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="seen"
@labelKey="last_seen"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="topics_viewed"
@labelKey="admin.user.topics_entered"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="posts_read"
@labelKey="admin.user.posts_read_count"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="read_time"
@labelKey="admin.user.time_read"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<TableHeaderToggle
@field="created"
@labelKey="created"
@order={{this.order}}
@asc={{this.asc}}
@automatic={{true}}
/>
<PluginOutlet
@name="admin-users-list-thead-after"
@outletArgs={{hash order=this.order asc=this.asc}}
/>
{{#if this.siteSettings.must_approve_users}}
<div class="directory-table__column-header">{{i18n
"admin.users.approved"
}}</div>
{{/if}}
<div class="directory-table__column-header">&nbsp;</div>
</:header>
<:body>
{{#each this.model as |user|}} {{#each this.model as |user|}}
<tr <div
class="user class="user
{{user.selected}} {{user.selected}}
{{unless user.active 'not-activated'}}" {{unless user.active 'not-activated'}}
directory-table__row"
> >
<td class="username"> <div class="directory-table__cell username">
<a href={{user.path}} data-user-card={{user.username}}> <a
class="avatar"
href={{user.path}}
data-user-card={{user.username}}
>
{{avatar user imageSize="small"}} {{avatar user imageSize="small"}}
</a> </a>
<LinkTo <LinkTo @route="adminUser" @model={{user}}>
@route="adminUser" {{user.username}}
@model={{user}} </LinkTo>
>{{user.username}}</LinkTo>
{{#if user.staged}} {{#if user.staged}}
{{d-icon "far-envelope" title="user.staged"}} {{d-icon "far-envelope" title="user.staged"}}
{{/if}} {{/if}}
</td> </div>
<td class="email {{if this.showEmails '' 'hidden'}}"> <div
{{~user.email~}} class="directory-table__cell email
</td> {{if this.showEmails '' 'hidden'}}"
>
<span class="directory-table__value">
{{~user.email~}}
</span>
</div>
{{#if user.last_emailed_at}} {{#if user.last_emailed_at}}
<td class="last-emailed" title={{raw-date user.last_emailed_at}}> <div
<div class="label">{{i18n "admin.users.last_emailed"}}</div> class="directory-table__cell last-emailed"
<div>{{format-duration user.last_emailed_age}}</div> title={{raw-date user.last_emailed_at}}
</td> >
<span class="directory-table__label">
<span>{{i18n "admin.users.last_emailed"}}</span>
</span>
<span class="directory-table__value">
{{format-duration user.last_emailed_age}}
</span>
</div>
{{else}} {{else}}
<td class="last-emailed"> <div class="directory-table__cell last-emailed">
<div class="label">{{i18n "admin.users.last_emailed"}}</div> <span class="directory-table__label">
<div>{{format-duration user.last_emailed_age}}</div> <span>{{i18n "admin.users.last_emailed"}}</span>
</td> </span>
<span class="directory-table__value">
{{format-duration user.last_emailed_age}}
</span>
</div>
{{/if}} {{/if}}
<td class="last-seen" title={{raw-date user.last_seen_at}}> <div
<div class="label">{{i18n "last_seen"}}</div> class="directory-table__cell last-seen"
<div>{{format-duration user.last_seen_age}}</div> title={{raw-date user.last_seen_at}}
</td> >
<td class="topics-entered"> <span class="directory-table__label">
<div class="label">{{i18n "admin.user.topics_entered"}}</div> <span>{{i18n "last_seen"}}</span>
<div>{{number user.topics_entered}}</div> </span>
</td> <span class="directory-table__value">
<td class="posts-read"> {{format-duration user.last_seen_age}}
<div class="label">{{i18n "admin.user.posts_read_count"}}</div> </span>
<div>{{number user.posts_read_count}}</div> </div>
</td> <div class="directory-table__cell topics-entered">
<td class="time-read"> <span class="directory-table__label">
<div class="label">{{i18n "admin.user.time_read"}}</div> <span>{{i18n "admin.user.topics_entered"}}</span>
<div>{{format-duration user.time_read}}</div> </span>
</td> <span class="directory-table__value">
{{number user.topics_entered}}
<td class="created" title={{raw-date user.created_at}}> </span>
<div class="label">{{i18n "created"}}</div> </div>
<div>{{format-duration user.created_at_age}}</div> <div class="directory-table__cell posts-read">
</td> <span class="directory-table__label">
<span>{{i18n "admin.user.posts_read_count"}}</span>
</span>
<span class="directory-table__value">
{{number user.posts_read_count}}
</span>
</div>
<div class="directory-table__cell time-read">
<span class="directory-table__label">
<span>{{i18n "admin.user.time_read"}}</span>
</span>
<span class="directory-table__value">
{{format-duration user.time_read}}
</span>
</div>
<div
class="directory-table__cell created"
title={{raw-date user.created_at}}
>
<span class="directory-table__label">
<span>{{i18n "created"}}</span>
</span>
<span class="directory-table__value">
{{format-duration user.created_at_age}}
</span>
</div>
<PluginOutlet <PluginOutlet
@name="admin-users-list-td-after" @name="admin-users-list-td-after"
@ -167,29 +231,43 @@
/> />
{{#if this.siteSettings.must_approve_users}} {{#if this.siteSettings.must_approve_users}}
<td>{{i18n-yes-no user.approved}}</td> <div class="directory-table__cell">
<span class="directory-table__label">
<span>{{i18n "admin.users.approved"}}</span>
</span>
<span class="directory-table__value">
{{i18n-yes-no user.approved}}
</span>
</div>
{{/if}} {{/if}}
<td class="user-status"> <div class="directory-table__cell user-status">
{{#if user.admin}} <span class="directory-table__label">
{{d-icon "shield-alt" title="admin.title"}} <span>{{i18n "admin.users.status"}}</span>
{{/if}} </span>
{{#if user.moderator}} <span class="directory-table__value">
{{d-icon "shield-alt" title="admin.moderator"}} {{#if user.admin}}
{{/if}} {{d-icon "shield-alt" title="admin.title"}}
{{#if user.second_factor_enabled}} {{/if}}
{{d-icon "lock" title="admin.user.second_factor_enabled"}} {{#if user.moderator}}
{{/if}} {{d-icon "shield-alt" title="admin.moderator"}}
{{/if}}
{{#if user.second_factor_enabled}}
{{d-icon "lock" title="admin.user.second_factor_enabled"}}
{{/if}}
</span>
<PluginOutlet <PluginOutlet
@name="admin-users-list-icon" @name="admin-users-list-icon"
@connectorTagName="div" @connectorTagName="div"
@outletArgs={{hash user=user query=this.query}} @outletArgs={{hash user=user query=this.query}}
/> />
</td> </div>
</tr> </div>
{{/each}} {{/each}}
</tbody> </:body>
</table>
</ResponsiveTable>
<ConditionalLoadingSpinner @condition={{this.refreshing}} /> <ConditionalLoadingSpinner @condition={{this.refreshing}} />
{{else}} {{else}}
<p>{{i18n "search.no_results"}}</p> <p>{{i18n "search.no_results"}}</p>

View File

@ -1,16 +1,38 @@
<td><UserInfo @user={{this.item.user}} /></td> <div class="directory-table__cell">
<UserInfo @user={{this.item.user}} />
</div>
{{#each this.columns as |column|}} {{#each this.columns as |column|}}
<td> {{#if (directory-column-is-user-field column=column)}}
{{#if (directory-column-is-user-field column=column)}} <div class="directory-table__cell--user-field">
<span class="directory-table__label">
<span>{{column.name}}</span>
</span>
{{directory-item-user-field-value item=this.item column=column}} {{directory-item-user-field-value item=this.item column=column}}
{{else}} </div>
{{else}}
<div class="directory-table__cell">
<span class="directory-table__label">
<span>
{{#if column.icon}}
{{d-icon column.icon}}
{{/if}}
{{directory-item-label item=this.item column=column}}
</span>
</span>
{{directory-item-value item=this.item column=column}} {{directory-item-value item=this.item column=column}}
{{/if}} </div>
</td> {{/if}}
{{/each}} {{/each}}
{{#if this.showTimeRead}} {{#if this.showTimeRead}}
<td><span class="time-read">{{format-duration <div class="directory-table__cell time-read">
this.item.time_read <span class="directory-table__label">
}}</span></td> <span>{{i18n "directory.time_read"}}</span>
</span>
<span class="directory-table__value">
{{format-duration this.item.time_read}}
</span>
</div>
{{/if}} {{/if}}

View File

@ -2,7 +2,8 @@ import Component from "@ember/component";
import { propertyEqual } from "discourse/lib/computed"; import { propertyEqual } from "discourse/lib/computed";
export default Component.extend({ export default Component.extend({
tagName: "tr", tagName: "div",
classNames: ["directory-table__row"],
classNameBindings: ["me"], classNameBindings: ["me"],
me: propertyEqual("item.user.id", "currentUser.id"), me: propertyEqual("item.user.id", "currentUser.id"),
columns: null, columns: null,

View File

@ -1,39 +1,37 @@
<div class="directory-table-top-scroll"> <ResponsiveTable>
<div class="directory-table-top-scroll-fake-content"></div> <:header>
</div> <TableHeaderToggle
@field="username"
<div class="directory-table-container"> @order={{this.order}}
<table class="directory-table"> @asc={{this.asc}}
<thead> />
{{#each this.columns as |column|}}
<TableHeaderToggle <TableHeaderToggle
@field="username" @field={{column.name}}
@icon={{column.icon}}
@order={{this.order}} @order={{this.order}}
@asc={{this.asc}} @asc={{this.asc}}
@automatic={{directory-column-is-automatic column=column}}
@translated={{column.user_field_id}}
@onActiveRender={{this.setActiveHeader}}
/> />
{{#each this.columns as |column|}} {{/each}}
<TableHeaderToggle
@field={{column.name}}
@icon={{column.icon}}
@order={{this.order}}
@asc={{this.asc}}
@automatic={{directory-column-is-automatic column=column}}
@translated={{column.user_field_id}}
@onActiveRender={{this.setActiveHeader}}
/>
{{/each}}
{{#if this.showTimeRead}} {{#if this.showTimeRead}}
<th>{{i18n "directory.time_read"}}</th> <div class="directory-table__column-header">
{{/if}} <div class="header-contents">
</thead> {{i18n "directory.time_read"}}
<tbody> </div>
{{#each this.items as |item|}} </div>
<DirectoryItem {{/if}}
@item={{item}} </:header>
@columns={{this.columns}} <:body>
@showTimeRead={{this.showTimeRead}} {{#each this.items as |item|}}
/> <DirectoryItem
{{/each}} @item={{item}}
</tbody> @columns={{this.columns}}
</table> @showTimeRead={{this.showTimeRead}}
</div> />
{{/each}}
</:body>
</ResponsiveTable>

View File

@ -2,109 +2,31 @@ import Component from "@ember/component";
import { action } from "@ember/object"; import { action } from "@ember/object";
export default Component.extend({ export default Component.extend({
lastScrollPosition: 0,
ticking: false,
_topHorizontalScrollBar: null,
_tableContainer: null,
_table: null, _table: null,
_fakeScrollContent: null,
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
this.setProperties({ this.setProperties({
_tableContainer: this.element.querySelector(".directory-table-container"),
_topHorizontalScrollBar: this.element.querySelector(
".directory-table-top-scroll"
),
_fakeScrollContent: this.element.querySelector(
".directory-table-top-scroll-fake-content"
),
_table: this.element.querySelector(".directory-table"), _table: this.element.querySelector(".directory-table"),
_columnCount: this.showTimeRead
? this.attrs.columns.value.length + 1
: this.attrs.columns.value.length,
}); });
this._tableContainer.addEventListener("scroll", this.onBottomScroll); this._table.style.gridTemplateColumns = `minmax(13em, 3fr) repeat(${this._columnCount}, minmax(max-content, 1fr))`;
this._topHorizontalScrollBar.addEventListener("scroll", this.onTopScroll);
// Set active header might have already scrolled the _tableContainer.
// Call onHorizontalScroll manually to scroll the _topHorizontalScrollBar
this.onResize();
this.onHorizontalScroll(this._tableContainer, this._topHorizontalScrollBar);
window.addEventListener("resize", this.onResize);
},
@action
onResize() {
if (
this._tableContainer.getBoundingClientRect().bottom < window.innerHeight
) {
// Bottom of the table is visible. Hide the scrollbar
this._fakeScrollContent.style.height = 0;
} else {
this._fakeScrollContent.style.width = `${this._table.offsetWidth}px`;
this._fakeScrollContent.style.height = "1px";
}
},
@action
onTopScroll() {
this.onHorizontalScroll(this._topHorizontalScrollBar, this._tableContainer);
},
@action
onBottomScroll() {
this.onHorizontalScroll(this._tableContainer, this._topHorizontalScrollBar);
},
@action
onHorizontalScroll(primary, replica) {
if (
this.isDestroying ||
this.isDestroyed ||
this.lastScrollPosition === primary.scrollLeft
) {
return;
}
this.set("lastScrollPosition", primary.scrollLeft);
if (!this.ticking) {
window.requestAnimationFrame(() => {
if (!this.isDestroying && !this.isDestroyed) {
replica.scrollLeft = this.lastScrollPosition;
this.set("ticking", false);
}
});
this.set("ticking", true);
}
},
willDestroyElement() {
this._tableContainer.removeEventListener("scroll", this.onBottomScroll);
this._topHorizontalScrollBar.removeEventListener(
"scroll",
this.onTopScroll
);
window.removeEventListener("resize", this.onResize);
}, },
@action @action
setActiveHeader(header) { setActiveHeader(header) {
// After render, scroll table left to ensure the order by column is visible // After render, scroll table left to ensure the order by column is visible
if (!this._tableContainer) { if (!this._table) {
this.set( this.set("_table", document.querySelector(".directory-table"));
"_tableContainer",
document.querySelector(".directory-table-container")
);
} }
const scrollPixels = const scrollPixels =
header.offsetLeft + header.offsetLeft + header.offsetWidth + 10 - this._table.offsetWidth;
header.offsetWidth +
10 -
this._tableContainer.offsetWidth;
if (scrollPixels > 0) { if (scrollPixels > 0) {
this._tableContainer.scrollLeft = scrollPixels; this._table.scrollLeft = scrollPixels;
} }
}, },
}); });

View File

@ -0,0 +1,22 @@
<div class="directory-table-container">
<div class="directory-table-top-scroll" {{on "scroll" this.onTopScroll}}>
<div class="directory-table-top-scroll-fake-content"></div>
</div>
<div
class={{concat-class "directory-table" @className}}
role="table"
aria-label={{@ariaLabel}}
style={{@style}}
{{did-insert this.checkScroll}}
{{did-update this.checkScroll @updates}}
{{on-resize this.checkScroll}}
{{on "scroll" this.onBottomScroll}}
>
<div class="directory-table__header">
{{yield to="header"}}
</div>
<div class="directory-table__body">
{{yield to="body"}}
</div>
</div>
</div>

View File

@ -0,0 +1,51 @@
import Component from "@ember/component";
import { bind } from "discourse-common/utils/decorators";
import { tracked } from "@glimmer/tracking";
export default class ResponsiveTable extends Component {
@tracked lastScrollPosition = 0;
@tracked ticking = false;
@tracked _table = document.querySelector(".directory-table");
@tracked _topHorizontalScrollBar = document.querySelector(
".directory-table-top-scroll"
);
@bind
checkScroll() {
const _fakeScrollContent = document.querySelector(
".directory-table-top-scroll-fake-content"
);
if (this._table.getBoundingClientRect().bottom < window.innerHeight) {
// Bottom of the table is visible. Hide the scrollbar
_fakeScrollContent.style.height = 0;
} else {
_fakeScrollContent.style.width = `${this._table.scrollWidth}px`;
_fakeScrollContent.style.height = "1px";
}
}
@bind
onTopScroll() {
this.onHorizontalScroll(this._topHorizontalScrollBar, this._table);
}
@bind
onBottomScroll() {
this.onHorizontalScroll(this._table, this._topHorizontalScrollBar);
}
@bind
onHorizontalScroll(primary, replica) {
this.set("lastScrollPosition", primary?.scrollLeft);
if (!this.ticking) {
window.requestAnimationFrame(() => {
replica.scrollLeft = this.lastScrollPosition;
this.set("ticking", false);
});
this.set("ticking", true);
}
}
}

View File

@ -1,4 +1,4 @@
<span <div
class="header-contents" class="header-contents"
id={{this.id}} id={{this.id}}
role="button" role="button"
@ -6,11 +6,15 @@
aria-label={{this.ariaLabel}} aria-label={{this.ariaLabel}}
aria-pressed={{this.pressedState}} aria-pressed={{this.pressedState}}
> >
{{directory-table-header-title
field=this.field {{yield}}
labelKey=this.labelKey <span class="text">
icon=this.icon {{directory-table-header-title
translated=this.translated field=this.field
}} labelKey=this.labelKey
{{this.chevronIcon}} icon=this.icon
</span> translated=this.translated
}}
{{this.chevronIcon}}
</span>
</div>

View File

@ -6,8 +6,8 @@ import discourseComputed from "discourse-common/utils/decorators";
import I18n from "I18n"; import I18n from "I18n";
export default Component.extend({ export default Component.extend({
tagName: "th", tagName: "div",
classNames: ["sortable"], classNames: ["directory-table__column-header", "sortable"],
attributeBindings: ["title", "colspan", "ariaSort:aria-sort", "role"], attributeBindings: ["title", "colspan", "ariaSort:aria-sort", "role"],
role: "columnheader", role: "columnheader",
labelKey: null, labelKey: null,

View File

@ -3,7 +3,7 @@ import { number } from "discourse/lib/formatter";
import { registerUnbound } from "discourse-common/lib/helpers"; import { registerUnbound } from "discourse-common/lib/helpers";
import I18n from "I18n"; import I18n from "I18n";
registerUnbound("mobile-directory-item-label", function (args) { registerUnbound("directory-item-label", function (args) {
// Args should include key/values { item, column } // Args should include key/values { item, column }
const count = args.item.get(args.column.name); const count = args.item.get(args.column.name);
const translationPrefix = const translationPrefix =
@ -14,7 +14,9 @@ registerUnbound("mobile-directory-item-label", function (args) {
registerUnbound("directory-item-value", function (args) { registerUnbound("directory-item-value", function (args) {
// Args should include key/values { item, column } // Args should include key/values { item, column }
return htmlSafe( return htmlSafe(
`<span class='number'>${number(args.item.get(args.column.name))}</span>` `<span class='directory-table__value'>${number(
args.item.get(args.column.name)
)}</span>`
); );
}); });
@ -25,7 +27,9 @@ registerUnbound("directory-item-user-field-value", function (args) {
? args.item.user.user_fields[args.column.user_field_id] ? args.item.user.user_fields[args.column.user_field_id]
: null; : null;
const content = value || "-"; const content = value || "-";
return htmlSafe(`<span class='user-field-value'>${content}</span>`); return htmlSafe(
`<span class='directory-table__value--user-field'>${content}</span>`
);
}); });
registerUnbound("directory-column-is-automatic", function (args) { registerUnbound("directory-column-is-automatic", function (args) {

View File

@ -1,5 +1,15 @@
<section class="user-content"> <section class="user-content">
<div class="group-members-actions"> <div class="group-members-actions">
{{#if this.canManageGroup}}
<DButton
@class="bulk-select"
@icon="list"
@action={{action "toggleBulkSelect"}}
@title="topics.bulk.toggle"
/>
{{/if}}
{{#if this.model.can_see_members}} {{#if this.model.can_see_members}}
<TextField <TextField
@value={{this.filterInput}} @value={{this.filterInput}}
@ -10,6 +20,35 @@
{{/if}} {{/if}}
{{#if this.canManageGroup}} {{#if this.canManageGroup}}
{{#if this.isBulk}}
<span class="bulk-select-buttons-wrap">
{{#if this.bulkSelection}}
<BulkGroupMemberDropdown
@bulkSelection={{this.bulkSelection}}
@canAdminGroup={{this.model.can_admin_group}}
@canEditGroup={{this.model.can_edit_group}}
@onChange={{action "actOnSelection" this.bulkSelection}}
/>
<DButton
@action={{action "bulkClearAll"}}
@label="topics.bulk.clear_all"
@icon="far-square"
@class="bulk-select-clear"
/>
{{/if}}
<DButton
@action={{action "bulkSelectAll"}}
@label="topics.bulk.select_all"
@icon="check-square"
@class="bulk-select-all"
/>
</span>
{{/if}}
<div class="group-members-manage"> <div class="group-members-manage">
<DButton <DButton
@icon="plus" @icon="plus"
@ -31,134 +70,148 @@
</div> </div>
{{#if this.hasMembers}} {{#if this.hasMembers}}
<LoadMore @selector=".group-members tr" @action={{action "loadMore"}}> <LoadMore
<table @selector=".group-members .directory-table-row"
class={{if this.isBulk "group-members sticky-header" "group-members"}} @action={{action "loadMore"}}
>
<ResponsiveTable
@className="group-members
{{if this.isBulk 'sticky-header' ''}}
{{if this.canManageGroup 'group-members--can-manage' ''}}"
> >
<thead> <:header>
<tr> <TableHeaderToggle
<th class="bulk-select"> @order={{this.order}}
{{#if this.canManageGroup}} @asc={{this.asc}}
<FlatButton @field="username_lower"
@class="bulk-select" @labelKey="username"
@icon="list" @class="username"
@action={{action "toggleBulkSelect"}} @automatic={{true}}
@title="topics.bulk.toggle" @colspan="2"
/> />
{{/if}}
</th> {{#if this.canManageGroup}}
{{#if this.isBulk}} <div class="directory-table__column-header"></div>
<th class="bulk-select-buttons"> {{/if}}
<span class="bulk-select-buttons-wrap">
{{#if this.bulkSelection}} <TableHeaderToggle
<BulkGroupMemberDropdown @class="directory-table__column-header"
@bulkSelection={{this.bulkSelection}} @order={{this.order}}
@canAdminGroup={{this.model.can_admin_group}} @asc={{this.asc}}
@canEditGroup={{this.model.can_edit_group}} @field="added_at"
@onChange={{action "actOnSelection" this.bulkSelection}} @labelKey="groups.member_added"
@automatic={{true}}
/>
<TableHeaderToggle
@class="directory-table__column-header"
@order={{this.order}}
@asc={{this.asc}}
@field="last_posted_at"
@labelKey="last_post"
@automatic={{true}}
/>
<TableHeaderToggle
@class="directory-table__column-header"
@order={{this.order}}
@asc={{this.asc}}
@field="last_seen_at"
@labelKey="last_seen"
@automatic={{true}}
/>
{{#if this.canManageGroup}}
<div class="directory-table__column-header"></div>
{{/if}}
</:header>
<:body>
{{#each this.model.members as |m|}}
<div class="directory-table__row">
<div class="directory-table__cell group-member" colspan="2">
{{#if this.canManageGroup}}
{{#if this.isBulk}}
<Input
@type="checkbox"
class="bulk-select"
{{on "click" (action "selectMember" m)}}
/> />
{{/if}} {{/if}}
<DButton {{/if}}
@action={{action "bulkSelectAll"}}
@label="topics.bulk.select_all"
/>
<DButton
@action={{action "bulkClearAll"}}
@label="topics.bulk.clear_all"
/>
</span>
</th>
{{/if}}
<TableHeaderToggle
@order={{this.order}}
@asc={{this.asc}}
@field="username_lower"
@labelKey="username"
@class="username"
@automatic={{true}}
@colspan="2"
/>
<TableHeaderToggle
@order={{this.order}}
@asc={{this.asc}}
@field="added_at"
@labelKey="groups.member_added"
@automatic={{true}}
/>
<TableHeaderToggle
@order={{this.order}}
@asc={{this.asc}}
@field="last_posted_at"
@labelKey="last_post"
@automatic={{true}}
/>
<TableHeaderToggle
@order={{this.order}}
@asc={{this.asc}}
@field="last_seen_at"
@labelKey="last_seen"
@automatic={{true}}
/>
<th></th>
</tr>
</thead>
<tbody>
{{#each this.model.members as |m|}}
<tr>
{{#if this.isBulk}}
<td class="bulk-select">
<Input
@type="checkbox"
class="bulk-select"
{{on "click" (action "selectMember" m)}}
/>
</td>
{{/if}}
<td class="group-member" colspan="2">
<UserInfo <UserInfo
@user={{m}} @user={{m}}
@skipName={{this.skipName}} @skipName={{this.skipName}}
@showStatus={{true}} @showStatus={{true}}
@showStatusTooltip={{true}} @showStatusTooltip={{true}}
/> />
</td> </div>
<td class="group-owner"> {{#if this.canManageGroup}}
{{#if m.owner}} <div class="directory-table__cell group-owner">
{{d-icon "shield-alt"}} {{#if (or m.owner m.primary)}}
{{i18n "groups.members.owner"}}<br /> <span class="directory-table__label">
{{/if}} <span>{{i18n "groups.members.status"}}</span>
{{#if m.primary}} </span>
{{i18n "groups.members.primary"}} {{/if}}
{{/if}} <span class="directory-table__value">
</td> {{#if m.owner}}
<td> {{d-icon "shield-alt"}}
<span class="text">{{bound-date m.added_at}}</span> {{i18n "groups.members.owner"}}<br />
</td> {{/if}}
<td> {{#if m.primary}}
<span class="text">{{bound-date m.last_posted_at}}</span> {{i18n "groups.members.primary"}}
</td> {{/if}}
<td> </span>
<span class="text">{{bound-date m.last_seen_at}}</span>
</td>
<td> </div>
{{#if this.canManageGroup}} {{/if}}
<div class="directory-table__cell">
<span class="directory-table__label">
<span>{{i18n "groups.member_added"}}</span>
</span>
<span class="directory-table__value">
{{bound-date m.added_at}}
</span>
</div>
<div class="directory-table__cell">
{{#if m.last_posted_at}}
<span class="directory-table__label">
<span>{{i18n "last_post"}}</span>
</span>
{{/if}}
<span class="directory-table__value">
{{bound-date m.last_posted_at}}
</span>
</div>
<div class="directory-table__cell">
{{#if m.last_seen_at}}
<span class="directory-table__label">
<span>{{i18n "last_seen"}}</span>
</span>
{{/if}}
<span class="directory-table__value">
{{bound-date m.last_seen_at}}
</span>
</div>
{{#if this.canManageGroup}}
<div class="directory-table__cell member-settings">
<GroupMemberDropdown <GroupMemberDropdown
@member={{m}} @member={{m}}
@canAdminGroup={{this.model.can_admin_group}} @canAdminGroup={{this.model.can_admin_group}}
@canEditGroup={{this.model.can_edit_group}} @canEditGroup={{this.model.can_edit_group}}
@onChange={{action "actOnGroup" m}} @onChange={{action "actOnGroup" m}}
@options={{hash placementStrategy="absolute"}}
/> />
{{/if}} {{! group parameter is used by plugins }}
{{! group parameter is used by plugins }} </div>
</td> {{/if}}
</tr> </div>
{{/each}} {{/each}}
</tbody> </:body>
</table>
</ResponsiveTable>
</LoadMore> </LoadMore>
<ConditionalLoadingSpinner @condition={{this.loading}} /> <ConditionalLoadingSpinner @condition={{this.loading}} />

View File

@ -9,10 +9,14 @@
</div> </div>
{{#if this.hasRequesters}} {{#if this.hasRequesters}}
<LoadMore @selector=".group-members tr" @action={{action "loadMore"}}> <LoadMore
<table class="group-members"> @selector=".group-members .directory-table__row"
<thead> @action={{action "loadMore"}}
>
<ResponsiveTable @className="group-members group-members__requests">
<:header>
<TableHeaderToggle <TableHeaderToggle
@class="username"
@order={{this.order}} @order={{this.order}}
@asc={{this.asc}} @asc={{this.asc}}
@field="username_lower" @field="username_lower"
@ -26,22 +30,34 @@
@labelKey="groups.member_requested" @labelKey="groups.member_requested"
@automatic={{true}} @automatic={{true}}
/> />
<th>{{i18n "groups.requests.reason"}}</th> <div class="directory-table__column-header">{{i18n
<th></th> "groups.requests.reason"
<th></th> }}</div>
</thead> <div class="directory-table__column-header"></div>
</:header>
<tbody> <:body>
{{#each this.model.requesters as |m|}} {{#each this.model.requesters as |m|}}
<tr> <div class="directory-table__row">
<td class="group-member"> <div class="directory-table__cell group-member">
<UserInfo @user={{m}} @skipName={{this.skipName}} /> <UserInfo @user={{m}} @skipName={{this.skipName}} />
</td> </div>
<td> <div class="directory-table__cell">
<span class="text">{{bound-date m.requested_at}}</span> <span class="directory-table__label">
</td> <span>{{i18n "groups.member_requested"}}</span>
<td>{{m.reason}}</td> </span>
<td> <span class="directory-table__value">
<span>{{bound-date m.requested_at}}</span>
</span>
</div>
<div class="directory-table__cell">
<span class="directory-table__label">
<span>{{i18n "groups.requests.reason"}}</span>
</span>
<span class="directory-table__value">
{{m.reason}}
</span>
</div>
<div class="directory-table__cell group-accept-deny-buttons">
{{#if m.request_undone}} {{#if m.request_undone}}
{{i18n "groups.requests.undone"}} {{i18n "groups.requests.undone"}}
{{else if m.request_accepted}} {{else if m.request_accepted}}
@ -67,17 +83,14 @@
@class="btn-danger" @class="btn-danger"
/> />
{{/if}} {{/if}}
</td> </div>
<td></td> </div>
</tr>
{{/each}} {{/each}}
</tbody> </:body>
</table> </ResponsiveTable>
</LoadMore> </LoadMore>
<ConditionalLoadingSpinner @condition={{this.loading}} /> <ConditionalLoadingSpinner @condition={{this.loading}} />
{{else}} {{else}}
<div>{{i18n "groups.empty.requests"}}</div> <div>{{i18n "groups.empty.requests"}}</div>
{{/if}} {{/if}}
</section> </section>

View File

@ -1,37 +0,0 @@
<UserInfo @user={{this.item.user}} />
{{#each this.columns as |column|}}
{{#if (directory-column-is-user-field column=column)}}
{{#if (get this.item.user.user_fields column.user_field_id)}}
<div class="user-stat">
<span class="value user-field">
{{directory-item-user-field-value item=this.item column=column}}
</span>
<span class="label">
{{column.name}}
</span>
</div>
{{/if}}
{{else}}
<div class="user-stat">
<span class="value">
{{directory-item-value item=this.item column=column}}
</span>
<span class="label">
{{#if column.icon}}
{{d-icon column.icon}}
{{/if}}
{{mobile-directory-item-label item=this.item column=column}}
</span>
</div>
{{/if}}
{{/each}}
{{#if this.showTimeRead}}
<UserStat
@value={{this.item.time_read}}
@label="directory.time_read"
@type="duration"
/>
{{/if}}

View File

@ -1,80 +0,0 @@
<LoadMore @selector=".directory .user" @action={{action "loadMore"}}>
<div class="container">
<div class="users-directory directory">
<span>
<PluginOutlet
@name="users-top"
@connectorTagName="div"
@outletArgs={{hash model=this.model}}
/>
</span>
<div class="directory-controls">
<PeriodChooser
@period={{this.period}}
@onChange={{action (mut this.period)}}
@fullDay={{false}}
/>
{{#if this.lastUpdatedAt}}
<div class="directory-last-updated">
{{i18n "directory.last_updated"}}
{{this.lastUpdatedAt}}
</div>
{{/if}}
<div class="inline-form full-width">
<Input
@value={{readonly this.nameInput}}
placeholder={{i18n "directory.filter_name"}}
class="filter-name no-blur"
{{on
"input"
(action "onUsernameFilterChanged" value="target.value")
}}
/>
<ComboBox
@class="directory-group-selector"
@value={{this.group}}
@content={{this.groupOptions}}
@onChange={{action this.groupChanged}}
@options={{hash none="directory.group.all"}}
/>
{{#if this.currentUser.staff}}
<DButton
@icon="wrench"
@action={{action "showEditColumnsModal"}}
@class="btn-default open-edit-columns-btn"
/>
{{/if}}
</div>
<PluginOutlet
@name="users-directory-controls"
@outletArgs={{hash model=this.model}}
/>
</div>
<ConditionalLoadingSpinner @condition={{this.isLoading}}>
{{#if this.model.length}}
<div class="total-rows">{{i18n
"directory.total_rows"
count=this.model.totalRows
}}</div>
{{#each this.model as |item|}}
<DirectoryItem
@tagName="div"
@class="user"
@item={{item}}
@columns={{this.columns}}
@showTimeRead={{this.showTimeRead}}
/>
{{/each}}
<ConditionalLoadingSpinner @condition={{this.model.loadingMore}} />
{{else}}
<div class="clearfix"></div>
<p>{{i18n "directory.no_results"}}</p>
{{/if}}
</ConditionalLoadingSpinner>
</div>
</div>
</LoadMore>

View File

@ -68,7 +68,8 @@ acceptance("Admin - Users List", function (needs) {
await click(".hide-emails"); await click(".hide-emails");
assert.strictEqual( assert.strictEqual(
query(".users-list .user:nth-child(1) .email").innerText, query(".users-list .user:nth-child(1) .email .directory-table__value")
.innerText,
"", "",
"hides the emails" "hides the emails"
); );

View File

@ -20,7 +20,7 @@ acceptance("Group Members - Anonymous", function () {
1, 1,
"it displays the group's avatar flair" "it displays the group's avatar flair"
); );
assert.ok(exists(".group-members tr"), "it lists group members"); assert.ok(exists(".group-members .group-member"), "it lists group members");
assert.ok( assert.ok(
!exists(".group-member-dropdown"), !exists(".group-member-dropdown"),
@ -137,7 +137,7 @@ acceptance("Group Members", function (needs) {
); );
await click("button.bulk-select"); await click("button.bulk-select");
await click(".bulk-select-buttons button:nth-child(1)"); await click(".bulk-select-all");
assert.ok( assert.ok(
exists(".bulk-select-buttons-wrap details"), exists(".bulk-select-buttons-wrap details"),

View File

@ -12,7 +12,7 @@ acceptance("Managing Group Category Notification Defaults", function () {
await visit("/g/discourse/manage/categories"); await visit("/g/discourse/manage/categories");
assert.ok( assert.ok(
exists(".group-members tr"), exists(".group-members .group-member"),
"it should redirect to members page for an anonymous user" "it should redirect to members page for an anonymous user"
); );
}); });

View File

@ -12,7 +12,7 @@ acceptance("Managing Group Profile", function () {
await visit("/g/discourse/manage/profile"); await visit("/g/discourse/manage/profile");
assert.ok( assert.ok(
exists(".group-members tr"), exists(".group-members .group-member"),
"it should redirect to members page for an anonymous user" "it should redirect to members page for an anonymous user"
); );
}); });

View File

@ -12,7 +12,7 @@ acceptance("Managing Group Tag Notification Defaults", function () {
await visit("/g/discourse/manage/tags"); await visit("/g/discourse/manage/tags");
assert.ok( assert.ok(
exists(".group-members tr"), exists(".group-members .group-member"),
"it should redirect to members page for an anonymous user" "it should redirect to members page for an anonymous user"
); );
}); });

View File

@ -89,37 +89,49 @@ acceptance("Group Requests", function (needs) {
test("Group Requests", async function (assert) { test("Group Requests", async function (assert) {
await visit("/g/Macdonald/requests"); await visit("/g/Macdonald/requests");
assert.strictEqual(count(".group-members tr"), 2); assert.strictEqual(count(".group-members .group-member"), 2);
assert.strictEqual( assert.strictEqual(
query(".group-members tr:first-child td:nth-child(1)") query(".group-members .directory-table__row:first-child .user-detail")
.innerText.trim() .innerText.trim()
.replace(/\s+/g, " "), .replace(/\s+/g, " "),
"eviltrout Robin Ward" "eviltrout Robin Ward"
); );
assert.strictEqual( assert.strictEqual(
query(".group-members tr:first-child td:nth-child(3)").innerText.trim(), query(
".group-members .directory-table__row:first-child .directory-table__cell:nth-child(3)"
).innerText.trim(),
"Please accept my membership request." "Please accept my membership request."
); );
assert.strictEqual( assert.strictEqual(
query(".group-members tr:first-child .btn-primary").innerText.trim(), query(
".group-members .directory-table__row:first-child .btn-primary"
).innerText.trim(),
"Accept" "Accept"
); );
assert.strictEqual( assert.strictEqual(
query(".group-members tr:first-child .btn-danger").innerText.trim(), query(
".group-members .directory-table__row:first-child .btn-danger"
).innerText.trim(),
"Deny" "Deny"
); );
await click(".group-members tr:first-child .btn-primary"); await click(
".group-members .directory-table__row:first-child .btn-primary"
);
assert.ok( assert.ok(
query(".group-members tr:first-child td:nth-child(4)") query(
".group-members .directory-table__row:first-child .directory-table__cell:nth-child(4)"
)
.innerText.trim() .innerText.trim()
.startsWith("accepted") .startsWith("accepted")
); );
assert.deepEqual(requests, [["19", "true"]]); assert.deepEqual(requests, [["19", "true"]]);
await click(".group-members tr:last-child .btn-danger"); await click(".group-members .directory-table__row:last-child .btn-danger");
assert.strictEqual( assert.strictEqual(
query(".group-members tr:last-child td:nth-child(4)").innerText.trim(), query(
".group-members .directory-table__row:last-child .directory-table__cell:nth-child(4)"
).innerText.trim(),
"denied" "denied"
); );
assert.deepEqual(requests, [ assert.deepEqual(requests, [

View File

@ -7,6 +7,9 @@ acceptance("User Directory - Mobile", function (needs) {
test("Visit Page", async function (assert) { test("Visit Page", async function (assert) {
await visit("/u"); await visit("/u");
assert.ok(exists(".directory .user"), "has a list of users"); assert.ok(
exists(".directory .directory-table__row"),
"has a list of users"
);
}); });
}); });

View File

@ -14,7 +14,10 @@ acceptance("User Directory", function () {
document.body.classList.contains("users-page"), document.body.classList.contains("users-page"),
"has the body class" "has the body class"
); );
assert.ok(exists(".directory table tr"), "has a list of users"); assert.ok(
exists(".directory .directory-table .directory-table__row"),
"has a list of users"
);
}); });
test("Visit All Time", async function (assert) { test("Visit All Time", async function (assert) {
@ -28,7 +31,10 @@ acceptance("User Directory", function () {
document.body.classList.contains("users-page"), document.body.classList.contains("users-page"),
"has the body class" "has the body class"
); );
assert.ok(exists(".directory table tr"), "has a list of users"); assert.ok(
exists(".directory .directory-table .directory-table__row"),
"has a list of users"
);
}); });
test("Visit With Group Filter", async function (assert) { test("Visit With Group Filter", async function (assert) {
@ -37,27 +43,27 @@ acceptance("User Directory", function () {
document.body.classList.contains("users-page"), document.body.classList.contains("users-page"),
"has the body class" "has the body class"
); );
assert.ok(exists(".directory table tr"), "has a list of users"); assert.ok(
exists(".directory .directory-table .directory-table__row"),
"has a list of users"
);
}); });
test("Custom user fields are present", async function (assert) { test("Custom user fields are present", async function (assert) {
await visit("/u"); await visit("/u");
const firstRow = query(".users-directory table tr"); const firstRowUserField = query(
const columnData = firstRow.querySelectorAll("td"); ".directory .directory-table__body .directory-table__row:first-child .directory-table__value--user-field"
const favoriteColorTd = columnData[columnData.length - 1];
assert.strictEqual(
favoriteColorTd.querySelector("span").textContent,
"Blue"
); );
assert.strictEqual(firstRowUserField.textContent, "Blue");
}); });
test("Can sort table via keyboard", async function (assert) { test("Can sort table via keyboard", async function (assert) {
await visit("/u"); await visit("/u");
const secondHeading = const secondHeading =
".users-directory table th:nth-child(2) .header-contents"; ".users-directory .directory-table__header div:nth-child(2) .header-contents";
await triggerKeyEvent(secondHeading, "keypress", "Enter"); await triggerKeyEvent(secondHeading, "keypress", "Enter");

View File

@ -802,19 +802,15 @@ section.details {
} }
} }
tr.not-activated { .directory-table {
td, .not-activated {
td a, .directory-table__cell {
td a:visited { &,
color: #bbb; a,
} a:visited {
} color: #bbb;
}
.details.not-activated { }
.username .value,
.email .value a,
.email .value a:visited {
color: #bbb;
} }
} }

View File

@ -99,49 +99,55 @@
} }
.admin-users-list { .admin-users-list {
td.username { .directory-table__cell {
@include ellipsis; &.username {
overflow-wrap: break-word; justify-content: start;
}
@media screen and (max-width: 970px) and (min-width: 768px) {
td.username {
max-width: 23vw; // Prevents horizontal scroll down to 768px
} }
td.email { &.email {
max-width: 28vw; // Prevents horizontal scroll down to 768px justify-content: start;
overflow-wrap: break-word; span {
display: flex;
min-width: 17em;
word-break: break-all;
}
} }
} }
@media screen and (max-width: 767px) {
tr {
td.username {
grid-column-start: 1;
grid-column-end: -2;
font-weight: bold;
}
td.user-status {
text-align: right;
grid-row: 1;
grid-column-end: -1;
.d-icon {
margin-left: 0.25em;
}
}
td.email {
grid-column-start: 1;
grid-column-end: -1;
word-wrap: break-word;
overflow-wrap: break-word;
overflow: hidden;
min-width: 0;
margin: 0.5em 0 0 0;
&:empty { .directory-table {
display: none; margin-top: 1em;
&__column-header--username,
&__column-header--email {
.header-contents {
text-align: left;
}
}
&__cell.username {
align-items: center;
}
&__cell.email {
@include breakpoint("tablet") {
grid-column-start: 1;
grid-column-end: -1;
span {
max-width: 100%;
} }
} }
} }
} }
.directory-table__cell {
padding: 0.5em 0.25em;
}
.user-status span {
gap: 0.15em;
}
.avatar {
margin-right: 0.25em;
}
} }
// mobile styles // mobile styles

View File

@ -1,16 +1,11 @@
.directory-table-top-scroll {
width: 100%;
overflow-x: auto;
}
.directory { .directory {
margin-bottom: 100px; margin-bottom: 100px;
.directory-table-container {
width: 100%;
overflow-x: auto;
}
.directory-table-top-scroll {
width: 100%;
overflow-x: auto;
}
&.users-directory { &.users-directory {
.directory-group-selector { .directory-group-selector {
vertical-align: top; vertical-align: top;
@ -39,58 +34,6 @@
color: var(--primary-medium); color: var(--primary-medium);
font-size: var(--font-down-1); font-size: var(--font-down-1);
} }
table {
width: 100%;
margin-bottom: 1em;
td,
th {
padding: 0.5em;
text-align: left;
border-bottom: 1px solid var(--primary-low);
@media screen and (max-width: $small-width) {
padding: 0.5em 0.25em;
}
.number,
.time-read {
font-size: var(--font-up-3);
color: var(--primary-medium);
@media screen and (max-width: $small-width) {
font-size: var(--font-up-1);
}
}
.time-read {
white-space: nowrap;
}
.user-field-value {
font-size: var(--font-up-1);
color: var(--primary-medium);
@media screen and (max-width: $small-width) {
font-size: var(--font-0);
}
}
}
th.sortable {
width: 13%;
.d-icon-heart {
color: var(--love);
margin: 0 0.25em 0 0;
}
}
}
.me {
background-color: var(--highlight-bg);
.username a,
.name a,
.title,
.number,
.time-read {
color: var(--primary-medium);
}
}
} }
.edit-user-directory-columns-modal { .edit-user-directory-columns-modal {
@ -139,3 +82,226 @@
.edit-user-directory-columns-modal .modal-inner-container { .edit-user-directory-columns-modal .modal-inner-container {
min-width: 450px; min-width: 450px;
} }
@container (min-width: 47em) {
.users-directory {
.directory-table {
&__value {
white-space: nowrap;
font-size: var(--font-up-2);
&,
&--user-field {
color: var(--primary-medium);
}
}
}
}
}
.directory-table-container {
container-type: inline-size;
container-name: directory-table;
}
.directory-table {
display: grid;
gap: 0;
width: 100%;
margin-top: 1em;
overflow-x: auto;
.me {
.directory-table__cell {
&,
&--user-field {
background-color: var(--highlight-low-or-medium);
}
}
}
&__header,
&__body,
&__row {
display: contents; // we'll be able to remove this with subgrid support
}
&__column-header,
&__cell,
&__cell--user-field {
display: flex;
border-bottom: 1px solid var(--primary-low);
justify-content: center;
align-items: center;
}
&__column-header {
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
color: var(--primary-medium);
padding: 0.5em;
.d-icon {
margin-right: 0.25em;
}
&:first-child {
.header-contents {
text-align: left;
}
}
}
&__cell {
&,
&--user-field {
padding: 0.75em 0.5em;
}
}
&__value {
white-space: nowrap;
&--user-field {
max-width: 30em;
}
}
&__label {
display: none;
}
.d-icon-heart {
font-size: var(--font-down-1);
color: var(--love);
}
.user-detail {
display: flex;
flex-direction: column;
min-width: 0; // allow content to shrink and hide overflow
}
.user-info {
display: flex;
min-width: 0;
margin: 0;
width: 100%;
.user-image {
padding-right: 0.5em;
margin-right: 0.5em;
}
.user-detail {
padding: 0;
width: 100%;
@media screen and (max-width: 600px) {
// overrides existing media query
font-size: var(--font-0);
}
@include breakpoint("mobile-medium") {
font-size: var(--font-down-1);
}
}
.title {
margin: 0;
}
}
.header-contents {
width: 100%;
text-align: center;
}
}
// using a container query to switch to a flex-based layout
// browsers without support for container queries
// fallback to big horizontal scrolling table
@container (max-width: 47em) {
.directory-table {
display: flex;
flex-direction: column;
.me {
background-color: var(--highlight-low-or-medium);
}
&__label {
display: inline-flex;
color: var(--primary-medium);
padding-right: 0.5em;
align-items: baseline;
align-self: start;
white-space: nowrap;
overflow: hidden;
span {
// caution: display flex here can interfere with overflow hiding
flex: 0 1 auto; // can shrink if needed
margin-right: 0.25em;
@include ellipsis;
}
// flexible divider between the label and value
&:after {
flex: 1 1 0; // can grow or shrink, but should be 0 width if needed
color: var(--primary-300);
min-width: 0;
overflow: hidden;
// this needs to be long to account for all possible widths
content: "................................................................................................................................................................";
}
.d-icon {
font-size: 0.8em;
vertical-align: baseline;
}
}
&__value {
font-size: var(--font-0);
color: var(--primary);
}
&__row {
&:first-child {
border-top: 1px solid var(--primary-low);
}
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11em, 1fr));
border-bottom: 1px solid var(--primary-low);
padding: 0.85em 0.75em 1em;
gap: 0 15%;
}
&__header {
display: none;
}
&__cell {
&,
&--user-field {
padding: 0.25em;
border: none;
&:first-child {
width: 100%;
padding: 0.5em 0.25em 1em;
justify-content: start;
// force full width of the cell
grid-column-start: 1;
grid-column-end: -1;
}
}
&--user-field {
order: 2;
// force full width of the cell
// because we don't know how much content there is
grid-column-start: 1;
grid-column-end: -1;
.directory-table__label {
margin-right: 0.25em;
}
}
}
}
}

View File

@ -25,15 +25,27 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
width: 100%; width: 100%;
gap: 0.5em 0;
input + .group-members-manage { .bulk-select + input {
margin-left: auto; margin-left: 0.5em;
} }
.group-username-filter { input {
margin: 0 0 5px 0; margin: 0 auto 0 0;
vertical-align: middle;
} }
.bulk-select-buttons-wrap {
margin-right: 0.5em;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
}
}
.group-members-manage {
display: flex;
gap: 0.5em;
} }
.group-info { .group-info {
@ -118,58 +130,45 @@ table.group-manage-logs {
} }
} }
table.group-members { .group-members {
width: 100%; grid-template-columns: 3fr repeat(3, minmax(min-content, 1fr));
th { &--can-manage {
text-align: center; grid-template-columns: 3fr repeat(4, minmax(min-content, 1fr)) 3em;
@container (max-width: 47em) {
&.bulk-select { // positioning the member settings button within the same cell
height: 30px; // and avoiding overlap with padding-right on user-info
width: 30px; .group-member,
} .member-settings {
grid-row-start: 1;
&.bulk-select-buttons { grid-column-start: 1;
text-align: left; grid-column-end: -1;
white-space: nowrap;
width: 1%;
.bulk-select-buttons-wrap {
display: flex;
} }
.member-settings {
.btn { margin-left: auto;
margin-right: 0.25em; }
.user-info {
padding-right: 3.5em;
} }
}
&.username {
text-align: left;
} }
} }
td { &.group-members__requests {
color: var(--primary-medium); grid-template-columns: 3fr repeat(3, minmax(min-content, 1fr));
padding: 0.8em 0;
text-align: center;
&.group-member {
text-align: left;
}
} }
.user-info { .directory-table__value {
display: block; font-size: var(--font-0);
color: var(--primary);
}
.avatar-flair { .group-accept-deny-buttons {
color: var(--primary); gap: 0.5em;
} }
.user-status-message { @container (max-width: 47em) {
img.emoji { .directory-table__cell.group-owner {
width: 1em; order: 2;
height: 1em;
}
} }
} }
} }

View File

@ -21,35 +21,6 @@
color: var(--primary-medium); color: var(--primary-medium);
padding: 5px; padding: 5px;
} }
.user {
border-top: 1px solid var(--primary-low);
padding: 1em;
display: flex;
flex-wrap: wrap;
.user-info {
width: 100%;
margin-bottom: 1em;
}
.user-stat {
flex: 1 1 50%;
.value {
font-weight: bold;
&.user-field {
font-size: var(--font-down-1);
}
}
.label {
margin-left: 0.2em;
color: var(--primary-medium);
}
.d-icon-heart {
color: var(--love);
}
}
}
} }
.edit-user-directory-columns-modal .modal-inner-container { .edit-user-directory-columns-modal .modal-inner-container {

View File

@ -923,6 +923,7 @@ en:
make_all_primary_description: "Make this the primary group for all selected users" make_all_primary_description: "Make this the primary group for all selected users"
remove_all_primary: "Remove as Primary" remove_all_primary: "Remove as Primary"
remove_all_primary_description: "Remove this group as primary" remove_all_primary_description: "Remove this group as primary"
status: "Status"
owner: "Owner" owner: "Owner"
primary: "Primary" primary: "Primary"
forbidden: "You're not allowed to view the members." forbidden: "You're not allowed to view the members."
@ -5629,6 +5630,7 @@ en:
not_found: "Sorry, that username doesn't exist in our system." not_found: "Sorry, that username doesn't exist in our system."
id_not_found: "Sorry, that user id doesn't exist in our system." id_not_found: "Sorry, that user id doesn't exist in our system."
active: "Activated" active: "Activated"
status: "Status"
show_emails: "Show Emails" show_emails: "Show Emails"
hide_emails: "Hide Emails" hide_emails: "Hide Emails"
nav: nav: