Merge branch 'gallery_filter' into 'v3'
Adds filtering to the gallery See merge request openflexure/openflexure-microscope-server!621
This commit is contained in:
commit
3e38722d45
10 changed files with 387 additions and 63 deletions
Binary file not shown.
|
|
@ -295,11 +295,6 @@ class BaseCamera(OFMThing, ABC):
|
||||||
# Register with gallery.
|
# Register with gallery.
|
||||||
_show_data_in_gallery = True
|
_show_data_in_gallery = True
|
||||||
|
|
||||||
@property
|
|
||||||
def gallery_data_name(self) -> str:
|
|
||||||
"""Name under which data shows up in gallery."""
|
|
||||||
return "Captures"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gallery_data_schema(self) -> type[CaptureInfo]:
|
def gallery_data_schema(self) -> type[CaptureInfo]:
|
||||||
"""The schema (BaseModel) for passing data to the gallery."""
|
"""The schema (BaseModel) for passing data to the gallery."""
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,14 @@ class GalleryCompatibleThing(Protocol):
|
||||||
be picked up, but may throw an error when used.
|
be picked up, but may throw an error when used.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
# Ensure it is a Thing:
|
# Ensure it is a Thing:
|
||||||
_thing_server_interface: lt.ThingServerInterface
|
_thing_server_interface: lt.ThingServerInterface
|
||||||
|
|
||||||
# Ensure it is an OFMThing:
|
# Ensure it is an OFMThing:
|
||||||
show_data_in_gallery: bool
|
show_data_in_gallery: bool
|
||||||
|
|
||||||
gallery_data_name: str
|
|
||||||
|
|
||||||
gallery_data_schema: type[BaseModel]
|
gallery_data_schema: type[BaseModel]
|
||||||
|
|
||||||
# Ignore D102: No docstrings for the protocol.
|
# Ignore D102: No docstrings for the protocol.
|
||||||
|
|
@ -44,7 +44,9 @@ class GalleryThing(lt.Thing):
|
||||||
|
|
||||||
all_ofm_things: Mapping[str, OFMThing] = lt.thing_slot()
|
all_ofm_things: Mapping[str, OFMThing] = lt.thing_slot()
|
||||||
|
|
||||||
_gallery_providing_things: Optional[Mapping[str, GalleryCompatibleThing]] = None
|
_gallery_providing_things: Optional[dict[str, GalleryCompatibleThing]] = None
|
||||||
|
_card_types: Optional[list[str]] = None
|
||||||
|
_card_type_map: dict[str, str] = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gallery_providing_things(self) -> Mapping[str, GalleryCompatibleThing]:
|
def gallery_providing_things(self) -> Mapping[str, GalleryCompatibleThing]:
|
||||||
|
|
@ -83,8 +85,45 @@ class GalleryThing(lt.Thing):
|
||||||
# Cast the type of each thing to "GalleryCompatibleThing" as other Things have
|
# Cast the type of each thing to "GalleryCompatibleThing" as other Things have
|
||||||
# been popped.
|
# been popped.
|
||||||
self._gallery_providing_things = cast(
|
self._gallery_providing_things = cast(
|
||||||
Mapping[str, GalleryCompatibleThing], gallery_providers
|
dict[str, GalleryCompatibleThing], gallery_providers
|
||||||
)
|
)
|
||||||
|
self._set_card_types(self._gallery_providing_things)
|
||||||
|
|
||||||
|
def _set_card_types(
|
||||||
|
self, gallery_providers: dict[str, GalleryCompatibleThing]
|
||||||
|
) -> None:
|
||||||
|
"""Find the card types from each provider.
|
||||||
|
|
||||||
|
:param gallery_providers: The dictionary of gallery providing things. If card
|
||||||
|
data cannot be extracted for a Thing it will be popped from the provider
|
||||||
|
dictionary.
|
||||||
|
"""
|
||||||
|
card_types: list[str] = []
|
||||||
|
# cache initial list of keys as it may change in the loop
|
||||||
|
keys = list(gallery_providers.keys())
|
||||||
|
for key in keys:
|
||||||
|
card_schema = gallery_providers[key].gallery_data_schema.schema()
|
||||||
|
props = card_schema["properties"]
|
||||||
|
if "card_type" not in props or "const" not in props["card_type"]:
|
||||||
|
self.logger.error(
|
||||||
|
f"Data from {key} cannot be shown in gallery as data card doesn't "
|
||||||
|
"have a static card_type."
|
||||||
|
)
|
||||||
|
gallery_providers.pop(key)
|
||||||
|
continue
|
||||||
|
card_type = props["card_type"]["const"]
|
||||||
|
if card_type not in card_types:
|
||||||
|
card_types.append(card_type)
|
||||||
|
self._card_type_map[key] = card_type
|
||||||
|
|
||||||
|
self._card_types = card_types
|
||||||
|
|
||||||
|
@lt.property
|
||||||
|
def card_types(self) -> list[str]:
|
||||||
|
"""Names for the card types in the gallery."""
|
||||||
|
if self._card_types is None:
|
||||||
|
raise RuntimeError("Cannot access card_types before server has started.")
|
||||||
|
return self._card_types
|
||||||
|
|
||||||
@lt.property
|
@lt.property
|
||||||
def list_data(self) -> list[dict[str, Any]]:
|
def list_data(self) -> list[dict[str, Any]]:
|
||||||
|
|
@ -108,10 +147,12 @@ class GalleryThing(lt.Thing):
|
||||||
return data_list
|
return data_list
|
||||||
|
|
||||||
@lt.action
|
@lt.action
|
||||||
def delete_all_data(self) -> None:
|
def delete_all_data(self, card_types: list[str]) -> None:
|
||||||
"""Delete all the gallery data on this microscope."""
|
"""Delete all the gallery data on this microscope with the given card types."""
|
||||||
for thing in self.gallery_providing_things.values():
|
for thing in self.gallery_providing_things.values():
|
||||||
try:
|
thing_card_type = self._card_type_map.get(thing.name)
|
||||||
thing.delete_all_gallery_items()
|
if thing_card_type in card_types:
|
||||||
except Exception as e:
|
try:
|
||||||
self.logger.exception(e)
|
thing.delete_all_gallery_items()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.exception(e)
|
||||||
|
|
|
||||||
|
|
@ -170,11 +170,6 @@ class SmartScanThing(OFMThing):
|
||||||
# Register with gallery.
|
# Register with gallery.
|
||||||
_show_data_in_gallery = True
|
_show_data_in_gallery = True
|
||||||
|
|
||||||
@property
|
|
||||||
def gallery_data_name(self) -> str:
|
|
||||||
"""Name under which data shows up in gallery."""
|
|
||||||
return "Scans"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gallery_data_schema(self) -> type[scan_directories.ScanInfo]:
|
def gallery_data_schema(self) -> type[scan_directories.ScanInfo]:
|
||||||
"""The schema (BaseModel) for passing data to the gallery."""
|
"""The schema (BaseModel) for passing data to the gallery."""
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Literal, Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -19,14 +19,19 @@ from openflexure_microscope_server.things.gallery import (
|
||||||
from ..shared_utils.lt_test_utils import LabThingsTestEnv
|
from ..shared_utils.lt_test_utils import LabThingsTestEnv
|
||||||
|
|
||||||
|
|
||||||
def test_error_if_accessing_gallery_things_before_started():
|
def test_error_if_accessing_properties_before_started():
|
||||||
"""Check that gallery_providing_things property errors before init."""
|
"""Check that unfefined properties errors before enter."""
|
||||||
thing = create_thing_without_server(GalleryThing)
|
thing = create_thing_without_server(GalleryThing)
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
RuntimeError,
|
RuntimeError,
|
||||||
match="Cannot access gallery_providing_things before server has started.",
|
match="Cannot access gallery_providing_things before server has started.",
|
||||||
):
|
):
|
||||||
thing.gallery_providing_things
|
thing.gallery_providing_things
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError,
|
||||||
|
match="Cannot access card_types before server has started.",
|
||||||
|
):
|
||||||
|
thing.card_types
|
||||||
|
|
||||||
|
|
||||||
class MockGalleryData(BaseModel):
|
class MockGalleryData(BaseModel):
|
||||||
|
|
@ -34,6 +39,13 @@ class MockGalleryData(BaseModel):
|
||||||
|
|
||||||
mock: str
|
mock: str
|
||||||
foobar: int
|
foobar: int
|
||||||
|
card_type: Literal["Mock"] = "Mock"
|
||||||
|
|
||||||
|
|
||||||
|
class MockGalleryData2(MockGalleryData):
|
||||||
|
"""Mock gallery data with a different card name."""
|
||||||
|
|
||||||
|
card_type: Literal["Mock2"] = "Mock2"
|
||||||
|
|
||||||
|
|
||||||
class MinimalGalleryClass:
|
class MinimalGalleryClass:
|
||||||
|
|
@ -42,13 +54,11 @@ class MinimalGalleryClass:
|
||||||
This should fail the protocol check as it is not an OFMThing
|
This should fail the protocol check as it is not an OFMThing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
gallery_data_name: str = "Mock"
|
|
||||||
|
|
||||||
gallery_data_schema: type[BaseModel] = MockGalleryData
|
gallery_data_schema: type[BaseModel] = MockGalleryData
|
||||||
|
|
||||||
def get_data_for_gallery(self) -> list[BaseModel]:
|
def get_data_for_gallery(self) -> list[BaseModel]:
|
||||||
"""Return a list of Mock Gallery Data."""
|
"""Return a list of Mock Gallery Data."""
|
||||||
return [MockGalleryData(mock="mock", foobar="foobar")]
|
return [self.gallery_data_schema(mock="mock", foobar="foobar")]
|
||||||
|
|
||||||
def delete_all_gallery_items(self) -> None:
|
def delete_all_gallery_items(self) -> None:
|
||||||
"""Mock deleting all the data."""
|
"""Mock deleting all the data."""
|
||||||
|
|
@ -63,16 +73,20 @@ class MinimalGallerySource(OFMThing, MinimalGalleryClass):
|
||||||
show_data_in_gallery = True
|
show_data_in_gallery = True
|
||||||
|
|
||||||
|
|
||||||
|
class MinimalGallerySource2(MinimalGallerySource):
|
||||||
|
"""Another Thing that can show data in the gallery with a different card type."""
|
||||||
|
|
||||||
|
gallery_data_schema: type[BaseModel] = MockGalleryData2
|
||||||
|
|
||||||
|
|
||||||
class BadGallerySource(OFMThing):
|
class BadGallerySource(OFMThing):
|
||||||
"""An OFMThing that almost defines enough to show in the gallery.
|
"""An OFMThing that almost defines enough to show in the gallery.
|
||||||
|
|
||||||
Shouldn't match the protocol as ``gallery_data_name`` is missing.
|
Shouldn't match the protocol as ``gallery_data_schema`` is missing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
show_data_in_gallery = True
|
show_data_in_gallery = True
|
||||||
|
|
||||||
gallery_data_schema: type[BaseModel] = MockGalleryData
|
|
||||||
|
|
||||||
def get_data_for_gallery(self) -> list[BaseModel]:
|
def get_data_for_gallery(self) -> list[BaseModel]:
|
||||||
"""Return a list of Mock Gallery Data."""
|
"""Return a list of Mock Gallery Data."""
|
||||||
return [MockGalleryData(mock="mock", foobar="foobar")]
|
return [MockGalleryData(mock="mock", foobar="foobar")]
|
||||||
|
|
@ -81,6 +95,31 @@ class BadGallerySource(OFMThing):
|
||||||
"""Mock deleting all the data."""
|
"""Mock deleting all the data."""
|
||||||
|
|
||||||
|
|
||||||
|
class BadMockGalleryData(BaseModel):
|
||||||
|
"""Mock gallery data without a card type."""
|
||||||
|
|
||||||
|
mock: str
|
||||||
|
foobar: int
|
||||||
|
|
||||||
|
|
||||||
|
class BadGallerySource2(OFMThing):
|
||||||
|
"""An OFMThing that defines enough to show in the gallery with bad card data.
|
||||||
|
|
||||||
|
Matches the protocol but will be rejected when setting card types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
show_data_in_gallery = True
|
||||||
|
|
||||||
|
gallery_data_schema: type[BaseModel] = BadMockGalleryData
|
||||||
|
|
||||||
|
def get_data_for_gallery(self) -> list[BaseModel]:
|
||||||
|
"""Return a list of Mock Gallery Data."""
|
||||||
|
return [BadMockGalleryData(mock="mock", foobar="foobar")]
|
||||||
|
|
||||||
|
def delete_all_gallery_items(self) -> None:
|
||||||
|
"""Mock deleting all the data."""
|
||||||
|
|
||||||
|
|
||||||
def test_gallery_compatible_protocol():
|
def test_gallery_compatible_protocol():
|
||||||
"""Check the gallery compatible protocol detects compatible classes only."""
|
"""Check the gallery compatible protocol detects compatible classes only."""
|
||||||
# Minimal class has the gallery specific methods but is not an OFMThing.
|
# Minimal class has the gallery specific methods but is not an OFMThing.
|
||||||
|
|
@ -118,18 +157,23 @@ def test_gallery_thing_finds_all_providers(simulation_test_env):
|
||||||
assert camera in gallery.gallery_providing_things.values()
|
assert camera in gallery.gallery_providing_things.values()
|
||||||
assert smart_scan in gallery.gallery_providing_things.values()
|
assert smart_scan in gallery.gallery_providing_things.values()
|
||||||
|
|
||||||
|
# Also check the card types:
|
||||||
|
assert gallery.card_types == ["Capture", "Scan"]
|
||||||
|
assert gallery._card_type_map == {"camera": "Capture", "smart_scan": "Scan"}
|
||||||
|
|
||||||
# Also check the runtime checkable protocol GalleryCompatibleThing works directly
|
# Also check the runtime checkable protocol GalleryCompatibleThing works directly
|
||||||
# when called with isinstance.
|
# when called with isinstance.
|
||||||
assert not isinstance(snake_workflow, GalleryCompatibleThing)
|
assert not isinstance(snake_workflow, GalleryCompatibleThing)
|
||||||
assert isinstance(smart_scan, GalleryCompatibleThing)
|
assert isinstance(smart_scan, GalleryCompatibleThing)
|
||||||
|
|
||||||
|
|
||||||
def test_gallery_logs_for_bad_providers(caplog):
|
@pytest.mark.parametrize("bad_source_class", [BadGallerySource, BadGallerySource2])
|
||||||
|
def test_gallery_logs_for_bad_providers(bad_source_class, caplog):
|
||||||
"""Test that an error is logged if a gallery source doesn't match the protocol."""
|
"""Test that an error is logged if a gallery source doesn't match the protocol."""
|
||||||
# Create a config with the minimal Thing and the bad Thing
|
# Create a config with the minimal Thing and the bad Thing
|
||||||
things = {
|
things = {
|
||||||
"minimal_thing": MinimalGallerySource,
|
"minimal_thing": MinimalGallerySource,
|
||||||
"bad_thing": BadGallerySource,
|
"bad_thing": bad_source_class,
|
||||||
"gallery": GalleryThing,
|
"gallery": GalleryThing,
|
||||||
}
|
}
|
||||||
with caplog.at_level(logging.INFO):
|
with caplog.at_level(logging.INFO):
|
||||||
|
|
@ -155,7 +199,7 @@ def minimal_gallery_env():
|
||||||
"""Return a minimal working environment for testing the gallery."""
|
"""Return a minimal working environment for testing the gallery."""
|
||||||
things = {
|
things = {
|
||||||
"minimal_thing1": MinimalGallerySource,
|
"minimal_thing1": MinimalGallerySource,
|
||||||
"minimal_thing2": MinimalGallerySource,
|
"minimal_thing2": MinimalGallerySource2,
|
||||||
"gallery": GalleryThing,
|
"gallery": GalleryThing,
|
||||||
}
|
}
|
||||||
with LabThingsTestEnv(
|
with LabThingsTestEnv(
|
||||||
|
|
@ -178,8 +222,8 @@ def test_gallery_lists_data(minimal_gallery_env, mocker, caplog):
|
||||||
]
|
]
|
||||||
|
|
||||||
thing_2_data = [
|
thing_2_data = [
|
||||||
MockGalleryData(mock="thing_2", foobar=1),
|
MockGalleryData2(mock="thing_2", foobar=1),
|
||||||
MockGalleryData(mock="thing_2", foobar=2),
|
MockGalleryData2(mock="thing_2", foobar=2),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Also mock the get_data_for_gallery methods.
|
# Also mock the get_data_for_gallery methods.
|
||||||
|
|
@ -188,12 +232,12 @@ def test_gallery_lists_data(minimal_gallery_env, mocker, caplog):
|
||||||
|
|
||||||
# Set the expected returns.
|
# Set the expected returns.
|
||||||
thing_1_return = [
|
thing_1_return = [
|
||||||
{"mock": "thing_1", "foobar": 1},
|
{"mock": "thing_1", "foobar": 1, "card_type": "Mock"},
|
||||||
{"mock": "thing_1", "foobar": 2},
|
{"mock": "thing_1", "foobar": 2, "card_type": "Mock"},
|
||||||
]
|
]
|
||||||
thing_2_return = [
|
thing_2_return = [
|
||||||
{"mock": "thing_2", "foobar": 1},
|
{"mock": "thing_2", "foobar": 1, "card_type": "Mock2"},
|
||||||
{"mock": "thing_2", "foobar": 2},
|
{"mock": "thing_2", "foobar": 2, "card_type": "Mock2"},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Check that the gallery returns the expected concatenated data
|
# Check that the gallery returns the expected concatenated data
|
||||||
|
|
@ -216,24 +260,53 @@ class GalleryDeleteTestCase:
|
||||||
The default save kwargs assume a jpeg.
|
The default save kwargs assume a jpeg.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
cards_to_delete: list[str]
|
||||||
thing_1_side_effect: Optional[Callable]
|
thing_1_side_effect: Optional[Callable]
|
||||||
|
thing_1_call_count: int
|
||||||
thing_2_call_count: int
|
thing_2_call_count: int
|
||||||
# Expect directly calling the action will raise the thing 1 side_effect error
|
# Expect directly calling the action will raise the thing 1 side_effect error
|
||||||
action_errors: bool
|
action_errors: bool
|
||||||
|
|
||||||
|
|
||||||
GALLERY_DELETE_TEST_CASES = [
|
GALLERY_DELETE_TEST_CASES = [
|
||||||
# No errors from thing 1, thing 2 gets called
|
# Delete both types of card.
|
||||||
GalleryDeleteTestCase(
|
GalleryDeleteTestCase(
|
||||||
thing_1_side_effect=None, thing_2_call_count=1, action_errors=False
|
cards_to_delete=["Mock", "Mock2"],
|
||||||
|
thing_1_side_effect=None,
|
||||||
|
thing_1_call_count=1,
|
||||||
|
thing_2_call_count=1,
|
||||||
|
action_errors=False,
|
||||||
|
),
|
||||||
|
# Delete first type of card.
|
||||||
|
GalleryDeleteTestCase(
|
||||||
|
cards_to_delete=["Mock"],
|
||||||
|
thing_1_side_effect=None,
|
||||||
|
thing_1_call_count=1,
|
||||||
|
thing_2_call_count=0,
|
||||||
|
action_errors=False,
|
||||||
|
),
|
||||||
|
# Delete second type of card.
|
||||||
|
GalleryDeleteTestCase(
|
||||||
|
cards_to_delete=["Mock2"],
|
||||||
|
thing_1_side_effect=None,
|
||||||
|
thing_1_call_count=0,
|
||||||
|
thing_2_call_count=1,
|
||||||
|
action_errors=False,
|
||||||
),
|
),
|
||||||
# Thing 1 errors, thing 2 is still called
|
# Thing 1 errors, thing 2 is still called
|
||||||
GalleryDeleteTestCase(
|
GalleryDeleteTestCase(
|
||||||
thing_1_side_effect=RuntimeError, thing_2_call_count=1, action_errors=False
|
cards_to_delete=["Mock", "Mock2"],
|
||||||
|
thing_1_side_effect=RuntimeError,
|
||||||
|
thing_1_call_count=1,
|
||||||
|
thing_2_call_count=1,
|
||||||
|
action_errors=False,
|
||||||
),
|
),
|
||||||
# Thing 1 raises invocation cancelled error. Thing 2 is not called. The InvocationError is raised
|
# Thing 1 raises invocation cancelled error. Thing 2 is not called.
|
||||||
|
# The InvocationError is raised
|
||||||
GalleryDeleteTestCase(
|
GalleryDeleteTestCase(
|
||||||
|
cards_to_delete=["Mock", "Mock2"],
|
||||||
thing_1_side_effect=InvocationCancelledError,
|
thing_1_side_effect=InvocationCancelledError,
|
||||||
|
thing_1_call_count=1,
|
||||||
thing_2_call_count=0,
|
thing_2_call_count=0,
|
||||||
action_errors=True,
|
action_errors=True,
|
||||||
),
|
),
|
||||||
|
|
@ -257,10 +330,10 @@ def test_gallery_calls_delete(test_case, minimal_gallery_env, mocker):
|
||||||
# Call delete_all_data, checking for errors if the test case expects an error.
|
# Call delete_all_data, checking for errors if the test case expects an error.
|
||||||
if test_case.action_errors:
|
if test_case.action_errors:
|
||||||
with pytest.raises(test_case.thing_1_side_effect):
|
with pytest.raises(test_case.thing_1_side_effect):
|
||||||
gallery.delete_all_data()
|
gallery.delete_all_data(test_case.cards_to_delete)
|
||||||
else:
|
else:
|
||||||
gallery.delete_all_data()
|
gallery.delete_all_data(test_case.cards_to_delete)
|
||||||
|
|
||||||
# Check the call counts are as expected from the test case.
|
# Check the call counts are as expected from the test case.
|
||||||
assert thing_1.delete_all_gallery_items.call_count == 1
|
assert thing_1.delete_all_gallery_items.call_count == test_case.thing_1_call_count
|
||||||
assert thing_2.delete_all_gallery_items.call_count == test_case.thing_2_call_count
|
assert thing_2.delete_all_gallery_items.call_count == test_case.thing_2_call_count
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,6 @@ def test_gallery_responses(camera, mocker, caplog):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir, caplog.at_level(logging.INFO):
|
with tempfile.TemporaryDirectory() as tmpdir, caplog.at_level(logging.INFO):
|
||||||
camera._data_dir = tmpdir
|
camera._data_dir = tmpdir
|
||||||
|
|
||||||
assert camera.gallery_data_name == "Captures"
|
|
||||||
assert camera.gallery_data_schema == CaptureInfo
|
assert camera.gallery_data_schema == CaptureInfo
|
||||||
|
|
||||||
# When we start there are no captures
|
# When we start there are no captures
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,48 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A button styled like text. For use in menus
|
||||||
|
.ofm-text-button {
|
||||||
|
display: block;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: @global-color;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.hook-inverse() {
|
||||||
|
.ofm-text-button{
|
||||||
|
color: @inverse-global-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Opaque - button-like colours
|
||||||
|
*
|
||||||
|
* The buttons are transparent. If an element overlays others it should
|
||||||
|
* Look the same as the buttons, but have no transparency.
|
||||||
|
*/
|
||||||
|
.ofm-opaque-element{
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.hook-inverse() {
|
||||||
|
.ofm-opaque-element{
|
||||||
|
background: #282626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofm-opaque-highlight:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.hook-inverse() {
|
||||||
|
.ofm-opaque-highlight:hover {
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Accordion
|
* Accordion
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,7 @@ export default {
|
||||||
|
|
||||||
#container-left {
|
#container-left {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
background-color: rgba(180, 180, 180, 0.025);
|
background-color: rgba(180, 180, 180, 0.025);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
150
webapp/src/components/genericComponents/multiSelectDropdown.vue
Normal file
150
webapp/src/components/genericComponents/multiSelectDropdown.vue
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
<template>
|
||||||
|
<div ref="multiselect" class="multi-select">
|
||||||
|
<div
|
||||||
|
class="dropdown-top ofm-opaque-element ofm-opaque-highlight"
|
||||||
|
:class="{ open: isOpen }"
|
||||||
|
@click="isOpen = !isOpen"
|
||||||
|
>
|
||||||
|
<span class="dropdown-top-text">{{ title }}</span>
|
||||||
|
<span>{{ arrow }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isOpen" class="dropdown-menu ofm-opaque-element">
|
||||||
|
<button class="ofm-text-button ofm-opaque-highlight" @click="selectAll">Select All</button>
|
||||||
|
<button class="ofm-text-button ofm-opaque-highlight" @click="selectNone">Select None</button>
|
||||||
|
<hr class="dropdown-separator" />
|
||||||
|
<label v-for="option in options" :key="option" class="dropdown-item ofm-opaque-highlight">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="uk-checkbox"
|
||||||
|
:value="option"
|
||||||
|
:checked="modelValue.includes(option)"
|
||||||
|
@change="toggleOption(option)"
|
||||||
|
/>
|
||||||
|
{{ option }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "MultiSelectDropdown",
|
||||||
|
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "Filter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ["update:modelValue"],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
arrow() {
|
||||||
|
return this.isOpen ? "▲" : "▼";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
window.addEventListener("click", this.handleClickOutside);
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeUnmount() {
|
||||||
|
window.removeEventListener("click", this.handleClickOutside);
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
handleClickOutside(event) {
|
||||||
|
if (!this.$refs.multiselect.contains(event.target)) {
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleOption(value) {
|
||||||
|
const selected = [...this.modelValue];
|
||||||
|
|
||||||
|
const index = selected.indexOf(value);
|
||||||
|
|
||||||
|
if (index > -1) {
|
||||||
|
selected.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
selected.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit("update:modelValue", selected);
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAll() {
|
||||||
|
this.$emit("update:modelValue", this.options);
|
||||||
|
},
|
||||||
|
|
||||||
|
selectNone() {
|
||||||
|
this.$emit("update:modelValue", []);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
@import url("../../assets/less/variables.less");
|
||||||
|
|
||||||
|
.multi-select {
|
||||||
|
position: relative;
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-top {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-top-text {
|
||||||
|
color: @global-primary-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-top.open {
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
border-left: 1px solid #ccc;
|
||||||
|
border-right: 1px solid #ccc;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-separator {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="galleryDisplay" class="galleryDisplay uk-padding uk-padding-remove-top">
|
<div ref="galleryDisplay" class="gallery-display uk-padding uk-padding-remove-top">
|
||||||
<!-- Gallery nav bar -->
|
<!-- Gallery nav bar -->
|
||||||
<nav class="gallery-navbar uk-navbar-container uk-navbar-transparent" uk-navbar="mode: click">
|
<nav class="gallery-navbar uk-navbar-container uk-navbar-transparent" uk-navbar="mode: click">
|
||||||
<!-- Right side buttons -->
|
<!-- Right side buttons -->
|
||||||
<div class="uk-navbar-right">
|
<div class="uk-navbar-right">
|
||||||
<div class="uk-grid">
|
<div class="uk-grid">
|
||||||
|
<div class="gallery-button">
|
||||||
|
<multi-select-dropdown
|
||||||
|
v-model="selectedCardTypes"
|
||||||
|
:options="allCardTypes"
|
||||||
|
title="Filter Gallery"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="gallery-button">
|
<div class="gallery-button">
|
||||||
<action-button
|
<action-button
|
||||||
class="uk-width-1-1"
|
class="uk-width-1-1"
|
||||||
|
|
@ -25,11 +32,13 @@
|
||||||
thing="gallery"
|
thing="gallery"
|
||||||
action="delete_all_data"
|
action="delete_all_data"
|
||||||
submit-label="Delete All"
|
submit-label="Delete All"
|
||||||
|
:submit-data="{ card_types: selectedCardTypes }"
|
||||||
|
:is-disabled="totalPages == 0"
|
||||||
:can-terminate="true"
|
:can-terminate="true"
|
||||||
:button-primary="false"
|
:button-primary="false"
|
||||||
:modal-progress="true"
|
:modal-progress="true"
|
||||||
:requires-confirmation="true"
|
:requires-confirmation="true"
|
||||||
:confirmation-message="'<p>Are you sure you want to delete all gallery data from the microscope?</p><p>This is <b>irreversible</b>!</p>'"
|
:confirmation-message="deleteAllConfirmationMessage"
|
||||||
@error="modalError"
|
@error="modalError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -54,7 +63,7 @@
|
||||||
<div class="gallery-grid uk-grid-match" uk-grid>
|
<div class="gallery-grid uk-grid-match" uk-grid>
|
||||||
<div v-if="noItems">
|
<div v-if="noItems">
|
||||||
<h2>Nothing to show</h2>
|
<h2>Nothing to show</h2>
|
||||||
<p>There is no captured data to show..</p>
|
<p>There is no captured data to show.</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="itemData in paginatedItems" :key="itemData.id">
|
<div v-for="itemData in paginatedItems" :key="itemData.id">
|
||||||
<gallery-card
|
<gallery-card
|
||||||
|
|
@ -75,14 +84,14 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import PaginateLinks from "@/components/genericComponents/paginateLinks.vue";
|
import PaginateLinks from "@/components/genericComponents/paginateLinks.vue";
|
||||||
|
import MultiSelectDropdown from "@/components/genericComponents/multiSelectDropdown.vue";
|
||||||
import actionButton from "../labThingsComponents/actionButton.vue";
|
import actionButton from "../labThingsComponents/actionButton.vue";
|
||||||
import galleryCard from "./galleryComponents/galleryCard.vue";
|
import galleryCard from "./galleryComponents/galleryCard.vue";
|
||||||
import galleryModal from "./galleryComponents/galleryViewer.vue";
|
import galleryModal from "./galleryComponents/galleryViewer.vue";
|
||||||
import { eventBus } from "../../eventBus.js";
|
import { eventBus } from "../../eventBus.js";
|
||||||
import { useIntersectionObserver } from "@vueuse/core";
|
import { useIntersectionObserver } from "@vueuse/core";
|
||||||
import { useSettingsStore } from "@/stores/settings.js";
|
import { useSettingsStore } from "@/stores/settings.js";
|
||||||
import { mapState, storeToRefs } from "pinia";
|
import { mapState } from "pinia";
|
||||||
import { watch } from "vue";
|
|
||||||
|
|
||||||
// Export main app
|
// Export main app
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -92,6 +101,7 @@ export default {
|
||||||
galleryCard,
|
galleryCard,
|
||||||
galleryModal,
|
galleryModal,
|
||||||
PaginateLinks,
|
PaginateLinks,
|
||||||
|
MultiSelectDropdown,
|
||||||
},
|
},
|
||||||
|
|
||||||
emits: ["scrollTop"],
|
emits: ["scrollTop"],
|
||||||
|
|
@ -103,33 +113,49 @@ export default {
|
||||||
osdViewer: null,
|
osdViewer: null,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
itemsPerPage: 18,
|
itemsPerPage: 18,
|
||||||
|
selectedCardTypes: [],
|
||||||
|
allCardTypes: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(useSettingsStore, ["baseUri", "ready"]),
|
...mapState(useSettingsStore, ["baseUri", "ready"]),
|
||||||
|
filtered_items() {
|
||||||
|
return this.all_items.filter((item) => this.selectedCardTypes.includes(item.card_type));
|
||||||
|
},
|
||||||
noItems() {
|
noItems() {
|
||||||
return !this.all_items || this.all_items?.length === 0;
|
return !this.filtered_items || this.filtered_items?.length === 0;
|
||||||
},
|
},
|
||||||
totalPages() {
|
totalPages() {
|
||||||
return Math.ceil((this.all_items?.length || 0) / this.itemsPerPage);
|
return Math.ceil((this.filtered_items?.length || 0) / this.itemsPerPage);
|
||||||
},
|
},
|
||||||
paginatedItems() {
|
paginatedItems() {
|
||||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||||
return (this.all_items || []).slice(start, start + this.itemsPerPage);
|
return (this.filtered_items || []).slice(start, start + this.itemsPerPage);
|
||||||
|
},
|
||||||
|
deleteAllConfirmationMessage() {
|
||||||
|
return `
|
||||||
|
<p>Are you sure you want to delete all gallery data with the following types</p>
|
||||||
|
<ul>
|
||||||
|
${this.selectedCardTypes.map((type) => `<li>${type}</li>`).join("\n")}
|
||||||
|
</ul>
|
||||||
|
<p>from the microscope?</p>
|
||||||
|
<p>This is <b>irreversible</b>!</p>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
totalPages(newPageCount) {
|
||||||
|
if (this.currentPage > newPageCount) {
|
||||||
|
this.currentPage = Math.max(1, newPageCount);
|
||||||
|
} else if (this.currentPage < 1) {
|
||||||
|
this.currentPage == 1;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
async mounted() {
|
async mounted() {
|
||||||
const store = useSettingsStore();
|
|
||||||
const { ready } = storeToRefs(store);
|
|
||||||
this.unwatchStoreFunction = watch(ready, (isReady) => {
|
|
||||||
if (isReady) {
|
|
||||||
this.refreshGallery();
|
|
||||||
} else {
|
|
||||||
this.all_items = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
useIntersectionObserver(
|
useIntersectionObserver(
|
||||||
this.$refs.galleryDisplay,
|
this.$refs.galleryDisplay,
|
||||||
([{ isIntersecting }]) => {
|
([{ isIntersecting }]) => {
|
||||||
|
|
@ -139,6 +165,8 @@ export default {
|
||||||
threshold: 0.0, // Adjust as needed
|
threshold: 0.0, // Adjust as needed
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
this.allCardTypes = await this.readThingProperty("gallery", "card_types");
|
||||||
|
this.selectedCardTypes = this.allCardTypes;
|
||||||
// Update on mount (does nothing if not connected)
|
// Update on mount (does nothing if not connected)
|
||||||
await this.refreshGallery();
|
await this.refreshGallery();
|
||||||
// A global signal listener to perform a gallery refresh
|
// A global signal listener to perform a gallery refresh
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue