diff --git a/pydatalab/schemas/sample.json b/pydatalab/schemas/sample.json index 35cb3fc64..9c022af4c 100644 --- a/pydatalab/schemas/sample.json +++ b/pydatalab/schemas/sample.json @@ -3,6 +3,18 @@ "description": "A model for representing an experimental sample.", "type": "object", "properties": { + "synthesis_constituents": { + "title": "Synthesis Constituents", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Constituent" + } + }, + "synthesis_description": { + "title": "Synthesis Description", + "type": "string" + }, "blocks_obj": { "title": "Blocks Obj", "default": {}, @@ -121,24 +133,97 @@ "LiNiO2@C" ], "type": "string" - }, - "synthesis_constituents": { - "title": "Synthesis Constituents", - "default": [], - "type": "array", - "items": { - "$ref": "#/definitions/Constituent" - } - }, - "synthesis_description": { - "title": "Synthesis Description", - "type": "string" } }, "required": [ "item_id" ], "definitions": { + "EntryReference": { + "title": "EntryReference", + "description": "A reference to a database entry by ID and type.\n\nCan include additional arbitarary metadata useful for\ninlining the item data.", + "type": "object", + "properties": { + "type": { + "title": "Type", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "immutable_id": { + "title": "Immutable Id", + "type": "string" + }, + "item_id": { + "title": "Item Id", + "minLength": 1, + "maxLength": 40, + "pattern": "^(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", + "type": "string" + }, + "refcode": { + "title": "Refcode", + "minLength": 1, + "maxLength": 40, + "pattern": "^[a-z]{2,10}:(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", + "type": "string" + } + }, + "required": [ + "type" + ] + }, + "InlineSubstance": { + "title": "InlineSubstance", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "chemform": { + "title": "Chemform", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "Constituent": { + "title": "Constituent", + "description": "A constituent of a sample.", + "type": "object", + "properties": { + "item": { + "title": "Item", + "anyOf": [ + { + "$ref": "#/definitions/EntryReference" + }, + { + "$ref": "#/definitions/InlineSubstance" + } + ] + }, + "quantity": { + "title": "Quantity", + "minimum": 0, + "type": "number" + }, + "unit": { + "title": "Unit", + "default": "g", + "type": "string" + } + }, + "required": [ + "item", + "quantity" + ] + }, "RelationshipType": { "title": "RelationshipType", "description": "An enumeration of the possible types of relationship between two entries.\n\n```mermaid\nclassDiagram\nclass entryC\nentryC --|> entryA: parent\nentryC ..|> entryD\nentryA <..> entryD: sibling\nentryA --|> entryB : child\n```", @@ -556,91 +641,6 @@ "time_added", "is_live" ] - }, - "EntryReference": { - "title": "EntryReference", - "description": "A reference to a database entry by ID and type.\n\nCan include additional arbitarary metadata useful for\ninlining the item data.", - "type": "object", - "properties": { - "type": { - "title": "Type", - "type": "string" - }, - "name": { - "title": "Name", - "type": "string" - }, - "immutable_id": { - "title": "Immutable Id", - "type": "string" - }, - "item_id": { - "title": "Item Id", - "minLength": 1, - "maxLength": 40, - "pattern": "^(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", - "type": "string" - }, - "refcode": { - "title": "Refcode", - "minLength": 1, - "maxLength": 40, - "pattern": "^[a-z]{2,10}:(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", - "type": "string" - } - }, - "required": [ - "type" - ] - }, - "InlineSubstance": { - "title": "InlineSubstance", - "type": "object", - "properties": { - "name": { - "title": "Name", - "type": "string" - }, - "chemform": { - "title": "Chemform", - "type": "string" - } - }, - "required": [ - "name" - ] - }, - "Constituent": { - "title": "Constituent", - "description": "A constituent of a sample.", - "type": "object", - "properties": { - "item": { - "title": "Item", - "anyOf": [ - { - "$ref": "#/definitions/EntryReference" - }, - { - "$ref": "#/definitions/InlineSubstance" - } - ] - }, - "quantity": { - "title": "Quantity", - "minimum": 0, - "type": "number" - }, - "unit": { - "title": "Unit", - "default": "g", - "type": "string" - } - }, - "required": [ - "item", - "quantity" - ] } } } \ No newline at end of file diff --git a/pydatalab/schemas/startingmaterial.json b/pydatalab/schemas/startingmaterial.json index ad1446b3a..861f178bd 100644 --- a/pydatalab/schemas/startingmaterial.json +++ b/pydatalab/schemas/startingmaterial.json @@ -3,6 +3,18 @@ "description": "A model for representing an experimental sample.", "type": "object", "properties": { + "synthesis_constituents": { + "title": "Synthesis Constituents", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Constituent" + } + }, + "synthesis_description": { + "title": "Synthesis Description", + "type": "string" + }, "blocks_obj": { "title": "Blocks Obj", "default": {}, @@ -193,6 +205,91 @@ "item_id" ], "definitions": { + "EntryReference": { + "title": "EntryReference", + "description": "A reference to a database entry by ID and type.\n\nCan include additional arbitarary metadata useful for\ninlining the item data.", + "type": "object", + "properties": { + "type": { + "title": "Type", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "immutable_id": { + "title": "Immutable Id", + "type": "string" + }, + "item_id": { + "title": "Item Id", + "minLength": 1, + "maxLength": 40, + "pattern": "^(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", + "type": "string" + }, + "refcode": { + "title": "Refcode", + "minLength": 1, + "maxLength": 40, + "pattern": "^[a-z]{2,10}:(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", + "type": "string" + } + }, + "required": [ + "type" + ] + }, + "InlineSubstance": { + "title": "InlineSubstance", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "chemform": { + "title": "Chemform", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "Constituent": { + "title": "Constituent", + "description": "A constituent of a sample.", + "type": "object", + "properties": { + "item": { + "title": "Item", + "anyOf": [ + { + "$ref": "#/definitions/EntryReference" + }, + { + "$ref": "#/definitions/InlineSubstance" + } + ] + }, + "quantity": { + "title": "Quantity", + "minimum": 0, + "type": "number" + }, + "unit": { + "title": "Unit", + "default": "g", + "type": "string" + } + }, + "required": [ + "item", + "quantity" + ] + }, "RelationshipType": { "title": "RelationshipType", "description": "An enumeration of the possible types of relationship between two entries.\n\n```mermaid\nclassDiagram\nclass entryC\nentryC --|> entryA: parent\nentryC ..|> entryD\nentryA <..> entryD: sibling\nentryA --|> entryB : child\n```", diff --git a/pydatalab/src/pydatalab/models/samples.py b/pydatalab/src/pydatalab/models/samples.py index 70401d588..3182cca75 100644 --- a/pydatalab/src/pydatalab/models/samples.py +++ b/pydatalab/src/pydatalab/models/samples.py @@ -1,71 +1,15 @@ -from typing import List, Optional +from typing import Optional -from pydantic import Field, root_validator +from pydantic import Field from pydatalab.models.items import Item -from pydatalab.models.utils import Constituent, InlineSubstance +from pydatalab.models.traits import HasSynthesisInfo -class Sample(Item): +class Sample(Item, HasSynthesisInfo): """A model for representing an experimental sample.""" type: str = Field("samples", const="samples", pattern="^samples$") chemform: Optional[str] = Field(example=["Na3P", "LiNiO2@C"]) """A string representation of the chemical formula or composition associated with this sample.""" - - synthesis_constituents: List[Constituent] = Field([]) - """A list of references to constituent materials giving the amount and relevant inlined details of consituent items.""" - - synthesis_description: Optional[str] - """Free-text details of the procedure applied to synthesise the sample""" - - @root_validator - def add_missing_synthesis_relationships(cls, values): - """Add any missing sample synthesis constituents to parent relationships""" - from pydatalab.models.relationships import RelationshipType, TypedRelationship - - constituents_set = set() - if values.get("synthesis_constituents") is not None: - existing_parent_relationship_ids = set() - if values.get("relationships") is not None: - existing_parent_relationship_ids = { - relationship.item_id or relationship.refcode - for relationship in values["relationships"] - if relationship.relation == RelationshipType.PARENT - } - else: - values["relationships"] = [] - - for constituent in values.get("synthesis_constituents", []): - # If this is an inline relationship, just skip it - if isinstance(constituent.item, InlineSubstance): - continue - if ( - constituent.item.item_id not in existing_parent_relationship_ids - and constituent.item.refcode not in existing_parent_relationship_ids - ): - relationship = TypedRelationship( - relation=RelationshipType.PARENT, - item_id=constituent.item.item_id, - type=constituent.item.type, - description="Is a constituent of", - ) - values["relationships"].append(relationship) - - # Accumulate all constituent IDs in a set to filter those that have been deleted - constituents_set.add(constituent.item.item_id) - - # Finally, filter out any parent relationships with item that were removed - # from the synthesis constituents - values["relationships"] = [ - rel - for rel in values["relationships"] - if not ( - rel.item_id not in constituents_set - and rel.relation == RelationshipType.PARENT - and rel.type in ("samples", "starting_materials") - ) - ] - - return values diff --git a/pydatalab/src/pydatalab/models/starting_materials.py b/pydatalab/src/pydatalab/models/starting_materials.py index 66940cb1f..294fcce99 100644 --- a/pydatalab/src/pydatalab/models/starting_materials.py +++ b/pydatalab/src/pydatalab/models/starting_materials.py @@ -3,10 +3,11 @@ from pydantic import Field, validator from pydatalab.models.items import Item +from pydatalab.models.traits import HasSynthesisInfo from pydatalab.models.utils import IsoformatDateTime -class StartingMaterial(Item): +class StartingMaterial(Item, HasSynthesisInfo): """A model for representing an experimental sample.""" type: str = Field( diff --git a/pydatalab/src/pydatalab/models/traits.py b/pydatalab/src/pydatalab/models/traits.py index 07be0d937..607313380 100644 --- a/pydatalab/src/pydatalab/models/traits.py +++ b/pydatalab/src/pydatalab/models/traits.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field, root_validator from pydatalab.models.people import Person -from pydatalab.models.utils import PyObjectId +from pydatalab.models.utils import Constituent, InlineSubstance, PyObjectId class HasOwner(BaseModel): @@ -78,3 +78,74 @@ def add_missing_collection_relationships(cls, values): raise RuntimeError("Relationships and collections mismatch") return values + + +class HasSynthesisInfo(BaseModel): + """Trait mixin for models that have synthesis information.""" + + synthesis_constituents: List[Constituent] = Field([]) + """A list of references to constituent materials giving the amount and relevant inlined details of consituent items.""" + + synthesis_description: Optional[str] = None + """Free-text details of the procedure applied to synthesise the sample""" + + @root_validator + def add_missing_synthesis_relationships(cls, values): + """Add any missing sample synthesis constituents to parent relationships""" + from pydatalab.models.relationships import RelationshipType, TypedRelationship + + constituents_set = set() + if values.get("synthesis_constituents") is not None: + existing_parent_relationship_ids = set() + if values.get("relationships") is not None: + existing_parent_relationship_ids = { + relationship.item_id or relationship.refcode + for relationship in values["relationships"] + if relationship.relation == RelationshipType.PARENT + } + else: + values["relationships"] = [] + + for constituent in values.get("synthesis_constituents", []): + # If this is an inline relationship, just skip it + if isinstance(constituent.item, InlineSubstance): + continue + if ( + constituent.item.item_id not in existing_parent_relationship_ids + and constituent.item.refcode not in existing_parent_relationship_ids + ): + relationship = TypedRelationship( + relation=RelationshipType.PARENT, + item_id=constituent.item.item_id, + type=constituent.item.type, + description="Is a constituent of", + ) + values["relationships"].append(relationship) + + # Accumulate all constituent IDs in a set to filter those that have been deleted + constituents_set.add(constituent.item.item_id) + + # Finally, filter out any parent relationships with item that were removed + # from the synthesis constituents + values["relationships"] = [ + rel + for rel in values["relationships"] + if not ( + rel.item_id not in constituents_set + and rel.relation == RelationshipType.PARENT + and rel.type in ("samples", "starting_materials") + ) + ] + + return values + + +class HasChemInfo: + smile: Optional[str] = Field(None) + """A SMILES string representation of the chemical structure associated with this sample.""" + inchi: Optional[str] = Field(None) + """An InChI string representation of the chemical structure associated with this sample.""" + inchi_key: Optional[str] = Field(None) + """An InChI key representation of the chemical structure associated with this sample.""" + """A unique key derived from the InChI string.""" + chemform: Optional[str] = Field(None) diff --git a/pydatalab/tests/server/conftest.py b/pydatalab/tests/server/conftest.py index 2eafda06a..6cdd23e83 100644 --- a/pydatalab/tests/server/conftest.py +++ b/pydatalab/tests/server/conftest.py @@ -373,7 +373,7 @@ def fixture_default_equipment(): @pytest.fixture(scope="module", name="complicated_sample") def fixture_complicated_sample(user_id): - from pydatalab.models.samples import Constituent + from pydatalab.models.utils import Constituent return Sample( **{ diff --git a/pydatalab/tests/server/test_graph.py b/pydatalab/tests/server/test_graph.py index 0b1a65d97..7e722e8fb 100644 --- a/pydatalab/tests/server/test_graph.py +++ b/pydatalab/tests/server/test_graph.py @@ -1,7 +1,7 @@ import json from pydatalab.models import Cell, Sample -from pydatalab.models.samples import Constituent +from pydatalab.models.utils import Constituent def test_simple_graph(admin_client): diff --git a/webapp/cypress/e2e/batchSampleFeature.cy.js b/webapp/cypress/e2e/batchSampleFeature.cy.js index ded3217b4..21bc0ab52 100644 --- a/webapp/cypress/e2e/batchSampleFeature.cy.js +++ b/webapp/cypress/e2e/batchSampleFeature.cy.js @@ -154,6 +154,7 @@ describe("Batch sample creation", () => { it("modifies some data in the first sample", () => { cy.get('[data-testid="search-input"]').type("baseA"); cy.findByText("baseA").click(); + cy.expandIfCollapsed("[data-testid=synthesis-block]"); cy.findByLabelText("Description").type("this is a description of baseA."); cy.findByText("Add a block").click(); cy.findByText("Comment").click(); @@ -167,6 +168,7 @@ describe("Batch sample creation", () => { it("modifies some data in the second sample", () => { cy.get('[data-testid="search-input"]').type("baseB"); cy.findByText("baseB").click(); + cy.expandIfCollapsed("[data-testid=synthesis-block]"); cy.findByLabelText("Description").type("this is a description of baseB."); cy.findByText("Add a block").click(); cy.findByLabelText("Add a block").contains("Comment").click(); diff --git a/webapp/cypress/e2e/editPage.cy.js b/webapp/cypress/e2e/editPage.cy.js index e39476f48..6148723b7 100644 --- a/webapp/cypress/e2e/editPage.cy.js +++ b/webapp/cypress/e2e/editPage.cy.js @@ -73,6 +73,7 @@ describe("Edit Page", () => { it("adds some synthesis information", () => { cy.get('[data-testid="search-input"]').type("editable_sample"); cy.findByText("editable_sample").click(); + cy.expandIfCollapsed("[data-testid=synthesis-block]"); cy.get("#synthesis-information .vs__search").first().type("component1"); cy.get(".vs__dropdown-menu").contains(".badge", "component1").click(); cy.get("#synthesis-information tbody > tr").should("have.length", 2); diff --git a/webapp/cypress/e2e/sampleTablePage.cy.js b/webapp/cypress/e2e/sampleTablePage.cy.js index 8e3f73eda..3e635e048 100644 --- a/webapp/cypress/e2e/sampleTablePage.cy.js +++ b/webapp/cypress/e2e/sampleTablePage.cy.js @@ -225,6 +225,7 @@ describe.only("Advanced sample creation features", () => { cy.findByText("Comment").click(); cy.get(".datablock-content div").first().type("a comment is added here."); + cy.expandIfCollapsed("[data-testid=synthesis-block]"); cy.get("#synthesis-information .vs__search").first().type("component3"); cy.get(".vs__dropdown-menu").contains(".badge", "component3").click(); diff --git a/webapp/cypress/support/commands.js b/webapp/cypress/support/commands.js index b17f30e47..a666c6477 100644 --- a/webapp/cypress/support/commands.js +++ b/webapp/cypress/support/commands.js @@ -314,3 +314,14 @@ Cypress.Commands.add("verifyStartingMaterial", (item_id, name = null, date = nul } }); }); + +Cypress.Commands.add("expandIfCollapsed", (selector) => { + cy.get(selector) + .find("[data-testid=collapse-arrow]") + .parents(".datablock-header") + .then(($header) => { + if (!$header.hasClass("expanded")) { + cy.wrap($header).find("[data-testid=collapse-arrow]").click(); + } + }); +}); diff --git a/webapp/src/components/StartingMaterialInformation.vue b/webapp/src/components/StartingMaterialInformation.vue index 1da6ff4e4..62f5adf2b 100644 --- a/webapp/src/components/StartingMaterialInformation.vue +++ b/webapp/src/components/StartingMaterialInformation.vue @@ -87,6 +87,8 @@ :item_id="item_id" :information-sections="tableOfContentsSections" /> + + @@ -99,6 +101,7 @@ import TableOfContents from "@/components/TableOfContents"; import ToggleableCollectionFormGroup from "@/components/ToggleableCollectionFormGroup"; import FormattedRefcode from "@/components/FormattedRefcode"; import StyledInput from "@/components/StyledInput"; +import SynthesisInformation from "@/components/SynthesisInformation"; import ItemRelationshipVisualization from "@/components/ItemRelationshipVisualization"; import GHSHazardInformation from "@/components/GHSHazardInformation"; @@ -114,6 +117,7 @@ export default { ToggleableCollectionFormGroup, TableOfContents, FormattedRefcode, + SynthesisInformation, GHSHazardInformation, }, props: { @@ -124,6 +128,7 @@ export default { tableOfContentsSections: [ { title: "Starting Material Information", targetID: "starting-material-information" }, { title: "Table of Contents", targetID: "table-of-contents" }, + { title: "Synthesis Information", targetID: "synthesis-information" }, ], }; }, diff --git a/webapp/src/components/SynthesisInformation.vue b/webapp/src/components/SynthesisInformation.vue index f6bc419a7..e33fa4056 100644 --- a/webapp/src/components/SynthesisInformation.vue +++ b/webapp/src/components/SynthesisInformation.vue @@ -1,20 +1,35 @@ @@ -36,6 +51,10 @@ export default { selectedNewConstituent: null, selectedChangedConstituent: null, selectShown: [], + // isExpanded is used to toggle the visibility of the content-container starts as false then will expand when clicked or if it is filled + isExpanded: false, + contentMaxHeight: "0px", // "none", Start collapsed so 0px, if expanded set to none in mounted + padding_height: 18, }; }, computed: { @@ -43,19 +62,56 @@ export default { SynthesisDescription: createComputedSetterForItemField("synthesis_description"), }, watch: { - // since constituents is an object, the computed setter never fires and - // saved status is never updated. So, use a watcher: + // Added initialization check to prevent firing on mount - this seemed to trigger an unsaved check when loading the sample for the second time constituents: { handler() { this.$store.commit("setItemSaved", { item_id: this.item_id, isSaved: false }); }, deep: true, }, + SynthesisDescription: { + handler() { + this.$store.commit("setItemSaved", { item_id: this.item_id, isSaved: false }); + }, + }, }, mounted() { this.selectShown = new Array(this.constituents.length).fill(false); + // Auto-collapsed when initialised empty + this.isExpanded = + (this.constituents && this.constituents.length > 0) || + (this.SynthesisDescription && this.SynthesisDescription.trim() !== ""); + // If expanded set height to none, otherwise set to 0px + if (this.isExpanded) { + this.contentMaxHeight = "none"; + } else { + this.contentMaxHeight = "0px"; + } + var content = this.$refs.contentContainer; + content.addEventListener("transitionend", () => { + if (this.isExpanded) { + this.contentMaxHeight = "none"; + } + }); }, methods: { + toggleExpandBlock() { + var content = this.$refs.contentContainer; + console.log(this.contentMaxHeight); + if (!this.isExpanded) { + this.contentMaxHeight = content.scrollHeight + 2 * this.padding_height + "px"; + this.isExpanded = true; + } else { + requestAnimationFrame(() => { + //must be an arrow function so that 'this' is still accessible! + this.contentMaxHeight = content.scrollHeight + "px"; + requestAnimationFrame(() => { + this.contentMaxHeight = "0px"; + this.isExpanded = false; + }); + }); + } + }, addConstituent(selectedItem) { this.constituents.push({ item: selectedItem, @@ -64,6 +120,7 @@ export default { }); this.selectedNewConstituent = null; this.selectShown.push(false); + this.isExpanded = true; }, turnOnRowSelect(index) { this.selectShown[index] = true; @@ -86,6 +143,59 @@ export default { diff --git a/webapp/src/components/itemCreateModalAddons/StartingMaterialCreateModalAddon.vue b/webapp/src/components/itemCreateModalAddons/StartingMaterialCreateModalAddon.vue new file mode 100644 index 000000000..5aa0d9b41 --- /dev/null +++ b/webapp/src/components/itemCreateModalAddons/StartingMaterialCreateModalAddon.vue @@ -0,0 +1,52 @@ + + + diff --git a/webapp/src/resources.js b/webapp/src/resources.js index 859121cb8..a6004dd13 100644 --- a/webapp/src/resources.js +++ b/webapp/src/resources.js @@ -15,6 +15,7 @@ import EquipmentInformation from "@/components/EquipmentInformation"; import SampleCreateModalAddon from "@/components/itemCreateModalAddons/SampleCreateModalAddon"; import CellCreateModalAddon from "@/components/itemCreateModalAddons/CellCreateModalAddon"; +import StartingMaterialCreateModalAddon from "./components/itemCreateModalAddons/StartingMaterialCreateModalAddon.vue"; // Look for values set in .env file. Use defaults if `null` is not explicitly handled elsewhere in the code. export const API_URL = @@ -79,6 +80,7 @@ export const itemTypes = { }, starting_materials: { itemInformationComponent: StartingMaterialInformation, + itemCreateModalAddon: StartingMaterialCreateModalAddon, navbarColor: "#349579", navbarName: "Starting Material", lightColor: "#d9f2eb",