kopia lustrzana https://github.com/wagtail/wagtail
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
rodzic
4e5f4ca4ec
commit
0a0e07abc0
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
|
@ -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 [
|
||||
|
|
|
@ -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"
|
||||
|
|
Ładowanie…
Reference in New Issue