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:
commit
2909194d61
17 changed files with 810 additions and 281 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
109
tests/shared_utils/md_test_cases.py
Normal file
109
tests/shared_utils/md_test_cases.py
Normal 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
|
||||
125
tests/unit_tests/data/html_sanitisation_test_cases.md
Normal file
125
tests/unit_tests/data/html_sanitisation_test_cases.md
Normal 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("x")
|
||||
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 = '../evil.php?cakemonster=' + 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="javascript: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>
|
||||
```
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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&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",
|
||||
|
|
|
|||
24
tests/unit_tests/test_ui.py
Normal file
24
tests/unit_tests/test_ui.py
Normal 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."
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
23
webapp/src/components/genericComponents/simpleAccordion.vue
Normal file
23
webapp/src/components/genericComponents/simpleAccordion.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue