Add help menu with dismissible badges

By default, the menu contains a link to a blog post
showcasing features in Wagtail 4.1 and a link to the editor guide.

We need to update the links and labels manually in the next release.
pull/9375/head^2
Sage Abdullah 2022-10-13 14:55:57 +01:00 zatwierdzone przez Thibaud Colas
rodzic 4e5f4ca4ec
commit 0a0e07abc0
10 zmienionych plików z 265 dodań i 12 usunięć

Wyświetl plik

@ -29,3 +29,30 @@
);
}
}
.w-dismissible-badge {
border-radius: theme('borderRadius.full');
background-color: theme('colors.warning.100');
flex-shrink: 0;
min-width: theme('spacing.[2.5]');
height: theme('spacing.[2.5]');
@media (forced-colors: active) {
border: 3px solid transparent;
box-sizing: content-box;
}
&--count {
color: theme('colors.primary.DEFAULT');
text-align: center;
font-size: 0.625rem;
font-weight: theme('fontWeight.bold');
min-width: theme('spacing.[3.5]');
height: theme('spacing.[3.5]');
line-height: theme('lineHeight.tight');
@media (prefers-reduced-motion: no-preference) {
animation: pulse-warning 5s 5;
}
}
}

Wyświetl plik

@ -3,6 +3,8 @@ import * as React from 'react';
import Tippy from '@tippyjs/react';
import Icon from '../../Icon/Icon';
import { MenuItemDefinition, MenuItemProps } from './MenuItem';
import { gettext } from '../../../utils/gettext';
import { isDismissed } from '../modules/MainMenu';
export const LinkMenuItem: React.FunctionComponent<
MenuItemProps<LinkMenuItemDefinition>
@ -11,12 +13,19 @@ export const LinkMenuItem: React.FunctionComponent<
const isActive = state.activePath.startsWith(path);
const isInSubMenu = path.split('.').length > 2;
const onClick = (e: React.MouseEvent) => {
const onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// Do not capture click events with modifier keys or non-main buttons.
if (e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button !== 0)) {
return;
}
if (!isDismissed(item, state)) {
dispatch({
type: 'set-dismissible-state',
item,
});
}
// For compatibility purposes – do not capture clicks for links with a target.
if (item.attrs.target) {
return;
@ -61,7 +70,14 @@ export const LinkMenuItem: React.FunctionComponent<
{item.iconName && (
<Icon name={item.iconName} className="icon--menuitem" />
)}
<span className="menuitem-label">{item.label}</span>
<div className="menuitem">
<span className="menuitem-label">{item.label}</span>
{!isDismissed(item, state) && (
<span className="w-dismissible-badge">
<span className="w-sr-only">{gettext('(New)')}</span>
</span>
)}
</div>
</a>
</Tippy>
</li>

Wyświetl plik

@ -25,7 +25,7 @@
color: $color-menu-text;
padding: 13px 15px 13px 20px;
font-weight: 400;
overflow: hidden;
overflow: visible;
// Note, font-weights lower than normal,
// and font-size smaller than 1em (80% ~= 12.8px),
@ -75,6 +75,17 @@
color: $color-white;
}
}
.w-dismissible-badge--count {
@apply w-absolute w-top-1 w-left-7;
text-shadow: none;
}
}
.menuitem {
display: flex;
justify-content: space-between;
width: 100%;
}
.menuitem-label {

Wyświetl plik

@ -8,7 +8,7 @@ describe('SubMenuItem', () => {
it('should render with the minimum required props', () => {
const wrapper = shallow(
<SubMenuItem
item={{ classNames: '', menuItems: [] }}
item={{ classNames: '', menuItems: [], attrs: {} }}
items={[]}
state={state}
path=".reports"
@ -24,7 +24,7 @@ describe('SubMenuItem', () => {
const wrapper = shallow(
<SubMenuItem
dispatch={dispatch}
item={{ classNames: '', menuItems: [] }}
item={{ classNames: '', menuItems: [], attrs: {} }}
items={[]}
state={state}
path=".reports"

Wyświetl plik

@ -3,10 +3,11 @@ import * as React from 'react';
import Tippy from '@tippyjs/react';
import Icon from '../../Icon/Icon';
import { renderMenu } from '../modules/MainMenu';
import { isDismissed, renderMenu } from '../modules/MainMenu';
import { SidebarPanel } from '../SidebarPanel';
import { SIDEBAR_TRANSITION_DURATION } from '../Sidebar';
import { MenuItemDefinition, MenuItemProps } from './MenuItem';
import { gettext } from '../../../utils/gettext';
interface SubMenuItemProps extends MenuItemProps<SubMenuItemDefinition> {
slim: boolean;
@ -39,6 +40,13 @@ export const SubMenuItem: React.FunctionComponent<SubMenuItemProps> = ({
}, [isOpen]);
const onClick = () => {
if (!isDismissed(item, state)) {
dispatch({
type: 'set-dismissible-state',
item,
});
}
if (isOpen) {
const pathComponents = path.split('.');
pathComponents.pop();
@ -64,6 +72,10 @@ export const SubMenuItem: React.FunctionComponent<SubMenuItemProps> = ({
'sidebar-sub-menu-trigger-icon' +
(isOpen ? ' sidebar-sub-menu-trigger-icon--open' : '');
const dismissibleCount = item.menuItems.filter(
(subItem) => !isDismissed(subItem, state),
).length;
return (
<li className={className}>
<Tippy disabled={isOpen || !slim} content={item.label} placement="right">
@ -79,6 +91,19 @@ export const SubMenuItem: React.FunctionComponent<SubMenuItemProps> = ({
<Icon name={item.iconName} className="icon--menuitem" />
)}
<span className="menuitem-label">{item.label}</span>
{dismissibleCount > 0 && !isDismissed(item, state) && (
<span className="w-dismissible-badge w-dismissible-badge--count">
<span aria-hidden="true">{dismissibleCount}</span>
<span className="w-sr-only">
{dismissibleCount === 1
? gettext('(1 new item in this menu)')
: gettext('({number} new items in this menu)').replace(
'{number}',
`${dismissibleCount}`,
)}
</span>
</span>
)}
<Icon className={sidebarTriggerIconClassName} name="arrow-right" />
</button>
</Tippy>

Wyświetl plik

@ -8,6 +8,7 @@ import { LinkMenuItemDefinition } from '../menu/LinkMenuItem';
import { MenuItemDefinition } from '../menu/MenuItem';
import { SubMenuItemDefinition } from '../menu/SubMenuItem';
import { ModuleDefinition } from '../Sidebar';
import { updateDismissibles } from '../../../includes/initDismissibles';
export function renderMenu(
path: string,
@ -32,6 +33,17 @@ export function renderMenu(
);
}
export function isDismissed(item: MenuItemDefinition, state: MenuState) {
return (
// Non-dismissibles are considered as dismissed
!item.attrs['data-wagtail-dismissible-id'] ||
// Dismissed on the server
'data-wagtail-dismissed' in item.attrs ||
// Dismissed on the client
state.dismissibles[item.name]
);
}
interface SetActivePath {
type: 'set-active-path';
path: string;
@ -42,25 +54,91 @@ interface SetNavigationPath {
path: string;
}
export type MenuAction = SetActivePath | SetNavigationPath;
interface SetDismissibleState {
type: 'set-dismissible-state';
item: MenuItemDefinition;
value?: boolean;
}
export type MenuAction =
| SetActivePath
| SetNavigationPath
| SetDismissibleState;
export interface MenuState {
navigationPath: string;
activePath: string;
dismissibles: Record<string, boolean>;
}
function walkDismissibleMenuItems(
menuItems: MenuItemDefinition[],
action: (item: MenuItemDefinition) => void,
) {
menuItems.forEach((menuItem) => {
const id = menuItem.attrs['data-wagtail-dismissible-id'];
if (id) {
action(menuItem);
}
if (menuItem instanceof SubMenuItemDefinition) {
walkDismissibleMenuItems(menuItem.menuItems, action);
}
});
}
function computeDismissibleState(
state: MenuState,
{ item, value = true }: SetDismissibleState,
) {
const update: Record<string, boolean> = {};
// Recursively update all dismissible items
walkDismissibleMenuItems([item], (menuItem) => {
update[menuItem.attrs['data-wagtail-dismissible-id']] = value;
});
// Send the update to the server
if (Object.keys(update).length > 0) {
updateDismissibles(update);
}
// Only update the top-level item in the client state so that the submenus
// are not immediately dismissed until the next page load
return { ...state.dismissibles, [item.name]: value };
}
function menuReducer(state: MenuState, action: MenuAction) {
const newState = { ...state };
if (action.type === 'set-active-path') {
newState.activePath = action.path;
} else if (action.type === 'set-navigation-path') {
newState.navigationPath = action.path;
switch (action.type) {
case 'set-active-path':
newState.activePath = action.path;
break;
case 'set-navigation-path':
newState.navigationPath = action.path;
break;
case 'set-dismissible-state':
newState.dismissibles = computeDismissibleState(state, action);
break;
default:
break;
}
return newState;
}
function getInitialDismissibleState(menuItems: MenuItemDefinition[]) {
const result: Record<string, boolean> = {};
walkDismissibleMenuItems(menuItems, (menuItem) => {
result[menuItem.attrs['data-wagtail-dismissible-id']] =
'data-wagtail-dismissed' in menuItem.attrs;
});
return result;
}
interface MenuProps {
menuItems: MenuItemDefinition[];
accountMenuItems: MenuItemDefinition[];
@ -91,6 +169,7 @@ export const Menu: React.FunctionComponent<MenuProps> = ({
const [state, dispatch] = React.useReducer(menuReducer, {
navigationPath: '',
activePath: '',
dismissibles: getInitialDismissibleState(menuItems),
});
const isVisible = !slim || expandingOrCollapsing;
const accountSettingsOpen = state.navigationPath.startsWith('.account');

Wyświetl plik

@ -316,6 +316,18 @@ As `register_admin_menu_item`, but registers menu items into the 'Reports' sub-m
As `construct_main_menu`, but modifies the 'Reports' sub-menu rather than the top-level menu.
(register_help_menu_item)=
### `register_help_menu_item`
As `register_admin_menu_item`, but registers menu items into the 'Help' sub-menu rather than the top-level menu.
(construct_help_menu)=
### `construct_help_menu`
As `construct_main_menu`, but modifies the 'Help' sub-menu rather than the top-level menu.
(register_admin_search_area)=
### `register_admin_search_area`

Wyświetl plik

@ -41,6 +41,28 @@ class MenuItem(metaclass=MediaDefiningClass):
)
class DismissibleMenuItemMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.attrs["data-wagtail-dismissible-id"] = self.name
def render_component(self, request):
profile = getattr(request.user, "wagtail_userprofile", None)
# Menu item instances are cached, so make sure the existence of the
# data-wagtail-dismissed attribute is correct for the user
if profile and profile.dismissibles.get(self.name):
self.attrs["data-wagtail-dismissed"] = ""
else:
self.attrs.pop("data-wagtail-dismissed", None)
return super().render_component(request)
class DismissibleMenuItem(DismissibleMenuItemMixin, MenuItem):
pass
class Menu:
def __init__(self, register_hook_name=None, construct_hook_name=None, items=None):
if register_hook_name is not None and not isinstance(register_hook_name, str):
@ -128,6 +150,10 @@ class SubmenuMenuItem(MenuItem):
)
class DismissibleSubmenuMenuItem(DismissibleMenuItemMixin, SubmenuMenuItem):
pass
class AdminOnlyMenuItem(MenuItem):
"""A MenuItem which is only shown to superusers"""
@ -147,3 +173,7 @@ reports_menu = Menu(
register_hook_name="register_reports_menu_item",
construct_hook_name="construct_reports_menu",
)
help_menu = Menu(
register_hook_name="register_help_menu_item",
construct_hook_name="construct_help_menu",
)

Wyświetl plik

@ -14,7 +14,15 @@ from wagtail.admin.admin_url_finder import (
)
from wagtail.admin.auth import user_has_any_page_permission
from wagtail.admin.forms.collections import GroupCollectionManagementPermissionFormSet
from wagtail.admin.menu import MenuItem, SubmenuMenuItem, reports_menu, settings_menu
from wagtail.admin.menu import (
DismissibleMenuItem,
DismissibleSubmenuMenuItem,
MenuItem,
SubmenuMenuItem,
help_menu,
reports_menu,
settings_menu,
)
from wagtail.admin.navigation import get_explorable_root_page
from wagtail.admin.rich_text.converters.contentstate import link_entity
from wagtail.admin.rich_text.converters.editor_html import (
@ -51,6 +59,10 @@ from wagtail.permissions import (
task_permission_policy,
workflow_permission_policy,
)
from wagtail.templatetags.wagtailcore_tags import (
wagtail_feature_release_editor_guide_link,
wagtail_feature_release_whats_new_link,
)
from wagtail.whitelist import allow_without_attributes, attribute_rule, check_url
@ -960,6 +972,42 @@ def register_reports_menu():
return SubmenuMenuItem(_("Reports"), reports_menu, icon_name="site", order=9000)
@hooks.register("register_help_menu_item")
def register_whats_new_in_wagtail_version_menu_item():
version = "4.1"
return DismissibleMenuItem(
_("What's new in Wagtail {version}").format(version=version),
wagtail_feature_release_whats_new_link(),
icon_name="help",
order=1000,
attrs={"target": "_blank", "rel": "noreferrer"},
name=f"whats-new-in-wagtail-{version}",
)
@hooks.register("register_help_menu_item")
def register_editors_guide_menu_item():
return DismissibleMenuItem(
_("Editor Guide"),
wagtail_feature_release_editor_guide_link(),
icon_name="help",
order=1100,
attrs={"target": "_blank", "rel": "noreferrer"},
name="editor-guide",
)
@hooks.register("register_admin_menu_item")
def register_help_menu():
return DismissibleSubmenuMenuItem(
_("Help"),
help_menu,
icon_name="help",
order=11000,
name="help",
)
@hooks.register("register_icons")
def register_icons(icons):
for icon in [

Wyświetl plik

@ -93,6 +93,11 @@ def wagtail_release_notes_path():
return "%s.html" % get_main_version(VERSION)
@register.simple_tag
def wagtail_feature_release_whats_new_link():
return "https://wagtail.org/wagtail-4-1-new-in-wagtail"
@register.simple_tag
def wagtail_feature_release_editor_guide_link():
return "https://wagtail.org/wagtail-4-1-editor-guide"