From 45280ea5ba6c8b3a24cec57731653dd5dbd56a2b Mon Sep 17 00:00:00 2001 From: Auguste Baum Date: Mon, 30 Sep 2024 11:16:11 +0200 Subject: [PATCH 01/25] Serialize views in API layer Change project serialization step to include `views` dictionary rather than a single `layout`. --- src/skore/ui/report.py | 20 +++++++++++++------- tests/integration/ui/test_ui.py | 4 ++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/skore/ui/report.py b/src/skore/ui/report.py index ed221e607..309410b67 100644 --- a/src/skore/ui/report.py +++ b/src/skore/ui/report.py @@ -33,15 +33,26 @@ class SerializedItem: created_at: str +@dataclass +class SerializedView: + """Serialized view.""" + + layout: Layout + + @dataclass class SerializedProject: """Serialized project, to be sent to the frontend.""" - layout: Layout items: dict[str, SerializedItem] + views: dict[str, SerializedView] def __serialize_project(project: Project) -> SerializedProject: + views = {} + for key in project.list_view_keys(): + views[key] = project.get_view(key) + items = {} for key in project.list_item_keys(): item = project.get_item(key) @@ -75,14 +86,9 @@ def __serialize_project(project: Project) -> SerializedProject: created_at=item.created_at, ) - try: - layout = project.get_view("layout").layout - except KeyError: - layout = [] - return SerializedProject( - layout=layout, items=items, + views=views, ) diff --git a/tests/integration/ui/test_ui.py b/tests/integration/ui/test_ui.py index 7f83947be..e3f6de047 100644 --- a/tests/integration/ui/test_ui.py +++ b/tests/integration/ui/test_ui.py @@ -37,7 +37,7 @@ def test_get_items(client, project): response = client.get("/api/items") assert response.status_code == 200 - assert response.json() == {"layout": [], "items": {}} + assert response.json() == {"views": {}, "items": {}} project.put("test", "test") item = project.get_item("test") @@ -45,7 +45,7 @@ def test_get_items(client, project): response = client.get("/api/items") assert response.status_code == 200 assert response.json() == { - "layout": [], + "views": {}, "items": { "test": { "media_type": "text/markdown", From eb6716bffea1430040e520ba65f8b436f61717b7 Mon Sep 17 00:00:00 2001 From: Auguste Baum Date: Mon, 30 Sep 2024 11:24:46 +0200 Subject: [PATCH 02/25] Change "set report layout" endpoint to take a `key` argument Remove duplicate test --- src/skore/ui/report.py | 11 +++++++---- tests/integration/ui/test_ui.py | 10 +--------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/skore/ui/report.py b/src/skore/ui/report.py index 309410b67..9df0e4a57 100644 --- a/src/skore/ui/report.py +++ b/src/skore/ui/report.py @@ -131,12 +131,15 @@ def read_asset_content(filename: str): ) -@router.put("/report/layout", status_code=201) -async def set_view_layout(request: Request, layout: Layout): - """Set the view layout.""" +@router.put("/report/view/{key:path}", status_code=201) +async def put_view(request: Request, key: str, layout: Layout): + """Set the layout of the view corresponding to `key`. + + If the view corresponding to `key` does not exist, it will be created. + """ project: Project = request.app.state.project view = View(layout=layout) - project.put_view("layout", view) + project.put_view(key, view) return __serialize_project(project) diff --git a/tests/integration/ui/test_ui.py b/tests/integration/ui/test_ui.py index e3f6de047..941d4cbb1 100644 --- a/tests/integration/ui/test_ui.py +++ b/tests/integration/ui/test_ui.py @@ -67,15 +67,7 @@ def test_share_view(client, project): def test_put_view_layout(client): response = client.put( - "/api/report/layout", - json=[{"key": "test", "size": "large"}], - ) - assert response.status_code == 201 - - -def test_put_view_layout_with_slash_in_name(client): - response = client.put( - "/api/report/layout", + "/api/report/view/hello", json=[{"key": "test", "size": "large"}], ) assert response.status_code == 201 From 3890ba2a8714910a80a25db9e742adc5494639b4 Mon Sep 17 00:00:00 2001 From: Auguste Baum Date: Mon, 30 Sep 2024 15:00:15 +0200 Subject: [PATCH 03/25] Add "delete view" endpoint --- src/skore/ui/report.py | 17 ++++++++++++++++- tests/integration/ui/test_ui.py | 12 ++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/skore/ui/report.py b/src/skore/ui/report.py index 9df0e4a57..48b9ec89e 100644 --- a/src/skore/ui/report.py +++ b/src/skore/ui/report.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Annotated, Any -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request, status from fastapi.params import Depends from fastapi.templating import Jinja2Templates @@ -143,3 +143,18 @@ async def put_view(request: Request, key: str, layout: Layout): project.put_view(key, view) return __serialize_project(project) + + +@router.delete("/report/view/{key:path}", status_code=status.HTTP_200_OK) +async def delete_view(request: Request, key: str): + """Delete the view corresponding to `key`.""" + project: Project = request.app.state.project + + try: + project.delete_view(key) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="View not found" + ) from None + + return __serialize_project(project) diff --git a/tests/integration/ui/test_ui.py b/tests/integration/ui/test_ui.py index 941d4cbb1..666066725 100644 --- a/tests/integration/ui/test_ui.py +++ b/tests/integration/ui/test_ui.py @@ -4,6 +4,7 @@ from skore.persistence.in_memory_storage import InMemoryStorage from skore.project import Project from skore.ui.app import create_app +from skore.view.view import View from skore.view.view_repository import ViewRepository @@ -71,3 +72,14 @@ def test_put_view_layout(client): json=[{"key": "test", "size": "large"}], ) assert response.status_code == 201 + + +def test_delete_view(client, project): + project.put_view("hello", View(layout=[])) + response = client.delete("/api/report/view/hello") + assert response.status_code == 200 + + +def test_delete_view_missing(client): + response = client.delete("/api/report/view/hello") + assert response.status_code == 404 From 4190264b37396e498f88cc88ba677dce36c35dd8 Mon Sep 17 00:00:00 2001 From: Auguste Baum Date: Mon, 30 Sep 2024 15:00:53 +0200 Subject: [PATCH 04/25] Change "Share report" endpoint to take only a view key rather than a layout --- src/skore/ui/report.py | 13 ++++++++++--- tests/integration/ui/test_ui.py | 9 +++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/skore/ui/report.py b/src/skore/ui/report.py index 48b9ec89e..cadf3212c 100644 --- a/src/skore/ui/report.py +++ b/src/skore/ui/report.py @@ -99,16 +99,23 @@ async def get_items(request: Request): return __serialize_project(project) -@router.post("/report/share") +@router.post("/report/share/{view_key:path}") async def share_store( request: Request, - layout: Layout, + view_key: str, templates: Annotated[Jinja2Templates, Depends(get_templates)], static_path: Annotated[Path, Depends(get_static_path)], ): """Serve an inlined shareable HTML page.""" project = request.app.state.project + try: + view = project.get_view(view_key) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="View not found" + ) from None + # Get static assets to inject them into the view template def read_asset_content(filename: str): with open(static_path / filename) as f: @@ -120,7 +127,7 @@ def read_asset_content(filename: str): # Fill the Jinja context context = { "project": asdict(__serialize_project(project)), - "layout": [{"key": item.key, "size": item.size} for item in layout], + "layout": [{"key": item.key, "size": item.size} for item in view.layout], "script": script_content, "styles": styles_content, } diff --git a/tests/integration/ui/test_ui.py b/tests/integration/ui/test_ui.py index 666066725..47bdab624 100644 --- a/tests/integration/ui/test_ui.py +++ b/tests/integration/ui/test_ui.py @@ -59,13 +59,18 @@ def test_get_items(client, project): def test_share_view(client, project): - project.put("test", "test") + project.put_view("hello", View(layout=[])) - response = client.post("/api/report/share", json=[{"key": "test", "size": "large"}]) + response = client.post("/api/report/share/hello") assert response.status_code == 200 assert b"" in response.content +def test_share_view_not_found(client, project): + response = client.post("/api/report/share/hello") + assert response.status_code == 404 + + def test_put_view_layout(client): response = client.put( "/api/report/view/hello", From 943c50b9fea4f392f65b4eb84dca84e34c32b503 Mon Sep 17 00:00:00 2001 From: Auguste Baum Date: Mon, 30 Sep 2024 13:02:16 +0200 Subject: [PATCH 05/25] Remove `LayoutItemSize` --- src/skore/ui/report.py | 2 +- src/skore/view/view.py | 26 +++---------------------- tests/integration/ui/test_ui.py | 5 +---- tests/unit/test_project.py | 7 ++----- tests/unit/view/test_view_repository.py | 9 ++------- 5 files changed, 9 insertions(+), 40 deletions(-) diff --git a/src/skore/ui/report.py b/src/skore/ui/report.py index cadf3212c..bae34b1ce 100644 --- a/src/skore/ui/report.py +++ b/src/skore/ui/report.py @@ -127,7 +127,7 @@ def read_asset_content(filename: str): # Fill the Jinja context context = { "project": asdict(__serialize_project(project)), - "layout": [{"key": item.key, "size": item.size} for item in view.layout], + "layout": view.layout, "script": script_content, "styles": styles_content, } diff --git a/src/skore/view/view.py b/src/skore/view/view.py index 942ad8726..e9a4592e7 100644 --- a/src/skore/view/view.py +++ b/src/skore/view/view.py @@ -1,26 +1,9 @@ """Project View models.""" from dataclasses import dataclass -from enum import StrEnum - -class LayoutItemSize(StrEnum): - """The size of a layout item.""" - - SMALL = "small" - MEDIUM = "medium" - LARGE = "large" - - -@dataclass -class LayoutItem: - """A layout item.""" - - key: str - size: LayoutItemSize - - -Layout = list[LayoutItem] +# An ordered list of keys to display +Layout = list[str] @dataclass @@ -29,10 +12,7 @@ class View: Examples -------- - >>> View(layout=[ - ... {"key": "a", "size": "medium"}, - ... {"key": "b", "size": "small"}, - ... ]) + >>> View(layout=["a", "b"]) View(...) """ diff --git a/tests/integration/ui/test_ui.py b/tests/integration/ui/test_ui.py index 47bdab624..8a38ba992 100644 --- a/tests/integration/ui/test_ui.py +++ b/tests/integration/ui/test_ui.py @@ -72,10 +72,7 @@ def test_share_view_not_found(client, project): def test_put_view_layout(client): - response = client.put( - "/api/report/view/hello", - json=[{"key": "test", "size": "large"}], - ) + response = client.put("/api/report/view/hello", json=["test"]) assert response.status_code == 201 diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index b0b931763..b65ff17bd 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -13,7 +13,7 @@ from skore.item import ItemRepository from skore.persistence.in_memory_storage import InMemoryStorage from skore.project import Project, ProjectLoadError, ProjectPutError, load -from skore.view.view import LayoutItem, LayoutItemSize, View +from skore.view.view import View from skore.view.view_repository import ViewRepository @@ -175,10 +175,7 @@ def test_keys(project): def test_view(project): - layout = [ - LayoutItem(key="key1", size=LayoutItemSize.LARGE), - LayoutItem(key="key2", size=LayoutItemSize.SMALL), - ] + layout = ["key1", "key2"] view = View(layout=layout) diff --git a/tests/unit/view/test_view_repository.py b/tests/unit/view/test_view_repository.py index 0b949e9d6..ed46d271d 100644 --- a/tests/unit/view/test_view_repository.py +++ b/tests/unit/view/test_view_repository.py @@ -1,6 +1,6 @@ import pytest from skore.persistence.in_memory_storage import InMemoryStorage -from skore.view.view import LayoutItem, LayoutItemSize, View +from skore.view.view import View from skore.view.view_repository import ViewRepository @@ -10,12 +10,7 @@ def view_repository(): def test_get(view_repository): - view = View( - layout=[ - LayoutItem(key="key1", size=LayoutItemSize.LARGE), - LayoutItem(key="key2", size=LayoutItemSize.SMALL), - ] - ) + view = View(layout=["key1", "key2"]) view_repository.put_view("view", view) From 261c5be3f89ac3177fcce8e84a2adba77b49d208 Mon Sep 17 00:00:00 2001 From: Matthieu Jouis Date: Mon, 30 Sep 2024 11:21:22 +0200 Subject: [PATCH 06/25] add optional action button to the section header --- frontend/src/components/SectionHeader.vue | 9 +++++++++ frontend/src/views/ComponentsView.vue | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/frontend/src/components/SectionHeader.vue b/frontend/src/components/SectionHeader.vue index 0417c9834..9aa753fef 100644 --- a/frontend/src/components/SectionHeader.vue +++ b/frontend/src/components/SectionHeader.vue @@ -1,13 +1,21 @@ @@ -16,6 +24,7 @@ const props = defineProps<{ display: flex; height: 44px; align-items: center; + justify-content: space-between; padding: var(--spacing-padding-large); border-right: solid var(--border-width-normal) var(--border-color-normal); border-bottom: solid var(--border-width-normal) var(--border-color-normal); diff --git a/frontend/src/views/ComponentsView.vue b/frontend/src/views/ComponentsView.vue index 09d92c4af..20fed8e20 100644 --- a/frontend/src/views/ComponentsView.vue +++ b/frontend/src/views/ComponentsView.vue @@ -11,6 +11,7 @@ import DropdownButton from "@/components/DropdownButton.vue"; import DropdownButtonItem from "@/components/DropdownButtonItem.vue"; import ImageWidget from "@/components/ImageWidget.vue"; import MarkdownWidget from "@/components/MarkdownWidget.vue"; +import SectionHeader from "@/components/SectionHeader.vue"; import SimpleButton from "@/components/SimpleButton.vue"; import Tabs from "@/components/TabsWidget.vue"; import TabsItem from "@/components/TabsWidgetItem.vue"; @@ -58,6 +59,10 @@ function showPromptModal() { console.info("Prompt modal closed with result:", result); }); } + +function onSectionHeaderAction() { + console.info("Section header action"); +} From 2e7af0862eaf61820741df5e47853be6308724ff Mon Sep 17 00:00:00 2001 From: Matthieu Jouis Date: Mon, 30 Sep 2024 15:26:59 +0200 Subject: [PATCH 07/25] editable list: layout and events --- frontend/src/components/DropdownButton.vue | 4 +- .../src/components/DropdownButtonItem.vue | 14 +++- frontend/src/components/EditableList.vue | 44 +++++++++++ frontend/src/components/EditableListItem.vue | 75 +++++++++++++++++++ frontend/src/components/SimpleButton.vue | 9 ++- frontend/src/views/ComponentsView.vue | 63 ++++++++++++++++ 6 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/EditableList.vue create mode 100644 frontend/src/components/EditableListItem.vue diff --git a/frontend/src/components/DropdownButton.vue b/frontend/src/components/DropdownButton.vue index f4cb147d4..1382e354c 100644 --- a/frontend/src/components/DropdownButton.vue +++ b/frontend/src/components/DropdownButton.vue @@ -7,6 +7,7 @@ interface DropdownProps { icon?: string; isPrimary?: boolean; align?: "left" | "right"; + isInline?: boolean; } const props = withDefaults(defineProps(), { isPrimary: false, align: "left" }); @@ -35,6 +36,7 @@ onBeforeUnmount(() => { :is-primary="props.isPrimary" :label="props.label" :icon="props.icon" + :is-inline="props.isInline" @click="isOpen = !isOpen" /> @@ -58,12 +60,10 @@ onBeforeUnmount(() => { display: flex; overflow: visible; flex-direction: column; - padding: var(--spacing-padding-small); border: solid 1px var(--border-color-normal); border-radius: var(--border-radius); background-color: var(--background-color-normal); box-shadow: 4px 10px 20px var(--background-color-selected); - gap: var(--spacing-padding-small); } &.align-right { diff --git a/frontend/src/components/DropdownButtonItem.vue b/frontend/src/components/DropdownButtonItem.vue index 6abccaa22..bd03893bb 100644 --- a/frontend/src/components/DropdownButtonItem.vue +++ b/frontend/src/components/DropdownButtonItem.vue @@ -20,16 +20,26 @@ const props = defineProps(); .dropdown-item { display: inline-block; box-sizing: border-box; - padding: calc(var(--spacing-padding-small) / 2) var(--spacing-padding-small); + padding: var(--spacing-padding-small); border: 0; - border-radius: var(--border-radius); + border-bottom: 1px solid var(--border-color-normal); background-color: var(--background-color-normal); color: var(--text-color-normal); + cursor: pointer; font-size: var(--text-size-normal); font-weight: var(--text-weight-normal); text-align: left; white-space: nowrap; + &:first-child { + border-radius: var(--border-radius) var(--border-radius) 0 0; + } + + &:last-child { + border-radius: 0 0 var(--border-radius) var(--border-radius); + border-bottom: 0; + } + &:hover { background-color: var(--background-color-elevated); } diff --git a/frontend/src/components/EditableList.vue b/frontend/src/components/EditableList.vue new file mode 100644 index 000000000..6239006cb --- /dev/null +++ b/frontend/src/components/EditableList.vue @@ -0,0 +1,44 @@ + + + + + + + diff --git a/frontend/src/components/EditableListItem.vue b/frontend/src/components/EditableListItem.vue new file mode 100644 index 000000000..22e019e4f --- /dev/null +++ b/frontend/src/components/EditableListItem.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/components/SimpleButton.vue b/frontend/src/components/SimpleButton.vue index 8789a734e..4370f95ed 100644 --- a/frontend/src/components/SimpleButton.vue +++ b/frontend/src/components/SimpleButton.vue @@ -6,6 +6,7 @@ interface ButtonProps { label?: string; icon?: string; isPrimary?: boolean; + isInline?: boolean; } const props = withDefaults(defineProps(), { isPrimary: false }); @@ -25,7 +26,7 @@ onBeforeMount(() => {