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

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

View File

@ -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>

View File

@ -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>;

View File

@ -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}}
{{#if (or (has-block "actions") @headerActionComponent)}}
<div class="admin-page-header__actions">
{{yield
{{#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=PrimaryButton Default=DefaultButton Danger=DangerButton
Primary=PrimaryActionListItem
Default=DefaultActionListItem
Danger=DangerActionListItem
Wrapped=WrappedActionListItem
)
to="actions"
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}}

View File

@ -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>
{{#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=PrimaryButton Default=DefaultButton Danger=DangerButton
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}}

View File

@ -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">

View File

@ -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() {

View File

@ -1,13 +1,19 @@
<AdminPageSubheader @titleLabel="admin.backups.files_title">
<:actions>
<:actions as |actions|>
<actions.Wrapped as |wrapped|>
{{#if this.localBackupStorage}}
<UppyBackupUploader
class={{wrapped.buttonClass}}
@done={{route-action "uploadSuccess"}}
@localBackupStorage={{this.localBackupStorage}}
/>
{{else}}
<UppyBackupUploader @done={{route-action "remoteUploadSuccess"}} />
<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"

View File

@ -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}}

View File

@ -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);
}

View File

@ -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:

View File

@ -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() {

View File

@ -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();
});
});

View File

@ -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();
});
}
);

View File

@ -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);
}
}

View File

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

View File

@ -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>

View File

@ -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>

View File

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