Repository: wagtail/wagtail

Issue:
ConfigException - "UNDERLINE" is not in the config and has no fallback
### Issue Summary

Not entirely sure *how* this happens - it's something I can sort of recreate by hacking around with the data sent in the admin edit form, but can't recreate in the same way as an end user. I've seen it on 2 different projects with Sentry reports.

Somehow underline content is coming through rich text fields, maybe through a copy/paste (but it seems to strip invalid styles for me), and ends up with a 500:

```
Internal Server Error: /admin/pages/4/edit/
Traceback (most recent call last):
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/draftjs_exporter/options.py", line 85, in get
    return options[type_]
KeyError: 'UNDERLINE'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/draftjs_exporter/options.py", line 88, in get
    return options[fallback_key]
KeyError: 'FALLBACK'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/django/views/decorators/cache.py", line 44, in _wrapped_view_func
    response = view_func(request, *args, **kwargs)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/wagtail/admin/urls/__init__.py", line 108, in wrapper
    return view_func(request, *args, **kwargs)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/wagtail/admin/auth.py", line 193, in decorated_view
    return view_func(request, *args, **kwargs)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/wagtail/admin/views/pages/edit.py", line 224, in dispatch
    return super().dispatch(request)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/wagtail/admin/views/pages/edit.py", line 310, in post
    if self.form.is_valid() and not self.page_perms.page_locked():
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/wagtail/admin/forms/pages.py", line 150, in is_valid
    return super().is_valid()
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/modelcluster/forms.py", line 316, in is_valid
    form_is_valid = super(ClusterForm, self).is_valid()
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/django/forms/forms.py", line 175, in is_valid
    return self.is_bound and not self.errors
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/django/forms/forms.py", line 170, in errors
    self.full_clean()
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/django/forms/forms.py", line 372, in full_clean
    self._clean_fields()
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/django/forms/forms.py", line 384, in _clean_fields
    value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name))
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/wagtail/core/blocks/base.py", line 535, in value_from_datadict
    return self.block_def.value_from_datadict(data, files, name)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/wagtail/core/blocks/stream_block.py", line 114, in value_from_datadict
    child_block.value_from_datadict(data, files, '%s-%d-value' % (prefix, i)),
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/wagtail/core/blocks/field_block.py", line 48, in value_from_datadict
    return self.value_from_form(self.field.widget.value_from_datadict(data, files, prefix))
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/wagtail/admin/rich_text/editors/draftail/__init__.py", line 73, in value_from_datadict
    return self.converter.to_database_format(original_value)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/wagtail/admin/rich_text/converters/contentstate.py", line 132, in to_database_format
    return self.exporter.render(json.loads(contentstate_json))
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/draftjs_exporter/html.py", line 52, in render
    elt = self.render_block(block, entity_map, wrapper_state)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/draftjs_exporter/html.py", line 84, in render_block
    styled_node = style_state.render_styles(decorated_node, block, wrapper_state.blocks)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/draftjs_exporter/style_state.py", line 34, in render_styles
    options = Options.get(self.style_options, style, INLINE_STYLES.FALLBACK)
  File "/Users/tomkins/.virtualenvs/tmp-826a0d9266372eb/lib/python3.9/site-packages/draftjs_exporter/options.py", line 90, in get
    raise ConfigException('"%s" is not in the config and has no fallback' % type_)
draftjs_exporter.error.ConfigException: "UNDERLINE" is not in the config and has no fallback
```

### Steps to Reproduce

1. Edit a rich text field, add some bold text: <img width="1124" alt="Screenshot 2021-08-01 at 22 14 06" src="https://user-images.githubusercontent.com/177332/127785617-04d56bdf-1c7a-4807-a28f-568a251427f6.png">
2. Change BOLD to UNDERLINE (note: you may have to lose focus on the text area input, otherwise it tries to revert it back to BOLD, and also change the outer HTML attribute quotes to `'` for this to work): 
<img width="1127" alt="Screenshot 2021-08-01 at 22 14 22" src="https://user-images.githubusercontent.com/177332/127785623-166074e4-f018-4f25-8037-98cce93bd21b.png">
3. Click publish (or preview) - a 500 will occur

I know this is a bit obscure, but an editor shouldn't be able to cause a 500 error.

### Technical details

* Python version: 3.9 (any)
* Django version: 3.2 (any)
* Wagtail version: main, 2.11.7


Discussion:
squash-labs[bot]: <h2>Manage this branch in <a href="https://app.squash.io/accounts/">Squash</a></h2>Test this branch here: <a href='https://tomkinsinline-configexception-y360t.squash.io' target='_blank'>https://tomkinsinline-configexception-y360t.squash.io</a><br/>
lb-: @tomkins thanks for investigating this and providing a fix with unit tests also, really appreciate it.

I have pushed a rebased version of this to your branch (along with release notes) and will merge in shortly.

Patch:
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index fe1c78d327..02dbdfb135 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -29,6 +29,7 @@ Changelog
  * Fix: `blocks.MultipleChoiceBlock`, `forms.CheckboxSelectMultiple` and `ArrayField` checkboxes will now stack instead of display inline to align with all other checkboxes fields (Seb Brown)
  * Fix: Screen readers can now access login screen field labels (Amy Chan)
  * Fix: Admin breadcrumbs home icon now shows for users with access to a subtree only (Stefan Hammer)
+ * Fix: Add handling of invalid inline styles submitted to `RichText` so `ConfigException` is not thrown (Alex Tomkins)
 
 
 2.14.1 (12.08.2021)
diff --git a/client/scss/components/_main-nav.scss b/client/scss/components/_main-nav.scss
index f03f495d95..7e45300ddc 100644
--- a/client/scss/components/_main-nav.scss
+++ b/client/scss/components/_main-nav.scss
@@ -303,11 +303,10 @@ body.explorer-open {
         transform: none;
         padding-left: $menu-width;
 
-        @include transition(padding-left 0.3s ease);
+        @include transition(padding-left $menu-transition-duration ease);
     }
 
     body.sidebar-collapsed .wrapper {
-        @include transition(padding-left $menu-transition-duration ease);
         padding-left: $menu-width-slim;
     }
 
diff --git a/client/src/components/Sidebar/Sidebar.scss b/client/src/components/Sidebar/Sidebar.scss
index 74035cce80..cc2ee045f1 100644
--- a/client/src/components/Sidebar/Sidebar.scss
+++ b/client/src/components/Sidebar/Sidebar.scss
@@ -50,6 +50,15 @@
             height: 16px;
         }
     }
+
+    // This element should cover all the area beneath the collapse toggle
+    // It's only used to attach mouse enter/exit event handlers to control peeking
+    &__peek-hover-area {
+        position: absolute;
+        top: 35px;  // Match height of __collapse-toggle
+        left: 0;
+        width: 100%;
+    }
 }
 
 @import 'SidebarPanel';
diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx
index 8879f54220..faa63a31f2 100644
--- a/client/src/components/Sidebar/Sidebar.tsx
+++ b/client/src/components/Sidebar/Sidebar.tsx
@@ -82,37 +82,46 @@ export const Sidebar: React.FunctionComponent<SidebarProps> = (
     e.preventDefault();
     setCollapsed(!collapsed);
 
-    // Unpeek if the user has just collapsed the menu
-    // Otherwise the menu would just stay open until the mouse leaves
-    if (!collapsed) {
-      setPeeking(false);
-    }
-
     if (onExpandCollapse) {
       onExpandCollapse(!collapsed);
     }
   };
 
-  // Switch peeking on/off when the mouse cursor hovers the sidebar
-  const startPeekingTimeout = React.useRef<any>(null);
-  const stopPeekingTimeout = React.useRef<any>(null);
+  // Switch peeking on/off when the mouse cursor hovers the sidebar or focus is on the sidebar
+  const [mouseHover, setMouseHover] = React.useState(false);
+  const [focused, setFocused] = React.useState(false);
 
   const onMouseEnterHandler = () => {
-    clearTimeout(startPeekingTimeout.current);
-    clearTimeout(stopPeekingTimeout.current);
-    startPeekingTimeout.current = setTimeout(() => {
-      setPeeking(true);
-    }, 100);
+    setMouseHover(true);
   };
 
   const onMouseLeaveHandler = () => {
-    clearTimeout(startPeekingTimeout.current);
-    clearTimeout(stopPeekingTimeout.current);
-    stopPeekingTimeout.current = setTimeout(() => {
-      setPeeking(false);
-    }, SIDEBAR_TRANSITION_DURATION);
+    setMouseHover(false);
+  };
+
+  const onFocusHandler = () => {
+    setFocused(true);
   };
 
+  const onBlurHandler = () => {
+    setFocused(false);
+  };
+
+  // We need a stop peeking timeout to stop the sidebar moving as someone tab's though the menu
+  const stopPeekingTimeout = React.useRef<any>(null);
+
+  React.useEffect(() => {
+    if (mouseHover || focused) {
+      clearTimeout(stopPeekingTimeout.current);
+      setPeeking(true);
+    } else {
+      clearTimeout(stopPeekingTimeout.current);
+      stopPeekingTimeout.current = setTimeout(() => {
+        setPeeking(false);
+      }, SIDEBAR_TRANSITION_DURATION);
+    }
+  }, [mouseHover, focused]);
+
   // Render modules
   const renderedModules = modules.map(
     (module, index) => module.render({
@@ -128,14 +137,21 @@ export const Sidebar: React.FunctionComponent<SidebarProps> = (
   return (
     <aside
       className={'sidebar' + (slim ? ' sidebar--slim' : '')}
-      onMouseEnter={onMouseEnterHandler} onMouseLeave={onMouseLeaveHandler}
     >
       <div className="sidebar__inner">
         <button onClick={onClickCollapseToggle} className="button sidebar__collapse-toggle">
           {collapsed ? <Icon name="angle-double-right" /> : <Icon name="angle-double-left" />}
         </button>
 
-        {renderedModules}
+        <div
+          className="sidebar__peek-hover-area"
+          onMouseEnter={onMouseEnterHandler}
+          onMouseLeave={onMouseLeaveHandler}
+          onFocus={onFocusHandler}
+          onBlur={onBlurHandler}
+        >
+          {renderedModules}
+        </div>
       </div>
     </aside>
   );
diff --git a/client/src/components/Sidebar/menu/MenuItem.scss b/client/src/components/Sidebar/menu/MenuItem.scss
index f1d663dc8b..29b4b66d89 100644
--- a/client/src/components/Sidebar/menu/MenuItem.scss
+++ b/client/src/components/Sidebar/menu/MenuItem.scss
@@ -1,3 +1,4 @@
+// stylelint-disable declaration-no-important
 .sidebar-menu-item {
     $root: &;
     @include transition(border-color $menu-transition-duration ease);
@@ -6,7 +7,7 @@
     &__link {
         @include transition(border-color $menu-transition-duration ease, background-color $menu-transition-duration ease);
         position: relative;
-        display: flex;
+        display: block;
         white-space: nowrap;
         border-left: 3px solid transparent;
 
@@ -43,6 +44,13 @@
             top: 0.5em;
             margin-top: 0;
         }
+
+        // Disable icon margin, this is instead applied to the left of the .menuitem-label
+        // This is because SVG icons and legacy icons apply this margin differently,
+        // we could remove this override when we remove legacy icons
+        .icon {
+            margin-right: 0 !important;
+        }
     }
 
     &--in-sub-menu {
@@ -69,6 +77,7 @@
 
 .menuitem-label {
     @include transition(opacity $menu-transition-duration ease);
+    margin-left: 0.5em;
 }
 
 .menuitem-tooltip {
diff --git a/client/src/components/Sidebar/modules/MainMenu.scss b/client/src/components/Sidebar/modules/MainMenu.scss
index efba4fe16f..4a3486babc 100644
--- a/client/src/components/Sidebar/modules/MainMenu.scss
+++ b/client/src/components/Sidebar/modules/MainMenu.scss
@@ -43,23 +43,6 @@
             transition: opacity $menu-transition-duration ease;
         }
     }
-
-    @at-root .sidebar--slim #{&} {
-        > ul > li > a {
-            padding: 0.8em 0.8em 0.8em 1.25em;
-
-            .menuitem-label {
-                opacity: 0;
-            }
-
-            .icon-arrow-right {
-                top: 1em;
-                right: 0.15em;
-                width: 1em;
-                height: 1em;
-            }
-        }
-    }
 }
 
 .sidebar-footer {
diff --git a/client/src/components/Sidebar/modules/Search.scss b/client/src/components/Sidebar/modules/Search.scss
index 2a7940627d..b5c1337833 100644
--- a/client/src/components/Sidebar/modules/Search.scss
+++ b/client/src/components/Sidebar/modules/Search.scss
@@ -7,6 +7,7 @@
     display: flex;
     align-items: center;
     flex-direction: row-reverse;
+    height: 42px;
 
     .sidebar--slim & {
         padding: 0;
@@ -18,19 +19,20 @@
 
     // Beat specificity
     input:not([type='submit']) {
+        position: absolute;
+        left: 0;
+        top: 0;
         // Need !important to override body.ready class
-        transition: background-color 0.2s ease, opacity 600ms ease-in-out !important;
-        visibility: hidden;
+        transition: background-color $menu-transition-duration ease, opacity $menu-transition-duration ease !important;
         font-size: 13px;
         font-weight: 400;
         background-color: transparent;
         border: 0;
-        padding: 12px 0;
         color: $color-menu-text;
         -webkit-font-smoothing: auto;
 
         .sidebar--slim & {
-            display: none;
+            opacity: 0;
         }
 
         &::placeholder {
@@ -39,33 +41,32 @@
     }
 
     &__submit {
+        position: absolute;
+        right: 0;
+        top: 0;
         background-color: transparent;
         border: 0;
         color: #ccc;
         padding: 0;
         width: 35px;
         height: 35px;
-        transition: opacity 600ms ease-in-out;
+        transition: opacity $menu-transition-duration ease, width $menu-transition-duration ease;
+
+        svg {
+            margin-right: 15px;
+            transition: margin-right $menu-transition-duration ease;
+        }
 
         .sidebar--slim & {
             width: 100%;
+
+            svg {
+                margin-right: 0;
+            }
         }
 
         &:hover {
             background-color: transparent;
         }
     }
-
-    &--visible {
-        input:not([type='submit']) {
-            visibility: visible;
-        }
-    }
-}
-
-.sidebar-search--slim.sidebar-search--visible {
-    .sidebar-search__submit,
-    input:not([type=submit]) {
-        opacity: 0;
-    }
 }
diff --git a/client/src/components/Sidebar/modules/WagtailBranding.scss b/client/src/components/Sidebar/modules/WagtailBranding.scss
index 8518a25574..8dcfec2428 100644
--- a/client/src/components/Sidebar/modules/WagtailBranding.scss
+++ b/client/src/components/Sidebar/modules/WagtailBranding.scss
@@ -18,11 +18,13 @@
     align-items: center;
     color: #aaa;
     -webkit-font-smoothing: auto;
-    margin: 4.5em auto 2.5em;
+    margin: 1.8em auto 2.5em;
     text-align: center;
     width: 100px;
     height: 100px;
-    transition: transform 150ms cubic-bezier(0.28, 0.15, 0, 2.1), width 150ms ease, height 150ms ease;
+    transition: transform 150ms cubic-bezier(0.28, 0.15, 0, 2.1), width $menu-transition-duration ease, height $menu-transition-duration ease, padding-top $menu-transition-duration ease;
+    box-sizing: border-box;
+    overflow: hidden;
 
     &:hover {
         color: $color-white;
@@ -31,8 +33,8 @@
 
     // Reduce overall size when in slim mode
     .sidebar--slim & {
-        width: 40px;
-        height: 40px;
+        width: 60px;
+        transform: none;
     }
 
     // Remove background on 404 page
@@ -68,16 +70,20 @@
     // Bird wrapper
     &__icon-wrapper {
         margin: auto;
-        position: relative;
+        position: absolute;
+        left: 0;
+        top: 0;
         width: 100px;
         height: 100px;
         background-color: #3a3a3a;
         border-radius: 50%;
-        transition: width 150ms ease, height 150ms ease;
+        transition: left $menu-transition-duration ease, top $menu-transition-duration ease, width $menu-transition-duration ease, height $menu-transition-duration ease;
 
         .sidebar--slim & {
-            width: 40px;
-            height: 40px;
+            left: -10px;
+            top: 10px;
+            width: 80px;
+            height: 80px;
         }
 
         .page404__bg & {
@@ -98,6 +104,13 @@
         margin: auto;
         bottom: 0;
         right: 0;
+        transition: left $menu-transition-duration ease, width $menu-transition-duration ease, height $menu-transition-duration ease;
+
+        .sidebar--slim & {
+            left: -10px;
+            width: 70%;
+            height: 70%;
+        }
 
         // TODO: Fix legacy specificity issues
         &[data-part='eye--open'] {
diff --git a/docs/releases/2.15.rst b/docs/releases/2.15.rst
index 5a15299ae9..335fdfabd8 100644
--- a/docs/releases/2.15.rst
+++ b/docs/releases/2.15.rst
@@ -44,6 +44,7 @@ Bug fixes
  * ``blocks.MultipleChoiceBlock``, ``forms.CheckboxSelectMultiple`` and ``ArrayField`` checkboxes will now stack instead of display inline to align with all other checkboxes fields (Seb Brown)
  * Screen readers can now access login screen field labels (Amy Chan)
  * Admin breadcrumbs home icon now shows for users with access to a subtree only (Stefan Hammer)
+ * Add handling of invalid inline styles submitted to ``RichText`` so ``ConfigException`` is not thrown (Alex Tomkins)
 
 Upgrade considerations
 ======================
diff --git a/wagtail/admin/rich_text/converters/contentstate.py b/wagtail/admin/rich_text/converters/contentstate.py
index 2032dd48b7..4d220aa41a 100644
--- a/wagtail/admin/rich_text/converters/contentstate.py
+++ b/wagtail/admin/rich_text/converters/contentstate.py
@@ -47,6 +47,12 @@ def entity_fallback(props):
     return None
 
 
+def style_fallback(props):
+    type_ = props['inline_style_range']['style']
+    logging.warn('Missing config for "%s". Deleting style.' % type_)
+    return props['children']
+
+
 def persist_key_for_block(config):
     # For any block level element config for draft js exporter, return a config that retains the
     # block key in a data attribute
@@ -90,7 +96,9 @@ class ContentstateConverter():
                 'atomic': render_children,
                 'fallback': block_fallback,
             },
-            'style_map': {},
+            'style_map': {
+                'FALLBACK': style_fallback,
+            },
             'entity_decorators': {
                 'FALLBACK': entity_fallback,
             },


Test patch:
diff --git a/wagtail/admin/tests/test_contentstate.py b/wagtail/admin/tests/test_contentstate.py
index 85d4206ab5..acd584936e 100644
--- a/wagtail/admin/tests/test_contentstate.py
+++ b/wagtail/admin/tests/test_contentstate.py
@@ -1036,3 +1036,21 @@ class TestContentStateToHtml(TestCase):
             <p data-block-key='00000'>Hello world!</p>
             <p data-block-key='00001'>Goodbye world!</p>
             ''')
+
+    def test_style_fallback(self):
+        # Test a block which uses an invalid inline style, and will be removed
+        converter = ContentstateConverter(features=[])
+        result = converter.to_database_format(json.dumps({
+            'entityMap': {},
+            'blocks': [
+                {
+                    'inlineStyleRanges': [{'offset': 0, 'length': 12, 'style': 'UNDERLINE'}],
+                    'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []
+                },
+            ]
+        }))
+        self.assertHTMLEqual(result, '''
+            <p data-block-key="00000">
+                Hello world!
+            </p>
+        ''')