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");
+}
@@ -74,6 +79,7 @@ function showPromptModal() {
'toast',
'inputs',
'modals',
+ 'section header',
]"
>
@@ -247,6 +253,14 @@ function showPromptModal() {
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ {{ props.item.name }}
+
+
+
+
+
+
+
+
+
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(() => {
+
+
+
+ inline button with extra classes
+
+
+
+
+
+
+
+ inline dropdown button with icon and extra classes
+
@@ -261,6 +297,22 @@ function onSectionHeaderAction() {
@action="onSectionHeaderAction"
/>
+
+
+
+
+
+
+
+
@@ -299,4 +351,15 @@ main {
padding: 10px;
}
}
+
+.editable-list-container {
+ display: block;
+ width: 33vw;
+ height: 300px;
+
+ & .editable-list-container-scrollable {
+ height: 100%;
+ overflow-y: auto;
+ }
+}
From 2560383a03c96ca7923446c1bd79920d01eddd8a Mon Sep 17 00:00:00 2001
From: Matthieu Jouis
Date: Mon, 30 Sep 2024 18:25:29 +0200
Subject: [PATCH 08/25] editable list item action and naming
---
frontend/src/assets/fonts/icomoon.eot | Bin 3972 -> 5876 bytes
frontend/src/assets/fonts/icomoon.svg | 47 +++++++-----
frontend/src/assets/fonts/icomoon.ttf | Bin 3808 -> 5712 bytes
frontend/src/assets/fonts/icomoon.woff | Bin 3884 -> 5788 bytes
frontend/src/assets/styles/_icons.css | 74 ++++++++++++++-----
frontend/src/components/DataFrameWidget.vue | 7 +-
frontend/src/components/EditableList.vue | 17 +++--
frontend/src/components/EditableListItem.vue | 51 ++++++++++---
frontend/src/components/ReportCard.vue | 10 +--
frontend/src/components/SectionHeader.vue | 8 +-
frontend/src/views/ComponentsView.vue | 17 ++++-
11 files changed, 159 insertions(+), 72 deletions(-)
diff --git a/frontend/src/assets/fonts/icomoon.eot b/frontend/src/assets/fonts/icomoon.eot
index 6bd6071ff2760b32e1ca7c0a7ae73487ad556e7d..0522958e21dba37b78f5396a08aba6a391056124 100644
GIT binary patch
delta 3415
zcmaJEe{2)y_51FZ?K}JIi+w(0pPig9u^mjD#EyLqNgV7WKX3*mq3t9ZYmO^A((iET`iwjs8%iD}Ah?>i?;
z0JWcd-@W&~_rCYO_ulv3`L*Wz9o+pOLYsqgT!FciI6FVL?x2)I2wjC|a_8Qu{mRoH
zo}T-uT6^Qct#2TN4Oo%s{0+cc_D$`bIaPK22kZ~7ABw&5>Rxbf#w_yoQ`3mgp-aozd<|vT3(ae{7Krc
z$(okS>#~-s<#jq+Fp_dx4CnF=zYOFYnUdL{Vq~x`J4&z+=N!rhVjv(=JIHKZ@QdUv
z`ptQJHVIDCA<^Sy%GO-7*=$bZr>%4N6o6$bg>Nj}F-K;NP+fe>TlQ{2SIv;D4Ji
z7T#}-Fy>&S)nst)42-{~HOUw;Kcl715ko>GB#Rz*qg6GO0GIX@a@n99;PiMRuYDqN
zA*DuAsfe1wVp3I;Q-2-!CrQAwIncU=8K~{%DJJcMGlrihk>lEbA9vx1@Cb1ycX;@GTJuW
zVx3@IOi<&0gm=lY8o#_2HNyC^JM7KRZrU`P_lDg|LZe4IfS-44R3s@AX)H%FlB7I}
zQ$xIu<9xgj;Qc|4`#z>AJ|K);b_Mw51%+X5IYmxcwMyZ{n)ARSzoPho7ufFH38$qd
zRUiz4+9ajJKxmSd2vSozOcIu|UAqHZtD=5zD`X^uGN)y59#Rc1k$sO8h^Ve%yDCMA
zPr-|gaFh5F|AWKR_%E?^I))E1bY|VUOgY0hxzf$n5XA<)-eA=m40@{s+TfFA-v(Mw
z#}+O(r=5O&T?UxT42?6fw58d6vTunm==I6O&_)grL&GSKOsJ?!#`fV%4FXPrNeaNC
zt#J}ju#*Ij6jeJZNCG4r5L&Rm8EzZI+sMZtlQ_pct)oNxTVhR6u4cP?oBP5YEmj9U
z->$dz_`F_UPpjUZ8VN^YUT?lte|t@wV>bz5Pxtz7#?vhKS-O~MS(t0dw9WJuv)QUF
zdpwwF$yi_XdGmT}D%Gmzy}ssfWMmQiVWQu?55l*RSk;ED%igG%NQhCdH_RVCEUfY#BMp5?D-e_OpMgu)j-^_-#&)p)|+@2oeTkNz|(BcJ^B<(?q-TFWY
zzS*cGApfXjBRt3Jvuw5YL8)MjE1{4wZWKzTLScfy6NN&_`g&BIu2jaxD&?K3+L1iV
zyZEr{T<5{cSfw(pMrpSa8VA0TQ5X*?1U85~z}9t0!fBF()0Og=d9d@GE9`Q)&ZRmU
zJA!`}`~L;H{uFnlb}3ba?yDXPsA}LC<+WdgP)r?L`1>NzShJS-Qbp?(f=!Z@$z_P1
zE~btX+Hpc7PXkL87v^koL)kJ*QAr)gPc(M%FYo79>w6rpBx7pW9LcWcO0C!1I`P_e
z{HyGUb*EiY{F3Q%DU#LUC;}0zdUS58~hw6KE%iZjMkL314qov;E*NJWsty&C~t#
zRmRPXGT&hyutBzm-OPTEt8gc|KRLP_C!DLD+nujD&pUtZd;mL=eOu5W1b;+eQ1$1$
L{wu%pyo>(_&;y)z
delta 1629
zcmaJ>Z)h837=NF;%b&|#le;#TyPDjkxs+TJn>MCx+O%E&q$Vw0X+*XTq|nwbG|Ku%
zH&~~*Oq`0im2M}tgCS+O!PE~z{2=O}U!+n+m<*X;L^mA8PkvBPw9)r2oz1M>%X`oJ
zJkR@k{=CogzV~bNtlxUph0qVq3)Td6Jt9wB=sU@)2%*pLH#t2!wIGgtbLqktzQRTR
zUJfBxM9A@RJ~wqVY|RZI#Ktf#k;e+#8{};)=doO$pFOeoBxbOD4wLa?^V3saN#4Zr
z7M5ePQ;Q2oM#mAVlCWGiH#M8PG)(`ABg>!@`12xA3C6a;!vPXzwHPVUfnN8sAv2v!yGxnFs$EL9*|&tpNz8S
z{};bwU6Fvv$A51SZ<
zsbfYgR;7ct;aWTncP9H&$>dnAB-M^3lc~}xiZYYI7cY~}D@rU}q-lqbF2+x1vgyo>qL9(O
zHf3L>&&!Ciedct$Nc$W#T@1$*C7;e@@gb4LEYSw3pRanU@J^&laSO_-Aj`rk(P%D;
z;8waKtJ}AB7*k1xcX5%f(lJF4l$^1X+vUjDvDv>aD)HO%rn)ki>0FGg!qflpV))b7
zoG)vQa_zuSJZ$R;ej93nWEgJ62TMOS@KOywV0TDdsnN#RtatPw*U50B#Fq}u1OhZY
z5e!OffMr>UVI~a4n#4&G$4i58BEd`gmTN9Zk|NKG+nc;3@kGMpUw{DvB=MY7x`9{y
z6t`N~iuS6qtoBA9X%?E*KHGvk=Y^qIP=4-392xw|7
z*a44WbrM=*ZThGDdKeDzgOC*;WW30L=Th@p(@g6?w5)4MDF
zV{YgPx4&k?cm)p(QO~=_`%IMsxsihA(0gEqr(hA@hK~q|xJLX%4wG+@zf()pI<;lt
ZEFG3{(~XFpGwvlGg#M$arkEA3!aoj(T5$jX
diff --git a/frontend/src/assets/fonts/icomoon.svg b/frontend/src/assets/fonts/icomoon.svg
index 6973d2441..c41e8d8c0 100644
--- a/frontend/src/assets/fonts/icomoon.svg
+++ b/frontend/src/assets/fonts/icomoon.svg
@@ -7,23 +7,32 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/assets/fonts/icomoon.ttf b/frontend/src/assets/fonts/icomoon.ttf
index a70024d8fe41fb568539c1dff250a6defa377304..40bb42ffa56281932f69df0e81d839f936240fb5 100644
GIT binary patch
literal 5712
zcmcgweQX<7a({1kxg?k5l1p+)nqT6FL`soFN&J>=eUY|gOL1b$*Zh$LK1;Gr31C~P
z?Og1(X_L0N+#S+7F5F&nXq&)D+r$X!T+#ylBLM>Ru0@p=tVf4bzpoi}ghy_tD4^JbTE#+Zja#$+~n_tBBEKd4rKc@6jRl~YR(
zMelj*3S&Hp{ukDk&OU@^0DXG8)=pmd(pNuz@0;kKXUzD?1FK6X29g7hG8TFOtjQ(+&G(LEjG&**xl^s
z*nR9cTVeH5^a=t-gHi4Ag4N`n|e5`OUJ0Su7z}Z
z>Y?_AXxr46<(TgCD>K%mo_0@rncrhw<<9_|vHJNtTi4rdDba4v#EVkfT9b&>An?-0%ZFU6yzG4Am+s{JV~=e`&X;_&++V
z0{^czuB~tPCY1J6qPMN^$_u!DJeV~abL~5`gYCJ7Av~aCZ!g=$3b1FjUMWY+u$s>m
zYT3{1T0Qz
z87V-_kaITq7}t4et3UxG~|><%{1ejcmqv|2RH@y8;KKAk)9
zZ#X6?NFY_Q#PLTsIUQmM9zm#^m~7ex%u2jEs5@*EyjsUJ_`{v-Ma;j^oK8*4Mus0f
z&^Md0E-P*&lGVP$Pnc;x#m0+HH=ci9!``D
zBl_vSv|kITYC!XZwNOM=|CGy;7WOQ>;tp%u15rhJ&!wx;UAv;H?mE*Uk48fv1=aQI
z@j)Y)^w78`88imtKm?60!Uzt=$ybr#VAo@?Ym)7SP3?WGWJ|mTFGKi<2#R7m^I0yE
zQHm@I8`^tKn@gFBw);ov!ND{?ugIn8>C#L|3%Un;to@QB;`c}9{gH@&o}hyP(+nJx
z^MmQFS9%6rA#J(@;u%HerSzbc6?A5xOBeA6Op?>yf08rKYOIYs+Ld(=hpbiFDhm9L
zq&eD6NJ$$g
zpN}h8pgRoy>*%)7Us@A8n2+wqmed2ufe
zy-%qjl&n>S9(mr?S?Fkng~s`u7_9Y`BPnuRkuK0c)d|uUJGuwfs#he|92+}m;yO4s
z*4znO*AnsjCnjcRCnoNXCw9X8h#t062qld!B)wsp^J8NT%mLK}+-wNg`e^^;#C?f)
zJaON|pp@y`mbS+6RgC_(7${;-E6%>Y2L#tWRXb@)yyQ1
zM)M~0ns3c4Cvh#$wB~ofB`$NNPvH{he4J0t&dTv*YN<6dzcABUN+kunFfU;16@N-E
z6!eteAJ;Bi@a*zmq$21h`FdFXXULZ~DxuF4=*0Lq-@Es7r1&@Meg613*0p2QwhP7R
z=WKi*&JZ=+1K$rvBjqVLf4NbwjvzLw#X`={i_hF*n2wRS=}BuI&*GxTqowI_Q;)xD
zo8}gi{hg+Pa`7m5H0|h7(L1`>>3vG}pS7d)Lb0Gi#*PkY-YD0n_-F3uTW;CoO}oD~
zC`3Kr&|Oa5apiLi`aMtDt;ya$8uEBNDbE3i3N!r$WE8cXd;vrN=ckQDEFv%-aeq{aO%>1)}i&S{wB(!vD4aT+-HVVfRt
zF2g&J?kKCLxgUGyyo2Xa6?UDJ${q5$5gdl1qC_zUQ%!M{FWKwQrt8M=_setnqPOjt
zDj)-HeYb!2Tv@8e0^c|D-XS%pJ!?cYr>gNe?{q(ZTuvwB?YZ(D>SXWReItDL0RQ{)
zoOOM`h=z=|+Z{EmVHe2XFN}N=KU5Rfuz%#nje1$6pL)56cifG#kD3!nh_;cUY)6p2
z9*C`F@6Wgx)F;RTN~xMm?5Vjz!%oCSvSagLFe^&
zVqWhtLOd5UqA}D|q@5A%*ztA97G-sc6}xn?%~LaM56fp|qkA}(M=PV{PbzIn1jqyY
z8Wm|9F(VM$ATg}O^Uu=rJ1`}Glg9o4HC$BsPomh`AOzXgh;3cq`)Gu2>ot19UiLXp
z_S(&6*g;5T7g?gzq-umx0c8=Yow2dDPuqZ9cy)y>ah?^FG9IYN4DOpA8kmj6W(S6*
z{l0zK(b4QjiDEP0DK-x`i=IHUnD~g0`+R<$;EB2NmBZF`w9>o7Q~6{vKQ$a54UcAX
zBUc)rZWI%VVgt;LDcI0ls
z^aNFh%()~xd~T;#QC!MPD9u%$%dN{2&V7#=8Sm}w-@LQcTA`G*(rVp_1O_;2{6IHU
z8O&T(P#VMn@*XM?Bw5#Rnkbs`lI)i8O61ZNqEZw!q`5HQmN|;U-KOc?pGvN@+DMV@
zm1GKVyR~4%Fds>z$g1J{ff4K4ioPiX@J1+38KgOJ%Fvre#Y8~dgxKOg)_gwA3cGdP
zy%UzEeOeD8doH~y+q98oDf9|&y9@@6359|~;d
za+`r6zw}cyZ$Zz^z#yvK&HVA)TYfr{#JfS%o-ZU~86^dUHcgEqq)=`A?d@2l6Spq%
ziG06h&(~KVfl!)Y5<5+Z{J
zfpD-D#k_Xjk;B1q@Xd{2B^V5D^nRBN*g>-+UAO3NS!af?^)b#x`CoeCvz;z5l^H
zc-g@9!_1k-wx4)QqAY@MYm`&O1^b9^RP=I!OMaasy6MW;BQ}oMsQ>?Yi~mNw+Q@@tA$IiUQZ^06c0s5%Zi{zPr5Ppkjz9CcoH-Ql|5_3N&eT>s$u
z7&DMo8FrqmjH|1?=yV#L{c%Tk&YZy6c8~xc@4zzPdIwejKiq*G_`+#+U={d<4y@rl
z@9W5vTyX=VW5k9fz-t{?20YP$6~MpLfgSMwOb4c~aEl#S!*{snA6!{KwZ49uJ;+v2
zZk)os&Q9O6dgknd>!&kgrBQYdzRaCrXF-4g%rk~}kF-=C+ix>!;6U)>coio>@A#dLpxYA#-qL{Sag;;X5I%eGaRyiSL9Y(GvQr>;!Pj
W>;l?@LV-iJ0+107xN}Qxh5rX?^tdAc
delta 1606
zcmaJ>TWs4@7(Ty`b8+k@c3RgiiJeZ(OR*GZ^S8%jt(y+J2Y
zK&(Xw5CW!APqdQ|OcPWB_J9-~cmR|KP^Au`4Q*Ur5Zc64ct=8F%{X?-no+tV`@eqw
zShS2Vif12b*IaIEkXXJT@8r}>ZeAMu{v1N!CHiO~w=hpQPjrLZ
z3)9a%u_D~e6MYdO$LGa-?(v8K=d~W8K0h;
z8pwHMWs?}Th#@|cTbxG6aY=-N)FMI{M)dgv5qMNeYPvDU?m83yP{Lg>*Vw
zBWplWbPG)=fy2P?Fz(%h*}+%N^FC)J;`H&nhh1LgJg(Itqp{lhJ`^HdYm%z3Gr@;y
z>eSYJs;`eqCht+`M&$~u_L0=oovnG%A%yC6zg^Kg9R8(O_G=QFSs%x;Cr+>|=QG${
z8kF~GDf`y{;&-BJ`tTr)Y*4@EXq!FQ4@ubfV1IF7j`KUt#LOHcR+ruGw0v@pO@GVz
z93)
zS-JU*KZM3c!6bWd)s)mR6ZrEnBZEoy0;}i0uKT&+rSAq3J;$5KI^L5QNF@?uO|slH
zmPn*3FRN-HL#8T|E~;ugQeqf~mnmtdGTC&dpsKiSZ%j2;<(n#^8lO3(l^CysVM>v>
zsut6kEZGyXnA_jL`tRI}OYcQ{)do>r6BR{VqgqX-iVbRSbZz_Uj$j&bWEU6htxT(;
zsOAk%epkY;k+3@{YVq51rn$D5nOusl!DIi)V&v0r)x8vMRhkBmXc1d~=!XL_NJQYO
zHdOhoMUd--LAyidE3G!xX1%U|;CwdHDhrj-LLk5}6QPjI1vrkAS$4uOoR@i7<^_33
zqdEmyKkS+fVJr!Pw7n_FvOsm3^7AlgfMkJ}D_;_>UvsT#TW!gpq6Cv|_qB`d!9e^%
zSD15kg*~2dmx~K`U5E#Q#8KJ2qz%Dglj!gBVkmSVXmf(`ctHU#h$=|crw4kW2gsc3
zKk_kmI11rlJk$*j#2}o22jd;OButJT0Spv&GWI$v&CnNVJ56&orp|&X0Bo_kS)0>h
zvD47f+?;3>*|b-gN~MmDq*7Cg*OW(i8GxcO5InXD&g+6yx+HuEqZ3N>qX}2vpNOT6
zNkBLFGgG^qT#60y&m%u&<9eElToqitT}G&i#~a3^-?b$D>-zB?)8s%6NJX>gW3a=c
pun6zKXOv8>Q@8LiK8ydNPt#?3%fef_E#qby2|bN0rIm)S;2#RpQN;iN
diff --git a/frontend/src/assets/fonts/icomoon.woff b/frontend/src/assets/fonts/icomoon.woff
index 4ce99eb825b78acecdd54120a11de397cb3e10b4..b2df8d54bf70d8af62eae2a536f2811f48895423 100644
GIT binary patch
literal 5788
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
delta 1674
zcmaJ>U1%d!6uxKf9$#G_rf{%
z+%w;I&b{}XI~UuoDy}oxOa=iW!x=@aK0AKVRz1>t(Z*6Rw>WlrG}*j~5E_
z%ewDNvbOFDUs!nIY2y0^A=?+ayf56z7xVK^A;i5*Fx|R@&H0fcaT4D(vTo7E@)o{X
zTs*x(d^gE@MwiElT3E^v+@!se*l}IFlDxGzzd{n*Nl?^mh>Vs`&M)SP?=_PA4P7pb
zGk=|0T0Tuq!|mZPu`s@)aWDi8+5{XUx(v_QiT|Gs{1VM(uwfJ1-J`E7*rd5mS*W_H4428;PRxdYPIq{4Hg!aTWwK*Hk|
zTxK;8EFI|*I=eeIspol7T-&r!;^~sFjVx$aD{wInaPaAWrhN5!Q$XFt58B~8d5`$zIZX`ylzqSfeqcCE%
zNqjYGVJ+rc+V74t;ix23rwV}p!_2Cx#05BxlUR0EcU+QqN#X@*G(mL>lD6hNsbVY&
zg1ECKNRmKx8}^GZqJt!Xm#SZpSqt#xvAb?TYBV3WClwKz;BD-EqJElH1?P5b0r
zDs^lkmCDIJ!=B(J0E)&y@YpOkZV6&VZ1@<0vvTB toValue(props.data), () => toValue(props.columns)], () => {