From 8088314ac34cce3c26dc06037428868ccbdd3729 Mon Sep 17 00:00:00 2001 From: DianaAliabieva Date: Wed, 3 Jun 2026 20:31:36 +0200 Subject: [PATCH 1/2] graph blocks --- pydatalab/schemas/cell.json | 5 + pydatalab/schemas/equipment.json | 5 + pydatalab/schemas/sample.json | 5 + pydatalab/schemas/startingmaterial.json | 5 + pydatalab/src/pydatalab/blocks/base.py | 4 + pydatalab/src/pydatalab/models/blocks.py | 5 + pydatalab/tasks.py | 3 +- webapp/src/components/SampleInformation.vue | 66 + .../src/components/SampleStagesTimeline.vue | 1329 +++++++++++++++++ 9 files changed, 1426 insertions(+), 1 deletion(-) create mode 100644 webapp/src/components/SampleStagesTimeline.vue diff --git a/pydatalab/schemas/cell.json b/pydatalab/schemas/cell.json index 94dbbb862..ec4347a65 100644 --- a/pydatalab/schemas/cell.json +++ b/pydatalab/schemas/cell.json @@ -215,6 +215,11 @@ "title": "Block Id", "type": "string" }, + "created_at": { + "title": "Created At", + "type": "string", + "format": "date-time" + }, "item_id": { "title": "Item Id", "type": "string" diff --git a/pydatalab/schemas/equipment.json b/pydatalab/schemas/equipment.json index d8c82722a..db1965de1 100644 --- a/pydatalab/schemas/equipment.json +++ b/pydatalab/schemas/equipment.json @@ -179,6 +179,11 @@ "title": "Block Id", "type": "string" }, + "created_at": { + "title": "Created At", + "type": "string", + "format": "date-time" + }, "item_id": { "title": "Item Id", "type": "string" diff --git a/pydatalab/schemas/sample.json b/pydatalab/schemas/sample.json index 92488c652..cb50cc6fd 100644 --- a/pydatalab/schemas/sample.json +++ b/pydatalab/schemas/sample.json @@ -307,6 +307,11 @@ "title": "Block Id", "type": "string" }, + "created_at": { + "title": "Created At", + "type": "string", + "format": "date-time" + }, "item_id": { "title": "Item Id", "type": "string" diff --git a/pydatalab/schemas/startingmaterial.json b/pydatalab/schemas/startingmaterial.json index b51586d6b..bc70a510b 100644 --- a/pydatalab/schemas/startingmaterial.json +++ b/pydatalab/schemas/startingmaterial.json @@ -344,6 +344,11 @@ "title": "Block Id", "type": "string" }, + "created_at": { + "title": "Created At", + "type": "string", + "format": "date-time" + }, "item_id": { "title": "Item Id", "type": "string" diff --git a/pydatalab/src/pydatalab/blocks/base.py b/pydatalab/src/pydatalab/blocks/base.py index 258afd826..35f63b2fa 100644 --- a/pydatalab/src/pydatalab/blocks/base.py +++ b/pydatalab/src/pydatalab/blocks/base.py @@ -1,3 +1,4 @@ +import datetime import functools import pprint import random @@ -183,6 +184,9 @@ def __init__( **self.defaults, } + if "created_at" not in self.data: + self.data["created_at"] = datetime.datetime.now(tz=datetime.timezone.utc) + # convert ObjectId file_ids to string to make handling them easier when sending to and from web if "file_id" in self.data: self.data["file_id"] = str(self.data["file_id"]) diff --git a/pydatalab/src/pydatalab/models/blocks.py b/pydatalab/src/pydatalab/models/blocks.py index 9e33aec65..b0a4f169a 100644 --- a/pydatalab/src/pydatalab/models/blocks.py +++ b/pydatalab/src/pydatalab/models/blocks.py @@ -1,3 +1,5 @@ +import datetime + from pydantic import BaseModel, Field from pydatalab.models.utils import JSON_ENCODERS, PyObjectId @@ -17,6 +19,9 @@ class DataBlockResponse(BaseModel): block_id: str """A shorthand random ID for the block.""" + created_at: datetime.datetime | None = None + """When the block was created, in UTC.""" + item_id: str | None = None """The item that the block is attached to, if any.""" diff --git a/pydatalab/tasks.py b/pydatalab/tasks.py index 3ee818dff..4d8008f17 100644 --- a/pydatalab/tasks.py +++ b/pydatalab/tasks.py @@ -7,7 +7,6 @@ import time import typing -import tomlkit from invoke import Collection, task if typing.TYPE_CHECKING: @@ -173,6 +172,8 @@ def install(_, dev=True): """ + import tomlkit + plugin_cfg = PLUGINS_TOML_PATH deps: list[str] = [] diff --git a/webapp/src/components/SampleInformation.vue b/webapp/src/components/SampleInformation.vue index d47c8b8f1..9602ff35a 100644 --- a/webapp/src/components/SampleInformation.vue +++ b/webapp/src/components/SampleInformation.vue @@ -63,6 +63,10 @@ + +
+ +
@@ -78,6 +82,7 @@ import SynthesisInformation from "@/components/SynthesisInformation"; import SubstanceInformation from "@/components/SubstanceInformation"; import TableOfContents from "@/components/TableOfContents"; import ItemRelationshipVisualization from "@/components/ItemRelationshipVisualization"; +import SampleStagesTimeline from "@/components/SampleStagesTimeline"; export default { components: { @@ -86,6 +91,7 @@ export default { SubstanceInformation, TableOfContents, ItemRelationshipVisualization, + SampleStagesTimeline, FormattedRefcode, ToggleableCollectionFormGroup, ToggleableCreatorsFormGroup, @@ -106,6 +112,7 @@ export default { { title: "Sample Information", targetID: "sample-information" }, { title: "Substance Information", targetID: "substance-information" }, { title: "Synthesis Information", targetID: "synthesis-information" }, + { title: "Sample Stages", targetID: "sample-stages" }, ], }; }, @@ -128,6 +135,65 @@ export default { possibleItemStatuses() { return this.schema?.attributes?.schema?.definitions?.ItemStatus?.enum; }, + sampleStages() { + const item = this.item || {}; + const blocks = item.blocks_obj || {}; + const displayOrder = item.display_order || Object.keys(blocks); + const baseTimestamp = item.date ? new Date(item.date).getTime() : Date.now(); + + return displayOrder + .map((blockId, index) => { + const block = blocks[blockId]; + + if (!block) { + return null; + } + + const blocktype = block.blocktype || "Block"; + const title = block.title || blocktype; + const rawTimestamp = + block.created_at || block.createdAt || block.date_created || block.timestamp; + const timestamp = rawTimestamp + ? rawTimestamp + : new Date(baseTimestamp + index * 10 * 60 * 1000).toISOString(); + const previousBlockId = displayOrder[index - 1]; + const previousBlock = previousBlockId ? blocks[previousBlockId] : null; + const transitionLabel = previousBlock + ? `from ${previousBlock.blocktype || "Block"} to ${blocktype}` + : "initial stage"; + + return { + id: blockId, + block_id: blockId, + blocktype, + full_name: title, + title, + name: title, + timestamp, + detail: block.freeform_comment || "", + metadata: block.metadata || null, + transition_label: transitionLabel, + }; + }) + .filter(Boolean); + }, + }, + methods: { + scrollToBlock(stage) { + const blockId = stage?.block_id || stage?.id; + if (!blockId || typeof document === "undefined") { + return; + } + + const target = document.getElementById(blockId); + if (!target) { + return; + } + + const headerOffset = 88; + const top = target.getBoundingClientRect().top + window.scrollY - headerOffset; + window.scrollTo({ top, behavior: "smooth" }); + }, }, }; diff --git a/webapp/src/components/SampleStagesTimeline.vue b/webapp/src/components/SampleStagesTimeline.vue new file mode 100644 index 000000000..875293644 --- /dev/null +++ b/webapp/src/components/SampleStagesTimeline.vue @@ -0,0 +1,1329 @@ + + + + + From 39f73bd3f8883ce87239614dcc3633ed9b2a1199 Mon Sep 17 00:00:00 2001 From: DianaAliabieva Date: Tue, 9 Jun 2026 15:49:48 +0200 Subject: [PATCH 2/2] graph blocks --- .../src/components/SampleStagesTimeline.vue | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/webapp/src/components/SampleStagesTimeline.vue b/webapp/src/components/SampleStagesTimeline.vue index 875293644..d6ff93a1e 100644 --- a/webapp/src/components/SampleStagesTimeline.vue +++ b/webapp/src/components/SampleStagesTimeline.vue @@ -192,7 +192,7 @@ connectorLabel(stage, visibleStages[index + 1]) }} -