UX: Automatically collapse admin page header buttons on mobile ()

This commit attempts to improve the mobile experience for
admin page header and subheader by automatically collapsing
all action buttons in these components into a DMenu when viewing
mobile.

This is done by using different "list" wrapper components and a
DMenu trigger and a DropdownMenu on mobile only, and uses has-block
to determine whether to render the DMenu trigger at all.

This also removes the `PluginOutlet` in `AdminPluginConfigPage`, it
was too inflexible for this `DropdownMenu` case, and since the `:actions`
were always rendering we couldn't rely on `has-block`. A new plugin API,
`registerPluginHeaderActionComponent`, has been introduced instead to
replace it.
This commit is contained in:
Martin Brennan 2024-10-08 08:28:32 +10:00 committed by GitHub
parent 4ea3d69979
commit 85774cc214
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 406 additions and 56 deletions
app/assets
plugins/chat
admin/assets/javascripts
admin/components
discourse/templates/admin-plugins/show/discourse-chat-incoming-webhooks
assets/javascripts/discourse/initializers

@ -74,14 +74,13 @@ export default class AdminBackupsActions extends Component {
@action={{routeAction "rollback"}}
@label="admin.backups.operations.rollback.label"
@title="admin.backups.operations.rollback.title"
@icon="truck-medical"
@disabled={{this.rollbackDisabled}}
@icon="truck-medical"
class="admin-backups__rollback"
/>
{{/if}}
<@actions.Default
@icon={{if this.site.isReadOnly "far-eye-slash" "far-eye"}}
@action={{this.toggleReadOnlyMode}}
@disabled={{@backups.isOperationRunning}}
@title={{if
@ -94,6 +93,7 @@ export default class AdminBackupsActions extends Component {
"admin.backups.read_only.disable.label"
"admin.backups.read_only.enable.label"
}}
@icon={{if this.site.isReadOnly "far-eye-slash" "far-eye"}}
class="admin-backups__toggle-read-only"
/>
</template>

@ -1,3 +1,4 @@
import { hash } from "@ember/helper";
import DButton from "discourse/components/d-button";
export const AdminPageActionButton = <template>
@ -13,6 +14,18 @@ export const AdminPageActionButton = <template>
@isLoading={{@isLoading}}
/>
</template>;
// This is used for cases where there is another component,
// e.g. UppyBackupUploader, that is a button which cannot use
// PrimaryButton and so on directly. This should be used very rarely,
// most cases are covered by the other button types.
export const WrappedButton = <template>
<span class="admin-page-action-wrapped-button">{{yield}}</span>
</template>;
// No buttons here pass in an @icon by design. They are okay to
// use on dropdown list items, but our UI guidelines do not allow them
// on regular buttons.
export const PrimaryButton = <template>
<AdminPageActionButton
class="btn-primary"
@ -22,10 +35,10 @@ export const PrimaryButton = <template>
@routeModels={{@routeModels}}
@label={{@label}}
@title={{@title}}
@icon={{@icon}}
@isLoading={{@isLoading}}
/>
</template>;
export const DangerButton = <template>
<AdminPageActionButton
class="btn-danger"
@ -35,12 +48,55 @@ export const DangerButton = <template>
@routeModels={{@routeModels}}
@label={{@label}}
@title={{@title}}
@icon={{@icon}}
@isLoading={{@isLoading}}
/>
</template>;
export const DefaultButton = <template>
<AdminPageActionButton
class="btn-default"
...attributes
@action={{@action}}
@route={{@route}}
@routeModels={{@routeModels}}
@label={{@label}}
@title={{@title}}
@isLoading={{@isLoading}}
/>
</template>;
export const AdminPageActionListItem = <template>
<li class="dropdown-menu__item admin-page-action-list-item">
<AdminPageActionButton
class="btn-transparent"
...attributes
@action={{@action}}
@route={{@route}}
@routeModels={{@routeModels}}
@label={{@label}}
@title={{@title}}
@icon={{@icon}}
@isLoading={{@isLoading}}
/>
</li>
</template>;
// This is used for cases where there is another component,
// e.g. UppyBackupUploader, that is a button which cannot use
// PrimaryActionListItem and so on directly. This should be used very rarely,
// most cases are covered by the other list types.
export const WrappedActionListItem = <template>
<li
class="dropdown-menu__item admin-page-action-list-item admin-page-action-wrapped-list-item"
>
{{yield (hash buttonClass="btn-transparent")}}
</li>
</template>;
// It is not a mistake that `btn-default` is used here, in a list
// there is no need for blue text.
export const PrimaryActionListItem = <template>
<AdminPageActionListItem
class="btn-default"
...attributes
@action={{@action}}
@ -52,3 +108,31 @@ export const DefaultButton = <template>
@isLoading={{@isLoading}}
/>
</template>;
export const DefaultActionListItem = <template>
<AdminPageActionListItem
class="btn-default"
...attributes
@action={{@action}}
@route={{@route}}
@routeModels={{@routeModels}}
@label={{@label}}
@title={{@title}}
@icon={{@icon}}
@isLoading={{@isLoading}}
/>
</template>;
export const DangerActionListItem = <template>
<AdminPageActionListItem
class="btn-danger"
...attributes
@action={{@action}}
@route={{@route}}
@routeModels={{@routeModels}}
@label={{@label}}
@title={{@title}}
@icon={{@icon}}
@isLoading={{@isLoading}}
/>
</template>;

@ -1,17 +1,28 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { or } from "truth-helpers";
import DBreadcrumbsContainer from "discourse/components/d-breadcrumbs-container";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DropdownMenu from "discourse/components/dropdown-menu";
import HorizontalOverflowNav from "discourse/components/horizontal-overflow-nav";
import i18n from "discourse-common/helpers/i18n";
import {
DangerActionListItem,
DangerButton,
DefaultActionListItem,
DefaultButton,
PrimaryActionListItem,
PrimaryButton,
WrappedActionListItem,
WrappedButton,
} from "admin/components/admin-page-action-button";
import DMenu from "float-kit/components/d-menu";
export default class AdminPageHeader extends Component {
@service site;
get title() {
if (this.args.titleLabelTranslated) {
return this.args.titleLabelTranslated;
@ -41,14 +52,54 @@ export default class AdminPageHeader extends Component {
<h1 class="admin-page-header__title">{{this.title}}</h1>
{{/if}}
<div class="admin-page-header__actions">
{{yield
(hash
Primary=PrimaryButton Default=DefaultButton Danger=DangerButton
)
to="actions"
}}
</div>
{{#if (or (has-block "actions") @headerActionComponent)}}
<div class="admin-page-header__actions">
{{#if this.site.mobileView}}
<DMenu
@identifier="admin-page-header-mobile-actions"
@title={{i18n "more_options"}}
@icon="ellipsis-vertical"
class="btn-small"
>
<:content>
<DropdownMenu class="admin-page-header__mobile-actions">
{{#let
(hash
Primary=PrimaryActionListItem
Default=DefaultActionListItem
Danger=DangerActionListItem
Wrapped=WrappedActionListItem
)
as |actions|
}}
{{#if (has-block "actions")}}
{{yield actions to="actions"}}
{{else}}
<@headerActionComponent @actions={{actions}} />
{{/if}}
{{/let}}
</DropdownMenu>
</:content>
</DMenu>
{{else}}
{{#let
(hash
Primary=PrimaryButton
Default=DefaultButton
Danger=DangerButton
Wrapped=WrappedButton
)
as |actions|
}}
{{#if (has-block "actions")}}
{{yield actions to="actions"}}
{{else}}
<@headerActionComponent @actions={{actions}} />
{{/if}}
{{/let}}
{{/if}}
</div>
{{/if}}
</div>
{{#if this.description}}

@ -1,14 +1,24 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DropdownMenu from "discourse/components/dropdown-menu";
import i18n from "discourse-common/helpers/i18n";
import {
DangerActionListItem,
DangerButton,
DefaultActionListItem,
DefaultButton,
PrimaryActionListItem,
PrimaryButton,
WrappedActionListItem,
WrappedButton,
} from "admin/components/admin-page-action-button";
import DMenu from "float-kit/components/d-menu";
export default class AdminPageSubheader extends Component {
@service site;
get title() {
if (this.args.titleLabelTranslated) {
return this.args.titleLabelTranslated;
@ -29,14 +39,42 @@ export default class AdminPageSubheader extends Component {
<div class="admin-page-subheader">
<div class="admin-page-subheader__title-row">
<h3 class="admin-page-subheader__title">{{this.title}}</h3>
<div class="admin-page-subheader__actions">
{{yield
(hash
Primary=PrimaryButton Default=DefaultButton Danger=DangerButton
)
to="actions"
}}
</div>
{{#if (has-block "actions")}}
<div class="admin-page-subheader__actions">
{{#if this.site.mobileView}}
<DMenu
@identifier="admin-page-subheader-mobile-actions"
@title={{i18n "more_options"}}
@icon="ellipsis-vertical"
class="btn-small"
>
<:content>
<DropdownMenu class="admin-page-subheader__mobile-actions">
{{yield
(hash
Primary=PrimaryActionListItem
Default=DefaultActionListItem
Danger=DangerActionListItem
Wrapped=WrappedActionListItem
)
to="actions"
}}
</DropdownMenu>
</:content>
</DMenu>
{{else}}
{{yield
(hash
Primary=PrimaryButton
Default=DefaultButton
Danger=DangerButton
Wrapped=WrappedButton
)
to="actions"
}}
{{/if}}
</div>
{{/if}}
</div>
{{#if this.description}}

@ -1,9 +1,8 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { service } from "@ember/service";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import NavItem from "discourse/components/nav-item";
import PluginOutlet from "discourse/components/plugin-outlet";
import { headerActionComponentForPlugin } from "discourse/lib/admin-plugin-header-actions";
import i18n from "discourse-common/helpers/i18n";
import AdminPageHeader from "./admin-page-header";
import AdminPluginConfigArea from "./admin-plugin-config-area";
@ -25,7 +24,11 @@ export default class AdminPluginConfigPage extends Component {
}
get actionsOutletName() {
return `admin-plugin-config-page-actions-${this.args.plugin.kebabCaseName}`;
return `admin-plugin-config-page-actions-${this.args.plugin.dasherizedName}`;
}
get headerActionComponent() {
return headerActionComponentForPlugin(this.args.plugin.dasherizedName);
}
linkText(navLink) {
@ -42,6 +45,7 @@ export default class AdminPluginConfigPage extends Component {
@titleLabelTranslated={{@plugin.nameTitleized}}
@descriptionLabelTranslated={{@plugin.about}}
@learnMoreUrl={{@plugin.linkUrl}}
@headerActionComponent={{this.headerActionComponent}}
>
<:breadcrumbs>
@ -71,14 +75,6 @@ export default class AdminPluginConfigPage extends Component {
{{/each}}
{{/if}}
</:tabs>
<:actions as |actions|>
<div class={{this.actionsOutletName}}>
<PluginOutlet
@name={{this.actionsOutletName}}
@outletArgs={{hash plugin=@plugin actions=actions}}
/>
</div>
</:actions>
</AdminPageHeader>
<div class="admin-plugin-config-page__content">

@ -1,5 +1,5 @@
import { cached, tracked } from "@glimmer/tracking";
import { capitalize } from "@ember/string";
import { capitalize, dasherize } from "@ember/string";
import { snakeCaseToCamelCase } from "discourse-common/lib/case-converter";
import I18n from "discourse-i18n";
@ -24,8 +24,8 @@ export default class AdminPlugin {
return this.name.replaceAll("-", "_");
}
get kebabCaseName() {
return this.name.replaceAll(" ", "-").replaceAll("_", "-");
get dasherizedName() {
return dasherize(this.name);
}
get translatedCategoryName() {

@ -1,13 +1,19 @@
<AdminPageSubheader @titleLabel="admin.backups.files_title">
<:actions>
{{#if this.localBackupStorage}}
<UppyBackupUploader
@done={{route-action "uploadSuccess"}}
@localBackupStorage={{this.localBackupStorage}}
/>
{{else}}
<UppyBackupUploader @done={{route-action "remoteUploadSuccess"}} />
{{/if}}
<:actions as |actions|>
<actions.Wrapped as |wrapped|>
{{#if this.localBackupStorage}}
<UppyBackupUploader
class={{wrapped.buttonClass}}
@done={{route-action "uploadSuccess"}}
@localBackupStorage={{this.localBackupStorage}}
/>
{{else}}
<UppyBackupUploader
class={{wrapped.buttonClass}}
@done={{route-action "remoteUploadSuccess"}}
/>
{{/if}}
</actions.Wrapped>
</:actions>
</AdminPageSubheader>
@ -34,10 +40,9 @@
<tr class="backup-item-row" data-backup-filename={{backup.filename}}>
<td class="backup-filename">{{backup.filename}}</td>
<td class="backup-size">{{human-size backup.size}}</td>
<td class="backup-controls">
<td class="backup-controls admin-table-row-controls">
<DButton
@action={{fn this.download backup}}
@icon="download"
@title="admin.backups.operations.download.title"
@label="admin.backups.operations.download.label"
class="btn-default btn-small backup-item-row__download"

@ -2,8 +2,9 @@
class="btn btn-small btn-primary admin-backups-upload"
disabled={{this.uploading}}
title={{i18n "admin.backups.upload.title"}}
...attributes
>
{{d-icon "upload"}}{{this.uploadButtonText}}
{{this.uploadButtonText}}
<input
class="hidden-upload-field"
disabled={{this.uploading}}

@ -0,0 +1,13 @@
let pluginHeaderActionComponents = new Map();
export function registerPluginHeaderActionComponent(pluginId, componentClass) {
pluginHeaderActionComponents.set(pluginId, componentClass);
}
export function clearPluginHeaderActionComponents() {
pluginHeaderActionComponents = new Map();
}
export function headerActionComponentForPlugin(pluginId) {
return pluginHeaderActionComponents.get(pluginId);
}

@ -61,6 +61,7 @@ import {
PLUGIN_NAV_MODE_TOP,
registerAdminPluginConfigNav,
} from "discourse/lib/admin-plugin-config-nav";
import { registerPluginHeaderActionComponent } from "discourse/lib/admin-plugin-header-actions";
import classPrepend, {
withPrependsRolledBack,
} from "discourse/lib/class-prepend";
@ -3252,6 +3253,24 @@ class PluginApi {
addLegacyAboutPageStat(name);
}
/**
* Registers a component class that will be rendered within the AdminPageHeader component
* only on plugins using the AdminPluginConfigPage and the new plugin "show" route.
*
* This component will be passed an `@actions` argument, with Primary, Default, Danger,
* and Wrapped keys, which can be used for various different types of buttons (Wrapped
* should be used only in very rare scenarios).
*
* This component would be used for actions that should be present on the entire UI
* for that plugin -- one example is "Create export" for chat.
*
* @param {string} pluginId - The `dasherizedName` of the plugin using this component.
* @param {Class} componentClass - The JS class of the component to render.
*/
registerPluginHeaderActionComponent(pluginId, componentClass) {
registerPluginHeaderActionComponent(pluginId, componentClass);
}
// eslint-disable-next-line no-unused-vars
#deprecatedWidgetOverride(widgetName, override) {
// insert here the code to handle widget deprecations, e.g. for the header widgets we used:

@ -35,6 +35,7 @@ import { clearHTMLCache } from "discourse/helpers/custom-html";
import { resetUsernameDecorators } from "discourse/helpers/decorate-username-selector";
import { resetBeforeAuthCompleteCallbacks } from "discourse/instance-initializers/auth-complete";
import { resetAdminPluginConfigNav } from "discourse/lib/admin-plugin-config-nav";
import { clearPluginHeaderActionComponents } from "discourse/lib/admin-plugin-header-actions";
import { rollbackAllPrepends } from "discourse/lib/class-prepend";
import { clearPopupMenuOptions } from "discourse/lib/composer/custom-popup-menu-options";
import { clearDesktopNotificationHandlers } from "discourse/lib/desktop-notifications";
@ -251,6 +252,7 @@ export function testCleanup(container, app) {
clearAboutPageActivities();
clearLegacyAboutPageStats();
resetWidgetCleanCallbacks();
clearPluginHeaderActionComponents();
}
function cleanupCssGeneratorTags() {

@ -2,10 +2,23 @@ import { click, render } from "@ember/test-helpers";
import { module, test } from "qunit";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import NavItem from "discourse/components/nav-item";
import { forceMobile } from "discourse/lib/mobile";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import i18n from "discourse-common/helpers/i18n";
import AdminPageHeader from "admin/components/admin-page-header";
const AdminPageHeaderActionsTestComponent = <template>
<div class="admin-page-header-actions-test-component">
<@actions.Default
@route="adminBadges.award"
@routeModels="new"
@icon="upload"
@label="admin.badges.mass_award.title"
class="award-badge"
/>
</div>
</template>;
module("Integration | Component | AdminPageHeader", function (hooks) {
setupRenderingTest(hooks);
@ -174,4 +187,60 @@ module("Integration | Component | AdminPageHeader", function (hooks) {
await click(".edit-groupings-btn");
assert.true(actionCalled);
});
test("@headerActionComponent is rendered with actions arg", async function (assert) {
await render(<template>
<AdminPageHeader
@headerActionComponent={{AdminPageHeaderActionsTestComponent}}
/>
</template>);
assert
.dom(".admin-page-header-actions-test-component .award-badge")
.exists();
});
});
module("Integration | Component | AdminPageHeader | Mobile", function (hooks) {
hooks.beforeEach(function () {
forceMobile();
});
setupRenderingTest(hooks);
test("action buttons become a dropdown on mobile", async function (assert) {
await render(<template>
<AdminPageHeader>
<:actions as |actions|>
<actions.Primary
@route="adminBadges.show"
@routeModels="new"
@icon="plus"
@label="admin.badges.new"
class="new-badge"
/>
<actions.Default
@route="adminBadges.award"
@routeModels="new"
@icon="upload"
@label="admin.badges.mass_award.title"
class="award-badge"
/>
</:actions>
</AdminPageHeader>
</template>);
assert
.dom(
".admin-page-header__actions .fk-d-menu__trigger.admin-page-header-mobile-actions-trigger"
)
.exists();
await click(".admin-page-header-mobile-actions-trigger");
assert
.dom(".dropdown-menu.admin-page-header__mobile-actions .new-badge")
.exists();
});
});

@ -1,5 +1,6 @@
import { click, render } from "@ember/test-helpers";
import { module, test } from "qunit";
import { forceMobile } from "discourse/lib/mobile";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import i18n from "discourse-common/helpers/i18n";
import AdminPageSubheader from "admin/components/admin-page-subheader";
@ -127,3 +128,50 @@ module("Integration | Component | AdminPageSubheader", function (hooks) {
assert.true(actionCalled);
});
});
module(
"Integration | Component | AdminPageSubheader | Mobile",
function (hooks) {
hooks.beforeEach(function () {
forceMobile();
});
setupRenderingTest(hooks);
test("action buttons become a dropdown on mobile", async function (assert) {
await render(<template>
<AdminPageSubheader>
<:actions as |actions|>
<actions.Primary
@route="adminBadges.show"
@routeModels="new"
@icon="plus"
@label="admin.badges.new"
class="new-badge"
/>
<actions.Default
@route="adminBadges.award"
@routeModels="new"
@icon="upload"
@label="admin.badges.mass_award.title"
class="award-badge"
/>
</:actions>
</AdminPageSubheader>
</template>);
assert
.dom(
".admin-page-subheader .fk-d-menu__trigger.admin-page-subheader-mobile-actions-trigger"
)
.exists();
await click(".admin-page-subheader-mobile-actions-trigger");
assert
.dom(".dropdown-menu.admin-page-subheader__mobile-actions .new-badge")
.exists();
});
}
);

@ -6,10 +6,6 @@
align-items: stretch;
margin-bottom: var(--space-2);
@media (max-width: $mobile-breakpoint) {
flex-direction: column;
}
h1,
h3 {
margin: 0;
@ -58,3 +54,33 @@
}
}
}
.admin-page-header {
&__title-row {
@media (max-width: $mobile-breakpoint) {
flex-direction: row;
align-items: center;
.admin-page-header__actions {
button {
margin-bottom: 0;
}
}
}
}
}
.admin-page-subheader {
&__title-row {
@media (max-width: $mobile-breakpoint) {
flex-direction: row;
align-items: center;
}
}
}
.admin-page-action-list-item {
.btn-primary {
color: var(--primary);
}
}

@ -97,7 +97,6 @@
}
.award-badge {
margin: 15px 0 0 15px;
float: left;
max-width: 70%;

@ -29,10 +29,11 @@ export default class ChatAdminPluginActions extends Component {
}
<template>
<@outletArgs.actions.Primary
<@actions.Primary
@label="chat.admin.export_messages.create_export"
@title="chat.admin.export_messages.create_export"
@action={{this.confirmExportMessages}}
@icon="right-from-bracket"
class="admin-chat-export"
/>
</template>

@ -14,6 +14,7 @@
@title="chat.incoming_webhooks.new"
@route="adminPlugins.show.discourse-chat-incoming-webhooks.new"
@routeModels="chat"
@icon="plus"
class="admin-incoming-webhooks-new"
/>
</:actions>

@ -19,10 +19,7 @@ export default {
},
]);
api.renderInOutlet(
"admin-plugin-config-page-actions-chat",
ChatAdminPluginActions
);
api.registerPluginHeaderActionComponent("chat", ChatAdminPluginActions);
});
},
};