Explicit header, text and bullet blocks rather than one HTML block in ui spec

This commit is contained in:
Julian Stirling 2026-03-10 15:25:04 +00:00
parent 85ebfef44a
commit c4d0c0e9da
5 changed files with 82 additions and 41 deletions

View file

@ -42,7 +42,8 @@ from openflexure_microscope_server.things.stage import BaseStage
from openflexure_microscope_server.ui import ( from openflexure_microscope_server.ui import (
UI_ELEMENT_RESPONSE, UI_ELEMENT_RESPONSE,
Accordion, Accordion,
HTMLBlock, HeaderBlock,
TextBlock,
UIElementList, UIElementList,
action_button_for, action_button_for,
property_control_for, property_control_for,
@ -642,7 +643,8 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
return UIElementList( return UIElementList(
[ [
HTMLBlock(html=f"<h4>{self.display_name}</h4><p>{self.ui_blurb}<p>"), HeaderBlock(text=self.display_name, level=4),
TextBlock(text=self.ui_blurb),
Accordion(title="Background Detect", children=background_ui), Accordion(title="Background Detect", children=background_ui),
Accordion( Accordion(
title="Scan Settings", title="Scan Settings",
@ -750,7 +752,8 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
) )
return UIElementList( return UIElementList(
[ [
HTMLBlock(html=f"<h4>{self.display_name}</h4><p>{self.ui_blurb}<p>"), HeaderBlock(text=self.display_name, level=4),
TextBlock(text=self.ui_blurb),
Accordion( Accordion(
title="Scan Settings", title="Scan Settings",
children=scan_settings, children=scan_settings,

View file

@ -5,24 +5,12 @@ from html.parser import HTMLParser
from typing import Annotated, Any, Literal, Optional from typing import Annotated, Any, Literal, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from pydantic import BaseModel, BeforeValidator, RootModel from pydantic import BaseModel, BeforeValidator, Field, RootModel
import labthings_fastapi as lt import labthings_fastapi as lt
ALLOWED_TAGS = { # Only allowed tags are italic, bold, and link. Also allows self closing br tags.
"p", ALLOWED_TAGS = {"i", "b", "a"}
"i",
"b",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"li",
"a",
}
ALLOWED_SCHEMES = {"http", "https"} ALLOWED_SCHEMES = {"http", "https"}
@ -116,8 +104,7 @@ class SafeHTMLParser(HTMLParser):
def sanitise_html(html: str) -> str: def sanitise_html(html: str) -> str:
"""Santitise HTML to only have a small list of allowed tags. """Santitise HTML to only have a small list of allowed tags.
Tags allowed without attrs: ``<p>, <i>, <b>, <h1>, <h2>, <h3>, <h4>, <h5>, <h6>,`` Tags allowed without attrs: ``<i>, <b>,``
``<ul>, <li>``
Tags allowed with attrs: ``<a>`` is allowed with ``href`` only. This automatically Tags allowed with attrs: ``<a>`` is allowed with ``href`` only. This automatically
appends ``target="_blank" rel="noopener noreferrer"`` so the link opens externally. appends ``target="_blank" rel="noopener noreferrer"`` so the link opens externally.
@ -137,14 +124,31 @@ def sanitise_html(html: str) -> str:
HtmlFragment = Annotated[str, BeforeValidator(sanitise_html)] HtmlFragment = Annotated[str, BeforeValidator(sanitise_html)]
class HTMLBlock(BaseModel): class HeaderBlock(BaseModel):
"""The data required for creating a block of HTML for the UI. """The data required for header."""
The HTML is limited to a small number of tags. element_type: Literal["header_block"] = "header_block"
""" text: HtmlFragment
"""The header text (can include basic HTML tags)."""
element_type: Literal["html_block"] = "html_block" level: int = Field(default=2, ge=1, le=6)
html: HtmlFragment """The header level. A number from 1-6."""
class TextBlock(BaseModel):
"""The data required for creating a block text."""
element_type: Literal["text_block"] = "text_block"
text: HtmlFragment
"""The text (can include basic HTML tags)."""
class BulletBlock(BaseModel):
"""The data required for creating a block text."""
element_type: Literal["bullet_block"] = "bullet_block"
bullets: list[HtmlFragment]
"""A list of text elements (can include basic HTML tags)."""
class ActionButton(BaseModel): class ActionButton(BaseModel):
@ -304,7 +308,15 @@ class Container(BaseModel):
children: "UIElementList" children: "UIElementList"
UIElementModels = HTMLBlock | PropertyControl | ActionButton | Accordion | Container UIElementModels = (
HeaderBlock
| TextBlock
| BulletBlock
| PropertyControl
| ActionButton
| Accordion
| Container
)
class UIElementList(RootModel[list[UIElementModels]]): class UIElementList(RootModel[list[UIElementModels]]):

View file

@ -20,7 +20,7 @@ The script is removed but the contents remain.
### Result ### Result
```html ```html
alert(&quot;x&quot;) alert(&quot;x&quot;)
<p>Hello</p> Hello
``` ```
## Test script in bold tag ## Test script in bold tag
@ -82,3 +82,14 @@ Remove the javascript from the links.
```html ```html
<a href="https://openflexure.org" target="_blank" rel="noopener noreferrer">OpenFlexure</a> <a href="https://openflexure.org" target="_blank" rel="noopener noreferrer">OpenFlexure</a>
``` ```
## Test basic formatting preserved
Check basic elements are not changed
### Input
```html
This is <b>bold</b> <i>italic</i> and on a new <br/> line.
```
### Result
```html
This is <b>bold</b> <i>italic</i> and on a new <br/> line.
```

View file

@ -21,8 +21,9 @@ from openflexure_microscope_server.things.scan_workflows import (
from openflexure_microscope_server.ui import ( from openflexure_microscope_server.ui import (
Accordion, Accordion,
ActionButton, ActionButton,
HTMLBlock, HeaderBlock,
PropertyControl, PropertyControl,
TextBlock,
UIElementList, UIElementList,
) )
@ -385,20 +386,24 @@ def test_histo_workflow_settings_ui(histo_workflow, mocker):
ui = histo_workflow.settings_ui().root ui = histo_workflow.settings_ui().root
# The UI is in 3 blocks # The UI is in 4 blocks
assert len(ui) == 3 assert len(ui) == 4
# The top HTML block # The top is a header
assert isinstance(ui[0], HTMLBlock) assert isinstance(ui[0], HeaderBlock)
assert "<h4>Histo Scan</h4>" in ui[0].html assert ui[0].text == "Histo Scan"
# The then some text (note the & is escaped)
assert isinstance(ui[1], TextBlock)
fragment = "This scan workflow is optimised for scanning H&amp;E stained biopsies."
assert fragment in ui[1].text
# Followed by a background detect accordion # Followed by a background detect accordion
assert isinstance(ui[1], Accordion)
assert ui[1].title == "Background Detect"
# And a Scan Settings accordion
assert isinstance(ui[2], Accordion) assert isinstance(ui[2], Accordion)
assert ui[2].title == "Scan Settings" assert ui[2].title == "Background Detect"
# And a Scan Settings accordion
assert isinstance(ui[3], Accordion)
assert ui[3].title == "Scan Settings"
# Background UI is ... # Background UI is ...
background_ui = ui[1].children.root background_ui = ui[2].children.root
# what the detector specifies as it settings ... # what the detector specifies as it settings ...
assert background_ui[0] is mock_prop_control assert background_ui[0] is mock_prop_control
# plus 2 action buttons # plus 2 action buttons
@ -409,7 +414,7 @@ def test_histo_workflow_settings_ui(histo_workflow, mocker):
assert background_ui[2].action == "check_background" assert background_ui[2].action == "check_background"
# Finally check the contents of the scan settings accordion # Finally check the contents of the scan settings accordion
scan_settings = ui[2].children.root scan_settings = ui[3].children.root
assert len(scan_settings) == 8 assert len(scan_settings) == 8
for element in scan_settings: for element in scan_settings:
assert isinstance(element, PropertyControl) assert isinstance(element, PropertyControl)

View file

@ -1,7 +1,17 @@
<template> <template>
<div v-for="(element, index) in elements" :key="index" class="uk-margin"> <div v-for="(element, index) in elements" :key="index" class="uk-margin">
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<div v-if="element.element_type === 'html_block'" v-html="element.html"></div> <component :is="'h' + element.level" v-if="element.element_type === 'header_block'">
<span v-html="element.text"></span>
</component>
<p v-if="element.element_type === 'text_block'" v-html="element.text"></p>
<ul v-if="element.element_type === 'bullet_block'">
<li
v-for="(bulletText, bulletIndex) in element.bullets"
:key="'bullet-' + bulletIndex"
v-html="bulletText"
></li>
</ul>
<!-- eslint-enable --> <!-- eslint-enable -->
<server-specified-property-control <server-specified-property-control
v-else-if="element.element_type === 'property_control'" v-else-if="element.element_type === 'property_control'"