Merge branch 'dynamic-ui' into 'v3'

Increase number of UI elements that can be specified in the server

Closes #508 and #644

See merge request openflexure/openflexure-microscope-server!530
This commit is contained in:
Joe Knapper 2026-03-11 11:45:51 +00:00
commit 2909194d61
17 changed files with 810 additions and 281 deletions

View file

@ -13,7 +13,11 @@ from pydantic import BaseModel
import labthings_fastapi as lt
from openflexure_microscope_server.ui import PropertyControl, property_control_for
from openflexure_microscope_server.ui import (
UI_ELEMENT_RESPONSE,
UIElementList,
property_control_for,
)
SettingsType = TypeVar("SettingsType", bound=BaseModel)
BackgroundType = TypeVar("BackgroundType", bound=BaseModel)
@ -72,9 +76,9 @@ class BackgroundDetectAlgorithm(lt.Thing):
"Each background detect algorithm must implement an set_background method."
)
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the UI."""
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
def settings_ui(self) -> UIElementList:
"""Return the UI for the background detect settings."""
raise NotImplementedError(
"Each background detect algorithm must implement an settings_ui method."
)
@ -113,15 +117,19 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
image to be labeled as containing sample.
"""
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the UI."""
return [
property_control_for(self, "channel_tolerance", label="Channel Tolerance"),
property_control_for(
self, "min_sample_coverage", label="Sample Coverage Required (%)"
),
]
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
def settings_ui(self) -> UIElementList:
"""Return the UI for the background detect settings."""
return UIElementList(
[
property_control_for(
self, "channel_tolerance", label="Channel Tolerance"
),
property_control_for(
self, "min_sample_coverage", label="Sample Coverage Required (%)"
),
]
)
@lt.property
def ready(self) -> bool:
@ -237,15 +245,19 @@ class ChannelDeviationLUV(BackgroundDetectAlgorithm):
image to be labeled as containing sample.
"""
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the UI."""
return [
property_control_for(self, "channel_tolerance", label="Channel Tolerance"),
property_control_for(
self, "min_sample_coverage", label="Sample Coverage Required (%)"
),
]
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
def settings_ui(self) -> UIElementList:
"""Return the UI for the background detect settings."""
return UIElementList(
[
property_control_for(
self, "channel_tolerance", label="Channel Tolerance"
),
property_control_for(
self, "min_sample_coverage", label="Sample Coverage Required (%)"
),
]
)
@lt.property
def ready(self) -> bool:

View file

@ -4,8 +4,6 @@ This module contains the base ``ScanWorkflow`` class that all workflows should s
as well as specific workflows.
"""
from __future__ import annotations
import os
from typing import (
Generic,
@ -41,7 +39,15 @@ from openflexure_microscope_server.things.background_detect import (
from openflexure_microscope_server.things.camera import BaseCamera, CaptureParams
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
from openflexure_microscope_server.things.stage import BaseStage
from openflexure_microscope_server.ui import PropertyControl, property_control_for
from openflexure_microscope_server.ui import (
UI_ELEMENT_RESPONSE,
Accordion,
HeaderBlock,
TextBlock,
UIElementList,
action_button_for,
property_control_for,
)
SettingModelType = TypeVar("SettingModelType", bound=BaseModel)
@ -160,9 +166,9 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
return True, focus_height
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
def settings_ui(self) -> UIElementList:
"""Return the UI for the workflow's settings in the scan tab."""
raise NotImplementedError(
"Each scan workflow must implement a settings_ui method."
)
@ -556,35 +562,96 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
return imaged, focus_height
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
return [
property_control_for(
self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05
),
property_control_for(
self, "stack_images_to_save", label="Images in Stack to Save"
),
property_control_for(
self,
"stack_min_images_to_test",
label="Minimum number of images to test for focus",
),
property_control_for(self, "stack_dz", label="Stack dz (steps)", step=5),
property_control_for(
self, "autofocus_dz", label="Autofocus Range (steps)", step=200
),
property_control_for(
self, "max_range", label="Maximum Distance (steps)", step=1000
),
property_control_for(
self, "skip_background", label="Detect and Skip Empty Fields"
),
property_control_for(
self, "equal_distances", label="Set Equal x and y Distances"
),
]
@lt.action
def check_background(self) -> str:
"""Check if sample is background.
This action is a pre-run check for feeding back to the user.
"""
image_array = self._cam.grab_as_array(stream_name="lores")
is_sample, bg_message = self._background_detector.image_is_sample(image_array)
label = "sample" if is_sample else "background"
return f"Current image is {label} ({bg_message})"
@lt.action
def set_background(self) -> None:
"""Set the background for this background detector.
This sets the background for this workflow's background detector as opposed to
the active background detector for the camera.
"""
image_array = self._cam.grab_as_array(stream_name="lores")
self._background_detector.set_background(image_array)
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
def settings_ui(self) -> UIElementList:
"""Return the UI for the workflow's settings in the scan tab."""
scan_settings = UIElementList(
[
property_control_for(
self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05
),
property_control_for(
self, "stack_images_to_save", label="Images in Stack to Save"
),
property_control_for(
self,
"stack_min_images_to_test",
label="Minimum number of images to test for focus",
),
property_control_for(
self, "stack_dz", label="Stack dz (steps)", step=5
),
property_control_for(
self, "autofocus_dz", label="Autofocus Range (steps)", step=200
),
property_control_for(
self, "max_range", label="Maximum Distance (steps)", step=1000
),
property_control_for(
self, "skip_background", label="Detect and Skip Empty Fields"
),
property_control_for(
self, "equal_distances", label="Set Equal x and y Distances"
),
]
)
background_ui = self._background_detector.settings_ui()
set_bg_button = action_button_for(
self,
"set_background",
poll_interval=0.1,
submit_label="Set Background",
can_terminate=False,
notify_on_success=True,
success_message="Background image has been updated",
update_interface_on_response=True,
)
check_bg_button = action_button_for(
self,
"check_background",
poll_interval=0.1,
submit_label="Check Current Image",
disabled=not self._background_detector.ready,
can_terminate=False,
notify_on_success=True,
response_is_success_message=True,
)
background_ui.root += [set_bg_button, check_bg_button]
return UIElementList(
[
HeaderBlock(text=self.display_name, level=4),
TextBlock(text=self.ui_blurb),
Accordion(title="Background Detect", children=background_ui),
Accordion(
title="Scan Settings",
children=scan_settings,
),
]
)
class RegularGridSettingsModel(RectGridSettingsModel):
@ -668,17 +735,31 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
save_resolution=settings.capture_params.save_resolution,
)
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
return [
property_control_for(
self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05
),
property_control_for(self, "x_count", label="Number of columns"),
property_control_for(self, "y_count", label="Number of rows"),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),
]
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
def settings_ui(self) -> UIElementList:
"""Return the UI for the workflow's settings in the scan tab."""
scan_settings = UIElementList(
[
property_control_for(
self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05
),
property_control_for(self, "x_count", label="Number of columns"),
property_control_for(self, "y_count", label="Number of rows"),
property_control_for(
self, "autofocus_dz", label="Autofocus Range (steps)"
),
]
)
return UIElementList(
[
HeaderBlock(text=self.display_name, level=4),
TextBlock(text=self.ui_blurb),
Accordion(
title="Scan Settings",
children=scan_settings,
),
]
)
class SnakeWorkflow(RegularGridWorkflow):

View file

@ -1,11 +1,155 @@
"""Functionality for communicating the required user interface for a thing."""
from typing import Any, Optional
from html import escape
from html.parser import HTMLParser
from typing import Annotated, Any, Literal, Optional
from urllib.parse import urlparse
from pydantic import BaseModel
from pydantic import BaseModel, BeforeValidator, Field, RootModel
import labthings_fastapi as lt
# Only allowed tags are italic, bold, and link. Also allows self closing br tags.
ALLOWED_TAGS = {"i", "b", "a"}
ALLOWED_SCHEMES = {"http", "https"}
def strip_control_chars(input_string: str) -> str:
"""Remove unprintable characters."""
return "".join(char for char in input_string if char.isprintable())
def sanitize_http_url(url: str) -> Optional[str]:
"""Santitise any url only allowing http and https without queries."""
url = strip_control_chars(url)
parsed = urlparse(url)
if parsed.scheme not in ALLOWED_SCHEMES:
return None
if not parsed.netloc:
return None
fragment_str = "#" + parsed.fragment if parsed.fragment else ""
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}{fragment_str}"
class SafeHTMLParser(HTMLParser):
"""An HTMLParser that only allows a very limited subset of HTML."""
def __init__(self) -> None:
"""Initialise the parser."""
super().__init__()
self.output = ""
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
"""Append a start tag found in the HTML if it is in the allowed list.
This is called by the base class when a start tag is found. We only append
the tag if it is our allowed list. We re-write the tag so no extra attributes
can be added.
No tags can have attributes except for ``a`` tags, why only allow ``href``. We then
append ``target="_blank" rel="noopener noreferrer"`` to ensure the link opens
in a new window.
:param tag: the tag name
:param attrs: the attributes for the tag.
"""
if tag not in ALLOWED_TAGS:
return
if tag == "a":
href = None
for attr, value in attrs:
if attr == "href" and value is not None:
# If it is not a httplink this will stay as None
href = sanitize_http_url(value.strip())
if href:
safe_href = escape(href, quote=True)
# Always in a new tab
self.output += (
f'<a href="{safe_href}" target="_blank" rel="noopener noreferrer">'
)
else:
self.output += "<a>"
else:
self.output += f"<{tag}>"
def handle_endtag(self, tag: str) -> None:
"""Append the end tag only if it is in the allowed list."""
if tag in ALLOWED_TAGS:
self.output += f"</{tag}>"
def handle_startendtag(
self, tag: str, _attrs: list[tuple[str, Optional[str]]]
) -> None:
"""Append a self-closing tag if it is a new line."""
if tag == "br":
self.output += "<br/>"
def handle_data(self, data: str) -> None:
"""Append any data inside tags after escaping it."""
self.output += escape(data)
def handle_entityref(self, name: str) -> None:
"""Append any named character."""
self.output += f"&{name};"
def handle_charref(self, name: str) -> None:
"""Append any character references."""
self.output += f"&#{name};"
def sanitise_html(html: str) -> str:
"""Santitise HTML to only have a small list of allowed tags.
Tags allowed without attrs: ``<i>, <b>,``
Tags allowed with attrs: ``<a>`` is allowed with ``href`` only. This automatically
appends ``target="_blank" rel="noopener noreferrer"`` so the link opens externally.
Self closing tags allowed ``<br>``.
:param html: The input HTML.
:return: The sanitised HTML.
"""
parser = SafeHTMLParser()
parser.feed(html)
parser.close()
return parser.output
HtmlFragment = Annotated[str, BeforeValidator(sanitise_html)]
class HeaderBlock(BaseModel):
"""The data required for header."""
element_type: Literal["header_block"] = "header_block"
text: HtmlFragment
"""The header text (can include basic HTML tags)."""
level: int = Field(default=2, ge=1, le=6)
"""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):
"""The data required for creating an actionButton in Vue.
@ -13,13 +157,15 @@ class ActionButton(BaseModel):
Currently this cannot be used to submitData, or to submitOnEvent.
"""
element_type: Literal["action_button"] = "action_button"
thing: str
"""The Thing "path" for the Thing instance."""
action: str
"""The name of the action to be triggered."""
poll_interval: int = 1
poll_interval: float = 1
"""The interval for polling in seconds."""
submit_label: str = "Submit"
@ -37,6 +183,9 @@ class ActionButton(BaseModel):
button_primary: bool = True
"""Specify whether the button is styled as a primary button."""
disabled: bool = False
"""Specify whether the button is disabled."""
modal_progress: bool = False
"""Specify whether to show a progress modal."""
@ -50,9 +199,21 @@ class ActionButton(BaseModel):
notify_on_success: bool = False
"""Specify whether to a notification on successful completion."""
response_is_success_message: bool = False
"""Set True to use the action response as the success message.
If ``success_message`` is set, this is never used.
"""
success_message: str = "Success!"
"""The message to show on successful completion."""
update_interface_on_response: bool = False
"""If True the action button emits a requestUpdate signal when completed.
Downstream components can subscribe to this and request an update.
"""
def action_button_for(thing: lt.Thing, action_name: str, **kwargs: Any) -> ActionButton:
"""Create a ActionButton data for the specified Thing Action.
@ -72,6 +233,8 @@ class PropertyControl(BaseModel):
Currently this cannot be used to submitData, or to submitOnEvent.
"""
element_type: Literal["property_control"] = "property_control"
thing: str
"""The Thing "path" for the Thing instance."""
@ -121,3 +284,63 @@ def property_control_for(
if "label" not in kwargs:
kwargs["label"] = property_name
return PropertyControl(thing=thing.name, property_name=property_name, **kwargs)
class Accordion(BaseModel):
"""The data required for creating an accordion in the UI.
The HTML is limited to a small number of tags.
"""
element_type: Literal["accordion"] = "accordion"
title: str
children: "UIElementList"
class Container(BaseModel):
"""The data required for creating ``<div>`` in the UI.
The HTML is limited to a small number of tags.
"""
element_type: Literal["container"] = "container"
css_class: str
children: "UIElementList"
UIElementModels = (
HeaderBlock
| TextBlock
| BulletBlock
| PropertyControl
| ActionButton
| Accordion
| Container
)
class UIElementList(RootModel[list[UIElementModels]]):
"""A list of user interface elements.
Note! Two important things to consider:
* This cannot be the return of a lt.property. To fully describe the UI recursion
is used. Web of Things DataSchema doesn't support recursion. Use an
lt.endpoint instead
* If the module using this class imports future annotations then this will cause
havoc with pydantics forward references.
"""
# Resolve forward references so Pydantic can build a sensible recursive schema
Accordion.model_rebuild()
Container.model_rebuild()
UIElementList.model_rebuild()
# This constant can be used as the value for the `responses` argument of lt.endpoint.
UI_ELEMENT_RESPONSE = {
200: {
"description": "A list of UI element specifications.",
"model": UIElementList,
}
}

View file

@ -0,0 +1,109 @@
"""Read multi-line test cases from markdown files.
When checking if text coercion is working multiline Python can be very difficult
to read and maintain. This file allows the input and expected results to be written
into markdown codeblocks.
Each test is a second level heading.
"""
import re
# Level 2 markdown heading. Group 1 is the text
CASE_REGEX = re.compile(r"^##\s(.*)", re.MULTILINE)
# Level 3 markdown heading. Group 1 is the text
LEVEL3_REGEX = re.compile(r"^###\s(.*)", re.MULTILINE)
# Markdown code block.
# Group 1 is the language specification after the back ticks
# Group 2 is the code
CODE_REGEX = re.compile(
r"^```([a-zA-Z]+)?\s?\n(?:(.*?)\n)?```", re.MULTILINE | re.DOTALL
)
class MdTestCase:
"""A class to represent a test case read from a markdown file.
Useful attributes:
* title - The title of the test case
* input_code - the input code for the test case
* result_code - the expected result code for the test case
"""
def __init__(self, title: str, case_text: str, block_type: str) -> None:
"""Initialise the test case.
:param title: The title of the test case.
:param case_text: The full text of the test case.
:param block_type: The type of code block expected (the label after the triple
backticks).
"""
self.title = title
self.block_type = block_type
self.input_code, self.result_code = self._parse(case_text)
def __repr__(self):
"""Return the string representation of the test case."""
return f"<MdTestCase: {self.title}>"
def _parse(self, case_text: str) -> tuple[str, str]:
"""Parse the case text and return the input and expected return code."""
h3_matches = list(LEVEL3_REGEX.finditer(case_text))
n_h3_matches = len(h3_matches)
if n_h3_matches != 2:
raise ValueError(
f"Case {self.title} has {n_h3_matches} level 3 headings. 2 expected."
)
if h3_matches[0].group(1) != "Input" or h3_matches[1].group(1) != "Result":
raise ValueError(
f"Case {self.title} does not have `Input` and `Result` headings."
)
input_section = case_text[h3_matches[0].end() : h3_matches[1].start()]
result_section = case_text[h3_matches[1].end() : None]
input_code_blocks = list(CODE_REGEX.finditer(input_section))
result_code_blocks = list(CODE_REGEX.finditer(result_section))
if len(input_code_blocks) != 1 or len(result_code_blocks) != 1:
raise ValueError(
f"Case {self.title} must have exactly 1 code block per section."
)
input_code_type = input_code_blocks[0].group(1)
input_code = input_code_blocks[0].group(2)
result_code_type = result_code_blocks[0].group(1)
result_code = result_code_blocks[0].group(2)
if input_code_type != self.block_type or result_code_type != self.block_type:
raise ValueError(
f"Case {self.title}: each code block must be labeled as "
f"{self.block_type}"
)
# If the code block is empty Regex returns None. Coerce to empty string.
input_code = "" if input_code is None else input_code.strip()
result_code = "" if result_code is None else result_code.strip()
return input_code, result_code
def find_test_cases(path: str, block_type: str) -> list[MdTestCase]:
"""Return all test cases in the markdown file.
:param path: Path to the markdown file containing cases.
:param block_type: The label for each triple backtick code block.
:return: A list of MdTestCase objects for each case.
"""
with open(path, "r", encoding="utf-8") as file_obj:
text = file_obj.read()
test_case_headings = list(CASE_REGEX.finditer(text))
test_cases = []
n_headings = len(test_case_headings)
for i, heading_match in enumerate(test_case_headings):
title = heading_match.group(1)
start = heading_match.end()
end = test_case_headings[i + 1].start() if i + 1 < n_headings else None
case_text = text[start:end]
test_cases.append(MdTestCase(title, case_text, block_type))
return test_cases

View file

@ -0,0 +1,125 @@
# HTML Sanitisation Test Cases
This file is in markdown not Python for readability.
Each test is a level 2 heading. The input and expected values are level 3 headings. Multiline inputs and results are treated as single, multiline strings.
The HTML to test is in triple backtick code blocks.
Anything not in code blocks is a comment.
This file is run by /tests/unit_tests/test_ui.py
## Test scripts removed
The script is removed but the contents remain.
### Input
```html
<script>alert("x")</script>
<p>Hello</p>
```
### Result
```html
alert(&quot;x&quot;)
Hello
```
## Test script in bold tag
### Input
```html
<b onmouseover=alert('Wufff!')>click me!</b>
```
### Result
```html
<b>click me!</b>
```
## Test script in disallowed tag
### Input
```html
<img src="http://url.to.file.which/not.exist" onerror=alert(document.cookie);>
```
### Result
```html
```
## Test cookie grabber script is escaped
The script should become escaped and have no tags
### Input
```html
<SCRIPT type="text/javascript">
var adr = '../evil.php?cakemonster=' + escape(document.cookie);
</SCRIPT>
```
### Result
```html
var adr = &#x27;../evil.php?cakemonster=&#x27; + escape(document.cookie);
```
## Test with non-http links
Remove the javascript from the links.
### Input
```html
<a href="javascript:alert(1)">link</a>
<a href="javas&#99;ript:alert(1)">encoded</a>
<a href="mailto:spammy@spam.com">Email</a>
<a href="/a/relateive/link">Relative</a>
```
### Result
```html
<a>link</a>
<a>encoded</a>
<a>Email</a>
<a>Relative</a>
```
## Test http links become external
Remove the javascript from the links.
### Input
```html
<a href="https://openflexure.org">OpenFlexure</a>
```
### Result
```html
<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.
```
## Test http link with path preserved
Valid HTTP links should preserve their path.
### Input
```html
<a href="https://example.com/docs/page">Docs</a>
```
### Result
```html
<a href="https://example.com/docs/page" target="_blank" rel="noopener noreferrer">Docs</a>
```
## Test http link with fragment preserved
Valid HTTP links should preserve their path and fragment.
### Input
```html
<a href="https://example.com/docs/page#section">Docs Section</a>
```
### Result
```html
<a href="https://example.com/docs/page#section" target="_blank" rel="noopener noreferrer">Docs Section</a>
```

View file

@ -73,7 +73,7 @@ def test_partial_base_classes():
bad_algo.ready
with pytest.raises(NotImplementedError):
bad_algo.settings_ui
bad_algo.settings_ui()
with pytest.raises(NotImplementedError):
bad_algo.set_background(background_image)
@ -256,7 +256,7 @@ def test_background_detect_settings_ui(detector_cls):
As both have identical settings they can be tested together
"""
detector = create_thing_without_server(detector_cls)
ui = detector.settings_ui
ui = detector.settings_ui().root
assert len(ui) == 2
assert isinstance(ui[0], PropertyControl)

View file

@ -18,7 +18,14 @@ from openflexure_microscope_server.things.scan_workflows import (
HistoScanWorkflow,
ScanWorkflow,
)
from openflexure_microscope_server.ui import PropertyControl
from openflexure_microscope_server.ui import (
Accordion,
ActionButton,
HeaderBlock,
PropertyControl,
TextBlock,
UIElementList,
)
def test_partial_base_classes():
@ -56,7 +63,7 @@ def test_partial_base_classes():
bad_workflow.acquisition_routine(settings, xyz_pos=[0, 0, 0])
with pytest.raises(NotImplementedError):
bad_workflow.settings_ui
bad_workflow.settings_ui()
@pytest.fixture
@ -370,15 +377,49 @@ def test_histo_acquisition_on_sample(histo_workflow, mocker, caplog):
assert histo_workflow._autofocus.run_smart_stack.call_count == 2
def test_histo_workflow_settings_ui(histo_workflow):
def test_histo_workflow_settings_ui(histo_workflow, mocker):
"""Check that the workflow specifies the expected controls."""
ui = histo_workflow.settings_ui
mock_prop_control = mocker.Mock(spec=PropertyControl)
histo_workflow._background_detector.settings_ui.return_value = UIElementList(
[mock_prop_control]
)
assert len(ui) == 8
for element in ui:
ui = histo_workflow.settings_ui().root
# The UI is in 4 blocks
assert len(ui) == 4
# The top is a header
assert isinstance(ui[0], HeaderBlock)
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
assert isinstance(ui[2], Accordion)
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 = ui[2].children.root
# what the detector specifies as it settings ...
assert background_ui[0] is mock_prop_control
# plus 2 action buttons
assert len(background_ui) == 3
assert isinstance(background_ui[1], ActionButton)
assert background_ui[1].action == "set_background"
assert isinstance(background_ui[2], ActionButton)
assert background_ui[2].action == "check_background"
# Finally check the contents of the scan settings accordion
scan_settings = ui[3].children.root
assert len(scan_settings) == 8
for element in scan_settings:
assert isinstance(element, PropertyControl)
names = [el.property_name for el in ui]
names = [el.property_name for el in scan_settings]
expected_names = [
"overlap",
"stack_images_to_save",

View file

@ -0,0 +1,24 @@
"""Testing the server specified user interface."""
import os
import pytest
from openflexure_microscope_server import ui
from ..shared_utils.md_test_cases import find_test_cases
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
HTML_SANITISATION_TEST_FILE = os.path.join(
THIS_DIR, "data", "html_sanitisation_test_cases.md"
)
HTML_SANITISATION_TEST_CASES = find_test_cases(HTML_SANITISATION_TEST_FILE, "html")
@pytest.mark.parametrize("test_case", HTML_SANITISATION_TEST_CASES)
def test_html_block_sanitises(test_case):
"""Check each case in the test case file sanitises as expected."""
sanitised = ui.sanitise_html(test_case.input_code).strip()
assert sanitised == test_case.result_code, (
f"Test case: '{test_case.title}': Input code doesn't sanitise as expected."
)

View file

@ -82,7 +82,6 @@ import tabContent from "./genericComponents/tabContent.vue";
// Import new content components
import aboutContent from "./tabContentComponents/aboutContent.vue";
import backgroundDetectContent from "./tabContentComponents/backgroundDetectContent.vue";
import controlContent from "./tabContentComponents/controlContent.vue";
import loggingContent from "./tabContentComponents/loggingContent.vue";
import powerContent from "./tabContentComponents/powerContent.vue";
@ -150,15 +149,6 @@ export default {
component: markRaw(controlContent),
requiredThings: [],
},
{
id: "background-detect",
title: "Background Detect",
icon: "background_replace",
component: markRaw(backgroundDetectContent),
// While stage isn't needed; automatic background detect has little function
// for a manual microscope.
requiredThings: ["stage"],
},
{
id: "slide-scan",
title: "Slide Scan",

View file

@ -0,0 +1,23 @@
<template>
<ul uk-accordion="multiple: true">
<li>
<a class="uk-accordion-title" href="#">{{ title }}</a>
<div class="uk-accordion-content">
<slot></slot>
</div>
</li>
</ul>
</template>
<script>
export default {
name: "SimpleAccordion",
props: {
title: {
type: String,
required: true,
},
},
};
</script>
<style scoped></style>

View file

@ -322,6 +322,9 @@ export default {
} else if (response.data.status == "cancelled") {
this.$emit("cancelled", response.data);
this.modalNotify(`The action '${this.submitLabel}' was cancelled.`);
} else if (response.data.status == "error") {
const err_msg = this.log?.[0]?.message ?? "Unknown error";
this.$emit("error", err_msg);
}
}
this.taskUrl = null;

View file

@ -8,6 +8,7 @@
:requires-confirmation="actionData.requires_confirmation"
:confirmation-message="actionData.confirmation_message"
:button-primary="actionData.button_primary"
:is-disabled="actionData.disabled"
:modal-progress="actionData.modal_progress"
:stream-with-modal="actionData.stream_with_modal"
@response="actionResponse"
@ -34,7 +35,7 @@ export default {
},
},
emits: ["response", "finished"],
emits: ["response", "finished", "requestUpdate"],
methods: {
/**
@ -45,7 +46,14 @@ export default {
*/
actionResponse: function (response) {
if (this.actionData.notify_on_success) {
this.modalNotify(this.actionData.success_message);
if (this.actionData.response_is_success_message) {
this.modalNotify(response.output);
} else {
this.modalNotify(this.actionData.success_message);
}
if (this.actionData.update_interface_on_response) {
this.$emit("requestUpdate");
}
this.$emit("response", response);
}
},

View file

@ -0,0 +1,66 @@
<template>
<div v-for="(element, index) in elements" :key="index" class="uk-margin">
<!-- eslint-disable vue/no-v-html -->
<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 -->
<server-specified-property-control
v-else-if="element.element_type === 'property_control'"
:property-data="element"
/>
<server-specified-action-button
v-else-if="element.element_type === 'action_button'"
:action-data="element"
@request-update="$emit('requestUpdate')"
/>
<simple-accordion v-else-if="element.element_type === 'accordion'" :title="element.title">
<server-specified-interface
:elements="element.children"
@request-update="$emit('requestUpdate')"
/>
</simple-accordion>
<div v-else-if="element.element_type === 'container'" :class="element.css_class">
<server-specified-interface
:elements="element.children"
@request-update="$emit('requestUpdate')"
/>
</div>
</div>
</template>
<script>
import SimpleAccordion from "../genericComponents/simpleAccordion.vue";
import ServerSpecifiedPropertyControl from "./serverSpecifiedPropertyControl.vue";
import ServerSpecifiedActionButton from "./serverSpecifiedActionButton.vue";
// Export main app
export default {
name: "ServerSpecifiedInterface",
components: {
SimpleAccordion,
ServerSpecifiedPropertyControl,
ServerSpecifiedActionButton,
},
props: {
elements: {
type: Array,
required: true,
},
},
emits: ["requestUpdate"],
};
</script>
<style lang="less"></style>

View file

@ -1,131 +0,0 @@
<template>
<div ref="backgroundDetectContent" class="uk-padding-small">
<div>
<ul uk-accordion="multiple: true">
<li>
<a class="uk-accordion-title" href="#">Configure</a>
<div class="uk-accordion-content">
<h4 v-if="backgroundDetectorDisplayName" class="detector-name">
{{ backgroundDetectorDisplayName }}
</h4>
<server-specified-property-control
v-for="(setting, index) in backgroundDetectorSettings"
:key="'detector_setting' + index"
:property-data="setting"
/>
</div>
</li>
</ul>
<div class="uk-margin">
<action-button
thing="camera"
action="set_background"
submit-label="Set Background"
:can-terminate="false"
:poll-interval="0.1"
@response="alertBackgroundSet"
@error="modalError"
/>
</div>
<div class="uk-margin">
<!--Once status is read this should be disabled if not ready..-->
<action-button
thing="camera"
action="image_is_sample"
submit-label="Check Current Image"
:is-disabled="!ready"
:can-terminate="false"
:poll-interval="0.1"
@response="alertImageLabel"
@error="modalError"
/>
</div>
</div>
</div>
</template>
<script>
import ActionButton from "../../labThingsComponents/actionButton.vue";
import ServerSpecifiedPropertyControl from "../../labThingsComponents/serverSpecifiedPropertyControl.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default {
components: {
ActionButton,
ServerSpecifiedPropertyControl,
},
data() {
return {
ready: false,
backgroundDetectorName: undefined,
backgroundDetectorDisplayName: undefined,
backgroundDetectorSettings: [],
animate: false,
};
},
async created() {
await this.readSettings();
},
mounted() {
useIntersectionObserver(
this.$refs.backgroundDetectContent,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0,
},
);
},
methods: {
async safeReadSettings() {
if (!this.$store.state.connected) return;
try {
await this.readSettings();
} catch (error) {
console.error("Error reading background detector settings:", error);
}
},
visibilityChanged(isVisible) {
if (isVisible) {
this.safeReadSettings();
}
},
alertBackgroundSet() {
this.modalNotify(`Background image has been updated`);
},
alertImageLabel(r) {
let label = r.output[0] ? "sample" : "background";
this.modalNotify(`Current image is ${label} (${r.output[1]})`);
},
readSettings: async function () {
this.backgroundDetectorName = await this.readThingProperty(
"camera",
"background_detector_name",
);
if (this.backgroundDetectorName) {
this.ready = await this.readThingProperty(this.backgroundDetectorName, "ready");
this.backgroundDetectorSettings = await this.readThingProperty(
this.backgroundDetectorName,
"settings_ui",
);
this.backgroundDetectorDisplayName = await this.readThingProperty(
this.backgroundDetectorName,
"display_name",
);
}
},
},
};
</script>
<style scoped lang="less">
.detector-name {
margin-bottom: 0.5rem;
}
</style>

View file

@ -1,25 +0,0 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component">
<paneBackgroundDetect />
</div>
<div class="view-component uk-width-expand">
<streamDisplay />
</div>
</div>
</template>
<script>
import paneBackgroundDetect from "./backgroundDetectComponents/paneBackgroundDetect";
import streamDisplay from "./streamContent.vue";
export default {
name: "BackgroundDetectContent",
components: {
paneBackgroundDetect,
streamDisplay,
},
};
</script>

View file

@ -16,23 +16,8 @@
</option>
</select>
</div>
<h4 v-if="workflowDisplayName" class="workflow-name">
{{ workflowDisplayName }}
</h4>
<p class="workflow-blurb">{{ workflowBlurb }}</p>
<server-specified-interface :elements="workflowSettings" @request-update="readSettings" />
<ul uk-accordion="multiple: true">
<li>
<a class="uk-accordion-title" href="#">Scan Settings</a>
<div class="uk-accordion-content">
<div
v-for="(setting, index) in workflowSettings"
:key="'detector_setting' + index"
class="uk-margin"
>
<server-specified-property-control :property-data="setting" />
</div>
</div>
</li>
<li class="uk-open">
<a class="uk-accordion-title" href="#">Stitching Settings</a>
<div class="uk-accordion-content">
@ -125,7 +110,7 @@
<script>
import streamDisplay from "./streamContent.vue";
import propertyControl from "../labThingsComponents/propertyControl.vue";
import ServerSpecifiedPropertyControl from "../labThingsComponents/serverSpecifiedPropertyControl.vue";
import ServerSpecifiedInterface from "../labThingsComponents/serverSpecifiedInterface.vue";
import actionLogDisplay from "../labThingsComponents/actionLogDisplay.vue";
import actionProgressBar from "../labThingsComponents/actionProgressBar.vue";
import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
@ -142,7 +127,7 @@ export default {
actionProgressBar,
MiniStreamDisplay,
ActionButton,
ServerSpecifiedPropertyControl,
ServerSpecifiedInterface,
},
data() {
@ -158,8 +143,6 @@ export default {
scan_name: "",
workflowName: undefined,
workflowSettings: [],
workflowDisplayName: undefined,
workflowBlurb: undefined,
workflowOptions: [],
};
},
@ -211,13 +194,7 @@ export default {
if (this.workflowName) {
this.ready = await this.readThingProperty(this.workflowName, "ready", true);
this.workflowSettings =
(await this.readThingProperty(this.workflowName, "settings_ui", true)) || [];
this.workflowDisplayName = await this.readThingProperty(
this.workflowName,
"display_name",
true,
);
this.workflowBlurb = await this.readThingProperty(this.workflowName, "ui_blurb", true);
(await this.getThingEndpoint(this.workflowName, "settings_ui")) || [];
}
},
onScanError: function (error) {
@ -296,10 +273,4 @@ export default {
.control-component {
width: 33%;
}
.workflow-name {
margin-bottom: 0.5rem;
}
.workflow-blurb {
color: #a2a2a2;
}
</style>

View file

@ -135,5 +135,14 @@ export default {
);
return url;
},
async getThingEndpoint(thing, endpoint) {
let url = `${this.$store.getters.baseUri}/${thing}/${endpoint}`;
try {
const response = await axios.get(url);
return response.data;
} catch {
return undefined;
}
},
},
};