From 0a0e07abc0e329318416dcfa7e7cc62525e269b2 Mon Sep 17 00:00:00 2001 From: Sage Abdullah Date: Thu, 13 Oct 2022 14:55:57 +0100 Subject: [PATCH] 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. --- client/scss/components/_dismissible.scss | 27 ++++++ .../components/Sidebar/menu/LinkMenuItem.tsx | 20 ++++- .../src/components/Sidebar/menu/MenuItem.scss | 13 ++- .../Sidebar/menu/SubMenuItem.test.js | 4 +- .../components/Sidebar/menu/SubMenuItem.tsx | 27 +++++- .../components/Sidebar/modules/MainMenu.tsx | 89 +++++++++++++++++-- docs/reference/hooks.md | 12 +++ wagtail/admin/menu.py | 30 +++++++ wagtail/admin/wagtail_hooks.py | 50 ++++++++++- wagtail/templatetags/wagtailcore_tags.py | 5 ++ 10 files changed, 265 insertions(+), 12 deletions(-) diff --git a/client/scss/components/_dismissible.scss b/client/scss/components/_dismissible.scss index 5aeda6e351..c5daa924c0 100644 --- a/client/scss/components/_dismissible.scss +++ b/client/scss/components/_dismissible.scss @@ -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; + } + } +} diff --git a/client/src/components/Sidebar/menu/LinkMenuItem.tsx b/client/src/components/Sidebar/menu/LinkMenuItem.tsx index 2756f4c6dd..c891dc098f 100644 --- a/client/src/components/Sidebar/menu/LinkMenuItem.tsx +++ b/client/src/components/Sidebar/menu/LinkMenuItem.tsx @@ -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 @@ -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) => { // 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 && ( )} - {item.label} +
+ {item.label} + {!isDismissed(item, state) && ( + + {gettext('(New)')} + + )} +
diff --git a/client/src/components/Sidebar/menu/MenuItem.scss b/client/src/components/Sidebar/menu/MenuItem.scss index 5b5afdf2d0..15231f5638 100644 --- a/client/src/components/Sidebar/menu/MenuItem.scss +++ b/client/src/components/Sidebar/menu/MenuItem.scss @@ -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 { diff --git a/client/src/components/Sidebar/menu/SubMenuItem.test.js b/client/src/components/Sidebar/menu/SubMenuItem.test.js index 3c0c10a6ec..ead2a592ea 100644 --- a/client/src/components/Sidebar/menu/SubMenuItem.test.js +++ b/client/src/components/Sidebar/menu/SubMenuItem.test.js @@ -8,7 +8,7 @@ describe('SubMenuItem', () => { it('should render with the minimum required props', () => { const wrapper = shallow( { const wrapper = shallow( { slim: boolean; @@ -39,6 +40,13 @@ export const SubMenuItem: React.FunctionComponent = ({ }, [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 = ({ 'sidebar-sub-menu-trigger-icon' + (isOpen ? ' sidebar-sub-menu-trigger-icon--open' : ''); + const dismissibleCount = item.menuItems.filter( + (subItem) => !isDismissed(subItem, state), + ).length; + return (
  • @@ -79,6 +91,19 @@ export const SubMenuItem: React.FunctionComponent = ({ )} {item.label} + {dismissibleCount > 0 && !isDismissed(item, state) && ( + + + + {dismissibleCount === 1 + ? gettext('(1 new item in this menu)') + : gettext('({number} new items in this menu)').replace( + '{number}', + `${dismissibleCount}`, + )} + + + )} diff --git a/client/src/components/Sidebar/modules/MainMenu.tsx b/client/src/components/Sidebar/modules/MainMenu.tsx index 11deb5531d..e2e4327602 100644 --- a/client/src/components/Sidebar/modules/MainMenu.tsx +++ b/client/src/components/Sidebar/modules/MainMenu.tsx @@ -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; +} + +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 = {}; + + // 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 = {}; + + 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 = ({ const [state, dispatch] = React.useReducer(menuReducer, { navigationPath: '', activePath: '', + dismissibles: getInitialDismissibleState(menuItems), }); const isVisible = !slim || expandingOrCollapsing; const accountSettingsOpen = state.navigationPath.startsWith('.account'); diff --git a/docs/reference/hooks.md b/docs/reference/hooks.md index 86caea787a..64137a49eb 100644 --- a/docs/reference/hooks.md +++ b/docs/reference/hooks.md @@ -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` diff --git a/wagtail/admin/menu.py b/wagtail/admin/menu.py index 82c79d410f..4870f7430d 100644 --- a/wagtail/admin/menu.py +++ b/wagtail/admin/menu.py @@ -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", +) diff --git a/wagtail/admin/wagtail_hooks.py b/wagtail/admin/wagtail_hooks.py index fcb7de8d2b..163859cf84 100644 --- a/wagtail/admin/wagtail_hooks.py +++ b/wagtail/admin/wagtail_hooks.py @@ -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 [ diff --git a/wagtail/templatetags/wagtailcore_tags.py b/wagtail/templatetags/wagtailcore_tags.py index f938b467c9..9e40c463bf 100644 --- a/wagtail/templatetags/wagtailcore_tags.py +++ b/wagtail/templatetags/wagtailcore_tags.py @@ -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"