diff --git a/cypress/e2e/datasets/datasets-publish.cy.js b/cypress/e2e/datasets/datasets-publish.cy.js index d55a09442..14a1f29b3 100644 --- a/cypress/e2e/datasets/datasets-publish.cy.js +++ b/cypress/e2e/datasets/datasets-publish.cy.js @@ -37,7 +37,7 @@ describe("Datasets", () => { cy.get("#abstractInput").type("some abstract text"); - cy.get("#publishButton").click(); + cy.get("#saveAndContinueButton").click(); cy.get("#doiRow").should("exist"); }); diff --git a/cypress/e2e/published-data/published-data.cy.js b/cypress/e2e/published-data/published-data.cy.js new file mode 100644 index 000000000..cb866ced5 --- /dev/null +++ b/cypress/e2e/published-data/published-data.cy.js @@ -0,0 +1,572 @@ +import { testData } from "../../fixtures/testData"; + +describe("Datasets general", () => { + const title = "publishedDataTitle"; + const abstract = "publishedDataAbstract"; + const userPublishedDataTitle = "userSpecificPublishedDataTitle"; + const userPublishedDataAbstract = "userSpecificPublishedDataAbstract"; + beforeEach(() => { + cy.login(Cypress.env("username"), Cypress.env("password")); + }); + + after(() => { + cy.removeDatasets(); + }); + + describe("Published data creation, update and registration", () => { + it("should be able to create new published data in private state", () => { + cy.createDataset("raw"); + + cy.visit("/datasets"); + + cy.get(".dataset-table mat-table mat-header-row").should("exist"); + + cy.finishedLoading(); + + cy.get('[data-cy="text-search"] input[type="search"]') + .clear() + .type("Cypress"); + + cy.isLoading(); + + cy.get(".dataset-table mat-row input[type='checkbox']").first().click(); + + cy.get("#addToBatchButton").click(); + + cy.get("#cartOnHeaderButton").click(); + + cy.get("a.button").click(); + + cy.get("#publishButton").click(); + + cy.get("#titleInput").type(title); + + cy.get("#abstractInput").type(abstract); + + cy.get("#saveAndContinueButton").click(); + + cy.get("#doiRow").should("exist"); + + cy.get("[data-cy='status']").contains("private"); + }); + + it("should prevent leaving published data form unsaved", () => { + cy.createDataset("raw"); + + cy.visit("/datasets"); + + cy.get(".dataset-table mat-table mat-header-row").should("exist"); + + cy.finishedLoading(); + + cy.get('[data-cy="text-search"] input[type="search"]') + .clear() + .type("Cypress"); + + cy.isLoading(); + + cy.get(".dataset-table mat-row input[type='checkbox']").first().click(); + + cy.get("#addToBatchButton").click(); + + cy.get("#cartOnHeaderButton").click(); + + cy.get("a.button").click(); + + cy.get("#publishButton").click(); + + cy.get("#titleInput").type(title); + + cy.get("#abstractInput").type(abstract); + + cy.get("#cancelButton").click(); + + cy.on("window:confirm", (str) => { + expect(str).to.equal( + "You have unsaved changes. Press Cancel to go back and save these changes, or OK to leave without saving.", + ); + + return false; + }); + + cy.get("#saveButton").click(); + + cy.get("#cancelButton").click(); + + cy.get('[data-cy="batch-table"] mat-row').should("exist"); + }); + + it("should be able to edit dataset list after creating the published data", () => { + cy.createDataset("raw"); + cy.createDataset("raw"); + + cy.visit("/datasets"); + + cy.get(".dataset-table mat-table mat-header-row").should("exist"); + + cy.finishedLoading(); + + cy.get('[data-cy="text-search"] input[type="search"]') + .clear() + .type("Cypress"); + + cy.isLoading(); + + cy.get(".dataset-table mat-row input[type='checkbox']").first().click(); + + cy.get("#addToBatchButton").click(); + + cy.get("#cartOnHeaderButton").click(); + + cy.get("a.button").click(); + + cy.get("#publishButton").click(); + + cy.get("#titleInput").type(title); + + cy.get("#abstractInput").type(abstract); + + cy.get("#saveButton").click(); + + cy.get("#cancelButton").click(); + + cy.get('[data-cy="batch-table"] mat-row').should("exist"); + + cy.visit("/datasets"); + + cy.get(".dataset-table mat-table mat-header-row").should("exist"); + + cy.finishedLoading(); + + cy.get('[data-cy="text-search"] input[type="search"]') + .clear() + .type("Cypress"); + + cy.isLoading(); + + cy.get(".dataset-table mat-row input[type='checkbox']").last().click(); + + cy.get("#addToBatchButton").click(); + + cy.get("#cartOnHeaderButton").click(); + + cy.get("a.button").click(); + + cy.get('[data-cy="batch-table"] mat-row').its("length").should("eq", 2); + + cy.get("#saveChangesButton").click(); + + cy.get('[data-cy="editPublishedDataForm"]').should("exist"); + cy.get("#titleInput").should("have.value", title); + cy.get("#abstractInput").should("have.value", abstract); + }); + + it("other users should not be able to see private published data that they do not own", () => { + cy.login(Cypress.env("guestUsername"), Cypress.env("guestPassword")); + const title = "some title text"; + + cy.visit("/publishedDatasets"); + + cy.get("app-publisheddata-dashboard mat-table mat-header-row").should( + "exist", + ); + + cy.finishedLoading(); + + cy.get('input[formcontrolname="globalSearch"]').clear().type(title); + + cy.isLoading(); + + cy.get("app-publisheddata-dashboard mat-table mat-row").should( + "not.contain", + title, + ); + }); + + it("should not be able to publish invalid private published data", () => { + cy.visit("/publishedDatasets"); + + cy.get("app-publisheddata-dashboard mat-table mat-header-row").should( + "exist", + ); + + cy.finishedLoading(); + + cy.get('input[formcontrolname="globalSearch"]').clear().type(title); + + cy.isLoading(); + + cy.get("app-publisheddata-dashboard mat-table mat-row") + .contains(title) + .first() + .click(); + + cy.get('[data-cy="status"]').contains("private"); + + cy.get('[data-cy="publishButton"]').click(); + + cy.get("simple-snack-bar").should( + "contain", + 'Publishing Failed. metadata requires property "creators"', + ); + }); + + it("admins should be able to edit their private published data", () => { + const creatorName = "Creator name"; + const resourceType = "resource type"; + const publisherName = "publisher name"; + const publisherIndetifierScheme = "publisher identifier scheme"; + cy.visit("/publishedDatasets"); + + cy.get("app-publisheddata-dashboard mat-table mat-header-row").should( + "exist", + ); + + cy.finishedLoading(); + + cy.get('input[formcontrolname="globalSearch"]').clear().type(title); + + cy.isLoading(); + + cy.get("app-publisheddata-dashboard mat-table mat-row") + .contains(title) + .first() + .click(); + + cy.get('[data-cy="status"]').contains("private"); + + cy.get("#editBtn").click(); + + cy.get('[data-cy="editPublishedDataForm"]').should("exist"); + + cy.get('[data-cy="metadata"]').click(); + cy.get("jsonforms").should("exist"); + + cy.get("button.save-and-continue").should("be.disabled"); + + cy.get('[aria-label="Add to Creators button"]').click(); + + cy.get('[aria-label="Add to Creators button"]') + .closest(".array-layout") + .find('input[id^="#/properties/name"]') + .first() + .clear() + .type(creatorName); + + cy.get('[id="#/properties/resourceType"]').clear().type(resourceType); + + cy.get('[ng-reflect-path="publisher"]') + .parent() + .find('[id="#/properties/name"]') + .clear() + .type(publisherName); + + cy.get('[ng-reflect-path="publisher"]') + .parent() + .should("contain", "is a required property"); + cy.get('[ng-reflect-path="publisher"]') + .parent() + .find('input[id="#/properties/publisherIdentifierScheme"]') + .clear() + .type(publisherIndetifierScheme); + + cy.get("button.save-and-continue").should("not.be.disabled"); + + cy.get("button.save-and-continue").click(); + + cy.get('[data-cy="status"]').contains("private"); + + cy.get('[data-cy="showHideMetadata"]').click(); + + cy.get("ngx-json-viewer section").contains("metadata").click(); + + cy.get("ngx-json-viewer section").contains(creatorName); + cy.get("ngx-json-viewer section").contains(publisherName); + cy.get("ngx-json-viewer section").contains(publisherIndetifierScheme); + cy.get("ngx-json-viewer section").contains(resourceType); + }); + + it("should be able to edit dataset list after creating the published data", () => { + const newDatasetName = "Test dataset name"; + cy.createDataset("raw", newDatasetName); + cy.visit("/publishedDatasets"); + + cy.get("app-publisheddata-dashboard mat-table mat-header-row").should( + "exist", + ); + + cy.finishedLoading(); + + cy.get('input[formcontrolname="globalSearch"]').clear().type(title); + + cy.isLoading(); + + cy.get("app-publisheddata-dashboard mat-table mat-row") + .contains(title) + .first() + .click(); + + cy.get('[data-cy="status"]').contains("private"); + + cy.get('[data-cy="editDatasetList"]').click(); + + cy.get('[data-cy="batch-table"] mat-row').should("exist"); + + cy.visit("/datasets"); + + cy.get(".dataset-table mat-table mat-header-row").should("exist"); + + cy.finishedLoading(); + + cy.get('[data-cy="text-search"] input[type="search"]') + .clear() + .type(newDatasetName); + + cy.isLoading(); + + cy.get(".dataset-table mat-row input[type='checkbox']").first().click(); + + cy.get("#addToBatchButton").click(); + + cy.get("#cartOnHeaderButton").click(); + + cy.get("a.button").click(); + + cy.get('[data-cy="batch-table"] mat-row').its("length").should("eq", 3); + + cy.get("#saveChangesButton").click(); + }); + + it("should be able to publish their private published data", () => { + cy.visit("/publishedDatasets"); + + cy.get("app-publisheddata-dashboard mat-table mat-header-row").should( + "exist", + ); + + cy.finishedLoading(); + + cy.get('input[formcontrolname="globalSearch"]').clear().type(title); + + cy.isLoading(); + + cy.get("app-publisheddata-dashboard mat-table mat-row") + .contains(title) + .first() + .click(); + + cy.get('[data-cy="status"]').contains("private"); + + cy.get('[data-cy="publishButton"]').click(); + + cy.get('[data-cy="status"]').contains("public"); + }); + + it("should not be able to edit dataset list on a published data that is public", () => { + cy.visit("/publishedDatasets"); + + cy.get("app-publisheddata-dashboard mat-table mat-header-row").should( + "exist", + ); + + cy.finishedLoading(); + + cy.get('input[formcontrolname="globalSearch"]').clear().type(title); + + cy.isLoading(); + + cy.get("app-publisheddata-dashboard mat-table mat-row") + .contains(title) + .first() + .click(); + + cy.get('[data-cy="status"]').contains("public"); + + cy.get("#editDatasetList").should("not.exist"); + }); + + it("other users should be able to see public published data that they do not own", () => { + cy.login(Cypress.env("guestUsername"), Cypress.env("guestPassword")); + cy.visit("/publishedDatasets"); + + cy.get("app-publisheddata-dashboard mat-table mat-header-row").should( + "exist", + ); + + cy.finishedLoading(); + + cy.get('input[formcontrolname="globalSearch"]').clear().type(title); + + cy.isLoading(); + + cy.get("app-publisheddata-dashboard mat-table mat-row").should( + "contain", + title, + ); + }); + + it("should be able to register their public published data", () => { + cy.visit("/publishedDatasets"); + + cy.get("app-publisheddata-dashboard mat-table mat-header-row").should( + "exist", + ); + + cy.finishedLoading(); + + cy.get('input[formcontrolname="globalSearch"]').clear().type(title); + + cy.isLoading(); + + cy.get("app-publisheddata-dashboard mat-table mat-row") + .contains(title) + .first() + .click(); + + cy.get('[data-cy="status"]').contains("public"); + + cy.get('[data-cy="registerButton"]').click(); + + cy.get('[data-cy="status"]').contains("registered"); + }); + + it("regular users should be able to create and edit their private published data but not after it gets public", () => { + cy.login(Cypress.env("guestUsername"), Cypress.env("guestPassword")); + const creatorName = "Creator name"; + const resourceType = "resource type"; + const publisherName = "publisher name"; + const publisherIndetifierScheme = "publisher identifier scheme"; + cy.createDataset("raw"); + + cy.visit("/datasets"); + + cy.get(".dataset-table mat-table mat-header-row").should("exist"); + + cy.finishedLoading(); + + cy.get('[data-cy="text-search"] input[type="search"]') + .clear() + .type("Cypress"); + + cy.isLoading(); + + cy.get(".dataset-table mat-row input[type='checkbox']").first().click(); + + cy.get("#addToBatchButton").click(); + + cy.get("#cartOnHeaderButton").click(); + + cy.get("a.button").click(); + + cy.get("#publishButton").click(); + + cy.get("#titleInput").type(userPublishedDataTitle); + + cy.get("#abstractInput").type(userPublishedDataAbstract); + + cy.get("#saveAndContinueButton").click(); + + cy.get("#doiRow").should("exist"); + + cy.get("[data-cy='status']").contains("private"); + + cy.get("#editBtn").click(); + + cy.get('[data-cy="editPublishedDataForm"]').should("exist"); + + cy.get('[data-cy="metadata"]').click(); + cy.get("jsonforms").should("exist"); + + cy.get("button.save-and-continue").should("be.disabled"); + + cy.get('[aria-label="Add to Creators button"]').click(); + + cy.get('[aria-label="Add to Creators button"]') + .closest(".array-layout") + .find('input[id^="#/properties/name"]') + .first() + .clear() + .type(creatorName); + + cy.get('[id="#/properties/resourceType"]').clear().type(resourceType); + + cy.get('[ng-reflect-path="publisher"]') + .parent() + .find('[id="#/properties/name"]') + .clear() + .type(publisherName); + + cy.get('[ng-reflect-path="publisher"]') + .parent() + .should("contain", "is a required property"); + cy.get('[ng-reflect-path="publisher"]') + .parent() + .find('input[id="#/properties/publisherIdentifierScheme"]') + .clear() + .type(publisherIndetifierScheme); + + cy.get("button.save-and-continue").should("not.be.disabled"); + + cy.get("button.save-and-continue").click(); + + cy.get('[data-cy="status"]').contains("private"); + + cy.get('[data-cy="publishButton"]').click(); + + cy.get('[data-cy="status"]').contains("public"); + + cy.get("#editBtn").should("not.exist"); + }); + + it("admins should be able to edit public published data", () => { + const newCreatorName = "new creator name"; + cy.visit("/publishedDatasets"); + + cy.get("app-publisheddata-dashboard mat-table mat-header-row").should( + "exist", + ); + + cy.finishedLoading(); + + cy.get('input[formcontrolname="globalSearch"]') + .clear() + .type(userPublishedDataTitle); + + cy.isLoading(); + + cy.get("app-publisheddata-dashboard mat-table mat-row") + .contains(userPublishedDataTitle) + .first() + .click(); + + cy.get('[data-cy="status"]').contains("public"); + + cy.get("#editBtn").click(); + + cy.get('[data-cy="editPublishedDataForm"]').should("exist"); + + cy.get('[data-cy="metadata"]').click(); + cy.get("jsonforms").should("exist"); + + cy.get("button.save-and-continue").should("not.be.disabled"); + + cy.get('[aria-label="Add to Creators button"]') + .closest(".array-layout") + .find('input[id^="#/properties/name"]') + .first() + .clear() + .type(newCreatorName); + + cy.get("button.save-and-continue").should("not.be.disabled"); + + cy.get("button.save-and-continue").click(); + + cy.get('[data-cy="status"]').contains("public"); + + cy.get('[data-cy="showHideMetadata"]').click(); + + cy.get("ngx-json-viewer section").contains("metadata").click(); + cy.get("ngx-json-viewer section").contains(newCreatorName); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index cd40bb147..9614c007c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,9 @@ "@angular/platform-server": "^19.2.8", "@angular/router": "^19.2.8", "@angular/service-worker": "^19.2.8", + "@jsonforms/angular": "^3.5.1", + "@jsonforms/angular-material": "^3.5.1", + "@jsonforms/core": "^3.5.1", "@ngbracket/ngx-layout": "^16.0.0", "@ngrx/effects": "^19.1.0", "@ngrx/operators": "^19.1.0", @@ -4126,6 +4129,70 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsonforms/angular": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@jsonforms/angular/-/angular-3.5.1.tgz", + "integrity": "sha512-qbMblz/G/kWOol1n6iMYUA81ndzQe80p788VyMsm6dJ5edTrqpvWVK8vjLkzWZtH5kbFrg/nkYaI/f6XsP1Chw==", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@angular/core": "^18.0.0 || ^19.0.0", + "@angular/forms": "^18.0.0 || ^19.0.0", + "@jsonforms/core": "3.5.1", + "rxjs": "^6.6.0 || ^7.4.0" + } + }, + "node_modules/@jsonforms/angular-material": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@jsonforms/angular-material/-/angular-material-3.5.1.tgz", + "integrity": "sha512-x2j3B3XG1uL3aU8gzo3iRSSRPuRBzceI4Do1itXn016h3S1JT5sDtI7NWvZ+K6TQN4YGuWypaXuBm2y686DKpw==", + "dependencies": { + "hammerjs": "2.0.8", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@angular/animations": "^18.0.0 || ^19.0.0", + "@angular/cdk": "^18.0.0 || ^19.0.0", + "@angular/common": "^18.0.0 || ^19.0.0", + "@angular/core": "^18.0.0 || ^19.0.0", + "@angular/forms": "^18.0.0 || ^19.0.0", + "@angular/material": "^18.0.0 || ^19.0.0", + "@angular/platform-browser": "^18.0.0 || ^19.0.0", + "@angular/router": "^18.0.0 || ^19.0.0", + "@jsonforms/angular": "3.5.1", + "@jsonforms/core": "3.5.1", + "dayjs": "^1.11.10", + "rxjs": "^6.6.0 || ^7.4.0" + } + }, + "node_modules/@jsonforms/core": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.5.1.tgz", + "integrity": "sha512-Jrq/UcfvKsAprLJ+9TMFa8pKsfdyv3dAw85XstSNRcjDT19LreBlhVqIvTvtgZidg8Iet3yqy5xlNnB+XyrvrQ==", + "dependencies": { + "@types/json-schema": "^7.0.3", + "ajv": "^8.6.1", + "ajv-formats": "^2.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/@jsonforms/core/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", @@ -6128,8 +6195,7 @@ "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "node_modules/@types/lodash": { "version": "4.17.16", @@ -7205,7 +7271,6 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10611,8 +10676,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -10670,7 +10734,6 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "dev": true, "funding": [ { "type": "github", @@ -11326,6 +11389,14 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -12697,8 +12768,7 @@ "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -13565,8 +13635,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash-es": { "version": "4.17.21", @@ -16164,7 +16233,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index 4b661b41a..886d67ab6 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ "@angular/platform-server": "^19.2.8", "@angular/router": "^19.2.8", "@angular/service-worker": "^19.2.8", + "@jsonforms/angular": "^3.5.1", + "@jsonforms/angular-material": "^3.5.1", + "@jsonforms/core": "^3.5.1", "@ngbracket/ngx-layout": "^16.0.0", "@ngrx/effects": "^19.1.0", "@ngrx/operators": "^19.1.0", diff --git a/src/app/app-routing/lazy/datasets-routing/datasets.routing.module.ts b/src/app/app-routing/lazy/datasets-routing/datasets.routing.module.ts index d1637dbd5..6715b60a4 100644 --- a/src/app/app-routing/lazy/datasets-routing/datasets.routing.module.ts +++ b/src/app/app-routing/lazy/datasets-routing/datasets.routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "app-routing/auth.guard"; +import { leavingPageGuard } from "app-routing/pending-changes.guard"; import { BatchViewComponent } from "datasets/batch-view/batch-view.component"; import { DashboardComponent } from "datasets/dashboard/dashboard.component"; import { DatablocksComponent } from "datasets/datablocks-table/datablocks-table.component"; @@ -21,6 +22,7 @@ const routes: Routes = [ path: "batch/publish", component: PublishComponent, canActivate: [AuthGuard], + canDeactivate: [leavingPageGuard], }, { path: ":id", diff --git a/src/app/app-routing/lazy/publisheddata-routing/publisheddata.routing.module.ts b/src/app/app-routing/lazy/publisheddata-routing/publisheddata.routing.module.ts index f8e37f935..a14e5fec2 100644 --- a/src/app/app-routing/lazy/publisheddata-routing/publisheddata.routing.module.ts +++ b/src/app/app-routing/lazy/publisheddata-routing/publisheddata.routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "app-routing/auth.guard"; +import { leavingPageGuard } from "app-routing/pending-changes.guard"; import { PublisheddataDashboardComponent } from "publisheddata/publisheddata-dashboard/publisheddata-dashboard.component"; import { PublisheddataDetailsComponent } from "publisheddata/publisheddata-details/publisheddata-details.component"; import { PublisheddataEditComponent } from "publisheddata/publisheddata-edit/publisheddata-edit.component"; @@ -20,6 +21,7 @@ const routes: Routes = [ path: ":id/edit", component: PublisheddataEditComponent, canActivate: [AuthGuard], + canDeactivate: [leavingPageGuard], }, ]; @NgModule({ diff --git a/src/app/datasets/batch-view/batch-view.component.html b/src/app/datasets/batch-view/batch-view.component.html index cce14bc9f..15a49a181 100644 --- a/src/app/datasets/batch-view/batch-view.component.html +++ b/src/app/datasets/batch-view/batch-view.component.html @@ -1,11 +1,45 @@
- + + + +
-
- - - -
diff --git a/src/app/datasets/publish/publish.component.scss b/src/app/datasets/publish/publish.component.scss index 4e5337fbf..a407912ff 100644 --- a/src/app/datasets/publish/publish.component.scss +++ b/src/app/datasets/publish/publish.component.scss @@ -9,3 +9,15 @@ mat-form-field { mat-card { margin: 1em; } + +mat-expansion-panel { + margin: 1em 0; +} + +button.save-and-continue { + margin-left: 1em; +} + +button.cancel { + margin-left: 1em; +} diff --git a/src/app/datasets/publish/publish.component.spec.ts b/src/app/datasets/publish/publish.component.spec.ts index 00c8be5e8..f89317211 100644 --- a/src/app/datasets/publish/publish.component.spec.ts +++ b/src/app/datasets/publish/publish.component.spec.ts @@ -84,31 +84,6 @@ describe("PublishComponent", () => { expect(component).toBeTruthy(); }); - describe("#addCreator()", () => { - it("should push a creator to the creator property in the form", () => { - const event = { - input: { - value: "", - }, - value: "testCreator", - }; - component.addCreator(event); - - expect(component.form.creators).toContain(event.value); - }); - }); - - describe("#removeCreator()", () => { - it("should remove a creator from the creator property in the form", () => { - const creator = "testCreator"; - component.form.creators = [creator]; - - component.removeCreator(creator); - - expect(component.form.creators).not.toContain(creator); - }); - }); - describe("#formIsValid()", () => { it("should return false if form has undefined properties", () => { component.form.title = undefined; @@ -121,20 +96,8 @@ describe("PublishComponent", () => { it("should return true if form has no undefined properties and their lengths > 0", () => { component.form = { title: "testTitle", - creators: ["testCreator"], - publisher: "testPublisher", - resourceType: "testType", - description: "testDescription", abstract: "testAbstract", - pidArray: ["testPid"], - publicationYear: 2019, - url: "testUrl", - dataDescription: "testDataDescription", - thumbnail: "testThumbnail", - numberOfFiles: 1, - sizeOfArchive: 100, - relatedPublications: ["testpub"], - downloadLink: "testlink", + datasetPids: ["testPid"], }; const isValid = component.formIsValid(); diff --git a/src/app/datasets/publish/publish.component.ts b/src/app/datasets/publish/publish.component.ts index f004549a8..53c27e4e0 100644 --- a/src/app/datasets/publish/publish.component.ts +++ b/src/app/datasets/publish/publish.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { Component, OnInit, OnDestroy, Output, signal } from "@angular/core"; import { COMMA, ENTER } from "@angular/cdk/keycodes"; import { Store, ActionsSubject } from "@ngrx/store"; @@ -7,20 +7,31 @@ import { first, tap } from "rxjs/operators"; import { selectDatasetsInBatch } from "state-management/selectors/datasets.selectors"; import { prefillBatchAction } from "state-management/actions/datasets.actions"; import { - publishDatasetAction, - fetchPublishedDataCompleteAction, + createDataPublicationAction, + createDataPublicationCompleteAction, + fetchPublishedDataAction, + fetchPublishedDataConfigAction, + resyncPublishedDataAction, + saveDataPublicationAction, + saveDataPublicationCompleteAction, + updatePublishedDataAction, } from "state-management/actions/published-data.actions"; import { CreatePublishedDataDto, + PublishedData, PublishedDataService, } from "@scicatproject/scicat-sdk-ts-angular"; -import { formatDate } from "@angular/common"; import { Router } from "@angular/router"; -import { selectCurrentPublishedData } from "state-management/selectors/published-data.selectors"; -import { Subscription } from "rxjs"; -import { selectCurrentUserName } from "state-management/selectors/user.selectors"; +import { + selectCurrentPublishedData, + selectPublishedDataConfig, +} from "state-management/selectors/published-data.selectors"; +import { fromEvent, Subscription } from "rxjs"; import { AppConfigService } from "app-config.service"; +import { angularMaterialRenderers } from "@jsonforms/angular-material"; +import { isEmpty } from "lodash-es"; +import { EditableComponent } from "app-routing/pending-changes.guard"; @Component({ selector: "publish", @@ -28,33 +39,32 @@ import { AppConfigService } from "app-config.service"; styleUrls: ["./publish.component.scss"], standalone: false, }) -export class PublishComponent implements OnInit, OnDestroy { +export class PublishComponent implements OnInit, OnDestroy, EditableComponent { + private _hasUnsavedChanges = false; private datasets$ = this.store.select(selectDatasetsInBatch); - private userName$ = this.store.select(selectCurrentUserName); + private publishedDataConfig$ = this.store.select(selectPublishedDataConfig); private countSubscription: Subscription; + private publishedDataConfigSubscription: Subscription; + private beforeUnloadSubscription: Subscription; + readonly panelOpenState = signal(false); appConfig = this.appConfigService.getConfig(); + renderers = angularMaterialRenderers; + schema: any = {}; + uiSchema: any = {}; + metadataData: any = {}; public separatorKeysCodes: number[] = [ENTER, COMMA]; public datasetCount: number; today: number = Date.now(); + public metadataFormErrors = []; + savedPublishedDataDoi: string | null = null; + initialMetadata = JSON.stringify({}); public form = { - title: "", - creators: [], - publisher: this.appConfig.facility, - resourceType: "", - description: "", - abstract: "", - pidArray: [], - publicationYear: null, - url: "", - dataDescription: "", - thumbnail: "", - numberOfFiles: null, - sizeOfArchive: null, - downloadLink: "", - relatedPublications: [], + title: undefined, + abstract: undefined, + datasetPids: [], }; public formData = null; @@ -68,81 +78,63 @@ export class PublishComponent implements OnInit, OnDestroy { private router: Router, ) {} - addCreator(event) { - if ((event.value || "").trim()) { - this.form.creators.push(event.value); - } - - if (event.input) { - event.input.value = ""; + public formIsValid() { + if (!Object.values(this.form).includes(undefined)) { + return this.form.title.length > 0 && this.form.abstract.length > 0; + } else { + return false; } } - removeCreator(creator) { - const index = this.form.creators.indexOf(creator); - - if (index >= 0) { - this.form.creators.splice(index, 1); - } + public metadataDataIsValid() { + return this.metadataFormErrors.length === 0; } - addRelatedPublication(event) { - if ((event.value || "").trim()) { - this.form.relatedPublications.push(event.value); - } - - if (event.input) { - event.input.value = ""; - } + onErrors(errors) { + this.metadataFormErrors = errors; } - removeRelatedPublication(relatedPublication) { - const index = this.form.relatedPublications.indexOf(relatedPublication); + onMetadataChange(data: any) { + this.metadataData = data; - if (index >= 0) { - this.form.relatedPublications.splice(index, 1); + if (JSON.stringify(data) !== this.initialMetadata) { + this._hasUnsavedChanges = true; } } - public formIsValid() { - if (!Object.values(this.form).includes(undefined)) { - return ( - this.form.title.length > 0 && - this.form.resourceType.length > 0 && - this.form.creators.length > 0 && - this.form.publisher.length > 0 && - this.form.description.length > 0 && - this.form.abstract.length > 0 - ); - } else { - return false; - } + onFormFieldChange() { + this._hasUnsavedChanges = true; } ngOnInit() { this.store.dispatch(prefillBatchAction()); + this.store.dispatch(fetchPublishedDataConfigAction()); this.datasets$ .pipe( first(), tap((datasets) => { if (datasets) { - const creator = datasets.map((dataset) => dataset.owner); - const unique = creator.filter( - (item, i) => creator.indexOf(item) === i, - ); - this.form.creators = unique; - this.form.pidArray = datasets.map((dataset) => dataset.pid); - let size = 0; - datasets.forEach((dataset) => { - size += dataset.size; - }); - this.form.sizeOfArchive = size; + this.form.datasetPids = datasets.map((dataset) => dataset.pid); } }), ) .subscribe(); + this.publishedDataConfigSubscription = this.publishedDataConfig$.subscribe( + (publishedDataConfig) => { + if (!isEmpty(publishedDataConfig)) { + this.schema = publishedDataConfig.metadataSchema; + // NOTE: We set the publicationYear by the system, so we remove it from the required fields in the frontend + this.schema.required.splice( + this.schema.required.indexOf("publicationYear"), + 1, + ); + this.uiSchema = publishedDataConfig.uiSchema; + } + }, + ); + this.countSubscription = this.datasets$.subscribe((datasets) => { if (datasets) { this.datasetCount = datasets.length; @@ -150,67 +142,102 @@ export class PublishComponent implements OnInit, OnDestroy { }); this.publishedDataApi - .publishedDataControllerFormPopulateV3(this.form.pidArray[0]) + .publishedDataControllerFormPopulateV3(this.form.datasetPids[0]) .subscribe((result) => { this.form.abstract = result.abstract; this.form.title = result.title; - this.form.description = result.description; - this.form.resourceType = "raw"; - this.form.thumbnail = result.thumbnail ?? ""; }); this.actionSubjectSubscription = this.actionsSubj.subscribe((data) => { - if (data.type === fetchPublishedDataCompleteAction.type) { - this.store - .select(selectCurrentPublishedData) - .subscribe((publishedData) => { - const doi = encodeURIComponent(publishedData.doi); - this.router.navigateByUrl("/publishedDatasets/" + doi); - }) - .unsubscribe(); + if (data.type === createDataPublicationCompleteAction.type) { + const publishedData = ( + data as { type: string; publishedData: PublishedData } + ).publishedData; + + const doi = encodeURIComponent(publishedData.doi); + this.router.navigateByUrl("/publishedDatasets/" + doi); + } + + if (data.type === saveDataPublicationCompleteAction.type) { + const publishedData = ( + data as { type: string; publishedData: PublishedData } + ).publishedData; + + this.savedPublishedDataDoi = publishedData.doi; } }); + + // Prevent user from reloading page if there are unsave changes + this.beforeUnloadSubscription = fromEvent(window, "beforeunload").subscribe( + (event) => { + if (this.hasUnsavedChanges()) { + event.preventDefault(); + } + }, + ); } ngOnDestroy() { this.actionSubjectSubscription.unsubscribe(); this.countSubscription.unsubscribe(); + this.publishedDataConfigSubscription.unsubscribe(); + this.beforeUnloadSubscription.unsubscribe(); } - public onPublish() { - const { - title, - abstract, - description, - creators, - resourceType, - pidArray, - publisher, - url, - thumbnail, - numberOfFiles, - sizeOfArchive, - downloadLink, - relatedPublications, - } = this.form; - - const publishedData: CreatePublishedDataDto = { + getPublishedDataForCreation() { + const { title, abstract, datasetPids } = this.form; + const metadata = { + ...this.metadataData, + landingPage: this.appConfig.landingPage, + }; + return { title: title, abstract: abstract, - dataDescription: description, - creator: creators, - resourceType: resourceType, - pidArray: pidArray, - publisher: publisher, - publicationYear: parseInt(formatDate(this.today, "yyyy", "en_GB"), 10), - url: url, - thumbnail: thumbnail, - numberOfFiles: numberOfFiles, - sizeOfArchive: sizeOfArchive, - downloadLink: downloadLink, - relatedPublications: relatedPublications, - }; + datasetPids: datasetPids, + metadata: metadata, + } as CreatePublishedDataDto; + } + + public onSaveAndContinue() { + const publishedData = this.getPublishedDataForCreation(); + + this._hasUnsavedChanges = false; + + if (this.savedPublishedDataDoi) { + this.store.dispatch( + resyncPublishedDataAction({ + doi: this.savedPublishedDataDoi, + data: publishedData, + redirect: true, + }), + ); + } else { + this.store.dispatch(createDataPublicationAction({ data: publishedData })); + } + } + + public onSaveChanges() { + const publishedData = this.getPublishedDataForCreation(); + + if (this.savedPublishedDataDoi) { + this.store.dispatch( + updatePublishedDataAction({ + doi: this.savedPublishedDataDoi, + data: publishedData, + }), + ); + } else { + this.store.dispatch(saveDataPublicationAction({ data: publishedData })); + } + + this._hasUnsavedChanges = false; + } + + public onCancel() { + this.router.navigateByUrl("/datasets/batch"); + } - this.store.dispatch(publishDatasetAction({ data: publishedData })); + hasUnsavedChanges() { + return this._hasUnsavedChanges; } } diff --git a/src/app/publisheddata/publisheddata-dashboard/publisheddata-dashboard.component.spec.ts b/src/app/publisheddata/publisheddata-dashboard/publisheddata-dashboard.component.spec.ts index 3b424b4d4..7024155b9 100644 --- a/src/app/publisheddata/publisheddata-dashboard/publisheddata-dashboard.component.spec.ts +++ b/src/app/publisheddata/publisheddata-dashboard/publisheddata-dashboard.component.spec.ts @@ -120,18 +120,19 @@ describe("PublisheddataDashboardComponent", () => { it("should add all DOI's to selectedDOIs if checked is true", () => { const published = createMock({ doi: "test", - creator: ["test"], - publisher: "test", - publicationYear: 2021, title: "test", abstract: "test", - dataDescription: "test", - resourceType: "test", - pidArray: [], + datasetPids: [], createdAt: "", registeredTime: "", - status: "", + status: PublishedData.StatusEnum.private, updatedAt: "", + metadata: { + creators: ["test creator"], + publisher: { name: "test" }, + publicationYear: 2021, + resourceType: "test", + }, }); spyOn(component.vm$, "pipe").and.returnValue( diff --git a/src/app/publisheddata/publisheddata-dashboard/publisheddata-dashboard.component.ts b/src/app/publisheddata/publisheddata-dashboard/publisheddata-dashboard.component.ts index 0b9e21f19..4527288b7 100644 --- a/src/app/publisheddata/publisheddata-dashboard/publisheddata-dashboard.component.ts +++ b/src/app/publisheddata/publisheddata-dashboard/publisheddata-dashboard.component.ts @@ -50,6 +50,14 @@ export class PublisheddataDashboardComponent implements OnInit, OnDestroy { matchMode: "contains", hideOrder: 2, }, + { + id: "status", + label: "Status", + icon: "face", + canSort: true, + matchMode: "contains", + hideOrder: 3, + }, { id: "createdBy", icon: "account_circle", diff --git a/src/app/publisheddata/publisheddata-details/publisheddata-details.component.html b/src/app/publisheddata/publisheddata-details/publisheddata-details.component.html index 03c53a276..8949b8dcc 100644 --- a/src/app/publisheddata/publisheddata-details/publisheddata-details.component.html +++ b/src/app/publisheddata/publisheddata-details/publisheddata-details.component.html @@ -1,5 +1,5 @@
-
+
@@ -9,7 +9,7 @@ - + @@ -30,11 +30,29 @@ + + @@ -63,7 +81,7 @@ - + @@ -81,21 +99,32 @@
Status {{ value }}
URL
Publication Year {{ value }}
- - - + + + + - - - + + + - + - + - +
Creator{{ value }}
Creators + + {{ creator.name }}{{ isLast ? "" : ", " }} + +
Authors{{ value }}
Contributors + + {{ contributor.name }}{{ isLast ? "" : ", " }} + +
Affiliation {{ value }}
Publisher{{ value }}{{ value.name }}
@@ -111,7 +140,7 @@ - + @@ -123,11 +152,11 @@ - + - +
Download Link {{ value }}
Number of Files {{ value }}
Resource Type {{ value }}
Data Description @@ -154,14 +183,14 @@ - + - + +
Related Publications{{ publishedData.relatedPublications }}{{ publishedData.metadata?.relatedPublications }}
Dataset IDs @@ -173,30 +202,62 @@ + +
- + + + -
@@ -211,8 +272,8 @@
-
- +
+ diff --git a/src/app/publisheddata/publisheddata-details/publisheddata-details.component.scss b/src/app/publisheddata/publisheddata-details/publisheddata-details.component.scss index c692f0275..d2bd77855 100644 --- a/src/app/publisheddata/publisheddata-details/publisheddata-details.component.scss +++ b/src/app/publisheddata/publisheddata-details/publisheddata-details.component.scss @@ -23,7 +23,7 @@ mat-card { } } - .edit-button { - margin-right: 1em; + .delete-button { + margin: 0 1em; } } diff --git a/src/app/publisheddata/publisheddata-details/publisheddata-details.component.ts b/src/app/publisheddata/publisheddata-details/publisheddata-details.component.ts index dd9ea48c2..19ee145cd 100644 --- a/src/app/publisheddata/publisheddata-details/publisheddata-details.component.ts +++ b/src/app/publisheddata/publisheddata-details/publisheddata-details.component.ts @@ -3,13 +3,18 @@ import { PublishedData } from "@scicatproject/scicat-sdk-ts-angular"; import { Store } from "@ngrx/store"; import { ActivatedRoute, Router } from "@angular/router"; import { + amendPublishedDataAction, + deletePublishedDataAction, fetchPublishedDataAction, + fetchRelatedDatasetsAndAddToBatchAction, + publishPublishedDataAction, registerPublishedDataAction, } from "state-management/actions/published-data.actions"; import { Subscription } from "rxjs"; import { pluck } from "rxjs/operators"; import { selectCurrentPublishedData } from "state-management/selectors/published-data.selectors"; import { AppConfigService } from "app-config.service"; +import { selectIsAdmin } from "state-management/selectors/user.selectors"; @Component({ selector: "publisheddata-details", @@ -19,7 +24,8 @@ import { AppConfigService } from "app-config.service"; }) export class PublisheddataDetailsComponent implements OnInit, OnDestroy { currentData$ = this.store.select(selectCurrentPublishedData); - publishedData: PublishedData; + isAdmin$ = this.store.select(selectIsAdmin); + publishedData: PublishedData & { metadata?: any }; subscriptions: Subscription[] = []; appConfig = this.appConfigService.getConfig(); show = false; @@ -60,7 +66,27 @@ export class PublisheddataDetailsComponent implements OnInit, OnDestroy { } onRegisterClick(doi: string) { - this.store.dispatch(registerPublishedDataAction({ doi })); + if ( + confirm( + "Are you sure you want to register this published data? Keep in mind that no further changes can be made after this action.", + ) + ) { + this.store.dispatch(registerPublishedDataAction({ doi })); + } + } + + onAmendClick(doi: string) { + this.store.dispatch(amendPublishedDataAction({ doi })); + } + + onDeleteClick(doi: string) { + if (confirm("Are you sure you want to delete this published data?")) { + this.store.dispatch(deletePublishedDataAction({ doi })); + } + } + + onPublishClick(doi: string) { + this.store.dispatch(publishPublishedDataAction({ doi })); } onEditClick() { @@ -68,6 +94,15 @@ export class PublisheddataDetailsComponent implements OnInit, OnDestroy { this.router.navigateByUrl("/publishedDatasets/" + id + "/edit"); } + onEditDatasetList() { + this.store.dispatch( + fetchRelatedDatasetsAndAddToBatchAction({ + datasetPids: this.publishedData.datasetPids, + publishedDataDoi: this.publishedData.doi, + }), + ); + } + isUrl(dataDescription: string): boolean { return dataDescription.includes("http"); } diff --git a/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.html b/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.html index d45f489a3..c69d3bc51 100644 --- a/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.html +++ b/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.html @@ -11,7 +11,7 @@
- - - - {{ item }} - cancel - - - - - - - - - - - - raw - derived - - - - - - - - - - - - - - - {{ item }} - cancel - - - - + + + Metadata + + Here you can edit the metadata. + + + + - + -
-
-
- -
- - - -
diff --git a/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.scss b/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.scss index 77db7c6f9..ef69a72b6 100644 --- a/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.scss +++ b/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.scss @@ -13,3 +13,15 @@ mat-card { .file-uploader { margin: 1em; } + +mat-expansion-panel { + margin: 1em 0; +} + +button.save-and-continue { + margin-left: 1em; +} + +button.cancel { + margin-left: 1em; +} diff --git a/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.spec.ts b/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.spec.ts index cfe6221a7..f3dd30d5e 100644 --- a/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.spec.ts +++ b/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.spec.ts @@ -25,10 +25,14 @@ import { MatOptionModule } from "@angular/material/core"; import { MatButtonModule } from "@angular/material/button"; import { FlexLayoutModule } from "@ngbracket/ngx-layout"; import { PublishedDataService } from "@scicatproject/scicat-sdk-ts-angular"; +import { AppConfigService } from "app-config.service"; describe("PublisheddataEditComponent", () => { let component: PublisheddataEditComponent; let fixture: ComponentFixture; + const getConfig = () => ({ + landingPage: "https://test-landing-page.com", + }); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -62,6 +66,7 @@ describe("PublisheddataEditComponent", () => { { provide: PublishedDataService, useClass: MockPublishedDataApi }, { provide: Router, useClass: MockRouter }, { provide: Store, useClass: MockStore }, + { provide: AppConfigService, useValue: { getConfig } }, ], }, }); @@ -77,65 +82,4 @@ describe("PublisheddataEditComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); - - describe("#addCreator()", () => { - it("should push a creator to the creator property in the form", () => { - const event = { - chipInput: { - inputElement: { - value: "testCreator", - }, - }, - value: "testCreator", - } as MatChipInputEvent; - component.addCreator(event); - - expect(component.creator!.value).toContain(event.value); - }); - }); - - describe("#removeCreator()", () => { - it("should remove a creator from the creator property in the form", () => { - const creator = "testCreator"; - component.creator!.setValue([]); - component.creator!.value.push("firstCreator", creator); - - component.removeCreator(1); - - expect(component.creator!.value).not.toContain(creator); - }); - }); - - describe("#addRelatedPublication()", () => { - it("should push a related publication to the relatedPublications property in the form", () => { - const event = { - chipInput: { - inputElement: { - value: "testRelatedPublication", - }, - }, - value: "testRelatedPublication", - } as MatChipInputEvent; - component.addRelatedPublication(event); - - expect(component.relatedPublications!.value).toContain(event.value); - }); - }); - - describe("#removeRelatedPublication()", () => { - it("should remove a related publication from the relatedPublications property in the form", () => { - const relatedPublication = "testRelatedPublication"; - component.relatedPublications!.setValue([]); - component.relatedPublications!.value.push( - "firstRelatedPublication", - relatedPublication, - ); - - component.removeRelatedPublication(1); - - expect(component.relatedPublications!.value).not.toContain( - relatedPublication, - ); - }); - }); }); diff --git a/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.ts b/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.ts index d5371c070..d839b28d3 100644 --- a/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.ts +++ b/src/app/publisheddata/publisheddata-edit/publisheddata-edit.component.ts @@ -1,21 +1,27 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Component, OnDestroy, OnInit, signal } from "@angular/core"; import { COMMA, ENTER } from "@angular/cdk/keycodes"; import { Store } from "@ngrx/store"; import { fetchPublishedDataAction, + fetchPublishedDataConfigAction, resyncPublishedDataAction, } from "state-management/actions/published-data.actions"; import { ActivatedRoute, Router } from "@angular/router"; -import { selectCurrentPublishedData } from "state-management/selectors/published-data.selectors"; -import { MatChipInputEvent } from "@angular/material/chips"; +import { + selectCurrentPublishedData, + selectPublishedDataConfig, +} from "state-management/selectors/published-data.selectors"; import { Attachment, PublishedData, } from "@scicatproject/scicat-sdk-ts-angular"; -import { PickedFile } from "shared/modules/file-uploader/file-uploader.component"; import { tap } from "rxjs/operators"; import { FormBuilder, FormGroup, Validators } from "@angular/forms"; -import { Observable, Subscription } from "rxjs"; +import { fromEvent, Observable, Subscription } from "rxjs"; +import { angularMaterialRenderers } from "@jsonforms/angular-material"; +import { EditableComponent } from "app-routing/pending-changes.guard"; +import { isEmpty } from "lodash-es"; +import { AppConfigService } from "app-config.service"; @Component({ selector: "publisheddata-edit", @@ -23,79 +29,69 @@ import { Observable, Subscription } from "rxjs"; styleUrls: ["./publisheddata-edit.component.scss"], standalone: false, }) -export class PublisheddataEditComponent implements OnInit, OnDestroy { +export class PublisheddataEditComponent + implements OnInit, OnDestroy, EditableComponent +{ + private _hasUnsavedChanges = false; + private publishedDataConfig$ = this.store.select(selectPublishedDataConfig); + renderers = angularMaterialRenderers; + schema: any = {}; + uiSchema: any = {}; + metadataData: any = {}; + public metadataFormErrors = []; + readonly panelOpenState = signal(false); routeSubscription = new Subscription(); publishedData$: Observable = new Observable(); attachments: Attachment[] = []; + form: FormGroup = this.formBuilder.group({ doi: [""], title: ["", Validators.required], - creator: [[""], Validators.minLength(1)], - publisher: ["", Validators.required], - resourceType: ["", Validators.required], abstract: ["", Validators.required], - pidArray: [[""], Validators.minLength(1)], - publicationYear: [0, Validators.required], - url: [""], - dataDescription: ["", Validators.required], - thumbnail: [""], - numberOfFiles: [0], - sizeOfArchive: [0], - downloadLink: [""], - relatedPublications: [[]], + datasetPids: [[""], Validators.minLength(1)], }); public separatorKeysCodes: number[] = [ENTER, COMMA]; + publishedDataConfigSubscription: Subscription; + beforeUnloadSubscription: Subscription; + initialMetadata: string; + appConfig = this.appConfigService.getConfig(); constructor( private formBuilder: FormBuilder, private route: ActivatedRoute, private router: Router, private store: Store, + private appConfigService: AppConfigService, ) {} - addCreator(event: MatChipInputEvent) { - const value = (event.value || "").trim(); - if (value) { - this.creator!.value.push(value); - } - - if (event.chipInput && event.chipInput.inputElement.value) { - event.chipInput.inputElement.value = ""; - } - } - - removeCreator(index: number) { - if (index >= 0) { - this.creator!.value.splice(index, 1); - } - } - - addRelatedPublication(event: MatChipInputEvent) { - const value = (event.value || "").trim(); - if (value) { - this.relatedPublications!.value.push(value); - } - - if (event.chipInput && event.chipInput.inputElement.value) { - event.chipInput.inputElement.value = ""; - } - } - - removeRelatedPublication(index: number) { - if (index >= 0) { - this.relatedPublications!.value.splice(index, 1); - } - } - - public onUpdate() { + public onPublishedDataUpdate(shouldRedirect = false) { if (this.form.valid) { - const doi = this.form.get("doi")!.value; + const { doi, ...rest } = this.form.value; + const metadata = { + ...this.metadataData, + landingPage: this.appConfig.landingPage, + }; + + if ( + shouldRedirect && + this.panelOpenState() && + !this.metadataDataIsValid() + ) { + return; + } + if (doi) { this.store.dispatch( - resyncPublishedDataAction({ doi, data: this.form.value }), + resyncPublishedDataAction({ + doi, + data: { ...rest, metadata }, + redirect: shouldRedirect, + }), ); } + + this._hasUnsavedChanges = false; } } @@ -107,37 +103,77 @@ export class PublisheddataEditComponent implements OnInit, OnDestroy { } } - onFileUploaderFilePicked(file: PickedFile) { - this.form.get("thumbnail")!.setValue(file.content); + public metadataDataIsValid() { + return this.metadataFormErrors.length === 0; } - deleteAttachment(attachmentId: string) { - this.form.get("thumbnail")!.setValue(""); + onErrors(errors) { + this.metadataFormErrors = errors; } - get creator() { - return this.form.get("creator"); - } - - get relatedPublications() { - return this.form.get("relatedPublications"); + onMetadataChange(data: any) { + this.metadataData = data; + if (JSON.stringify(data) !== this.initialMetadata) { + this._hasUnsavedChanges = true; + } } - get thumbnail() { - return this.form.get("thumbail"); + hasUnsavedChanges() { + return this._hasUnsavedChanges; } ngOnInit() { + this.store.dispatch(fetchPublishedDataConfigAction()); + this.routeSubscription = this.route.params.subscribe(({ id }) => this.store.dispatch(fetchPublishedDataAction({ id })), ); - this.publishedData$ = this.store - .select(selectCurrentPublishedData) - .pipe(tap((publishedData) => this.form.patchValue(publishedData))); + this.publishedDataConfigSubscription = this.publishedDataConfig$.subscribe( + (publishedDataConfig) => { + if (!isEmpty(publishedDataConfig)) { + this.schema = publishedDataConfig.metadataSchema; + // NOTE: We set the publicationYear by the system, so we remove it from the required fields in the frontend + this.schema.required.splice( + this.schema.required.indexOf("publicationYear"), + 1, + ); + this.uiSchema = publishedDataConfig.uiSchema; + } + }, + ); + + this.publishedData$ = this.store.select(selectCurrentPublishedData).pipe( + tap((publishedData) => { + this.form.patchValue(publishedData); + this.initialMetadata = JSON.stringify(this.form.value); + + if (publishedData?.metadata) { + this.initialMetadata = JSON.stringify(publishedData.metadata); + this.metadataData = publishedData.metadata; + } + }), + ); + + // Prevent user from reloading page if there are unsave changes + this.beforeUnloadSubscription = fromEvent(window, "beforeunload").subscribe( + (event) => { + if (this.hasUnsavedChanges()) { + event.preventDefault(); + } + }, + ); + + this.form.valueChanges.subscribe(() => { + if (this.form.dirty) { + this._hasUnsavedChanges = true; + } + }); } ngOnDestroy() { this.routeSubscription.unsubscribe(); + this.publishedDataConfigSubscription.unsubscribe(); + this.beforeUnloadSubscription.unsubscribe(); } } diff --git a/src/app/publisheddata/publisheddata.module.ts b/src/app/publisheddata/publisheddata.module.ts index 0d4362901..0040f1417 100644 --- a/src/app/publisheddata/publisheddata.module.ts +++ b/src/app/publisheddata/publisheddata.module.ts @@ -23,6 +23,10 @@ import { MatInputModule } from "@angular/material/input"; import { MatSelectModule } from "@angular/material/select"; import { MatTooltipModule } from "@angular/material/tooltip"; import { RouterModule } from "@angular/router"; +import { MatExpansionModule } from "@angular/material/expansion"; +import { JsonFormsModule } from "@jsonforms/angular"; +import { JsonFormsAngularMaterialModule } from "@jsonforms/angular-material"; +import { DatasetEffects } from "state-management/effects/datasets.effects"; @NgModule({ declarations: [ @@ -32,7 +36,7 @@ import { RouterModule } from "@angular/router"; ], imports: [ CommonModule, - EffectsModule.forFeature([PublishedDataEffects]), + EffectsModule.forFeature([PublishedDataEffects, DatasetEffects]), FlexLayoutModule, LinkyModule, MatButtonModule, @@ -50,6 +54,9 @@ import { RouterModule } from "@angular/router"; MatFormFieldModule, MatChipsModule, MatOptionModule, + MatExpansionModule, + JsonFormsModule, + JsonFormsAngularMaterialModule, ], }) export class PublisheddataModule {} diff --git a/src/app/state-management/actions/datasets.actions.ts b/src/app/state-management/actions/datasets.actions.ts index f12a8881a..16b30d6e5 100644 --- a/src/app/state-management/actions/datasets.actions.ts +++ b/src/app/state-management/actions/datasets.actions.ts @@ -226,6 +226,10 @@ export const selectDatasetAction = createAction( "[Dataset] Select Dataset", props<{ dataset: OutputDatasetObsoleteDto }>(), ); +export const selectDatasetsAction = createAction( + "[Dataset] Select Datasets", + props<{ datasets: OutputDatasetObsoleteDto[] }>(), +); export const deselectDatasetAction = createAction( "[Dataset] Deselect Dataset", props<{ dataset: OutputDatasetObsoleteDto }>(), diff --git a/src/app/state-management/actions/published-data.actions.spec.ts b/src/app/state-management/actions/published-data.actions.spec.ts index 7c2bc4227..7b34cba7a 100644 --- a/src/app/state-management/actions/published-data.actions.spec.ts +++ b/src/app/state-management/actions/published-data.actions.spec.ts @@ -95,9 +95,11 @@ describe("Published Data Actions", () => { describe("publishDatasetAction", () => { it("should create an action", () => { - const action = fromActions.publishDatasetAction({ data: publishedData }); + const action = fromActions.createDataPublicationAction({ + data: publishedData, + }); expect({ ...action }).toEqual({ - type: "[PublishedData] Publish Dataset", + type: "[PublishedData] Create Data Publication", data: publishedData, }); }); @@ -105,11 +107,11 @@ describe("Published Data Actions", () => { describe("publishDatasetCompleteAction", () => { it("should create an action", () => { - const action = fromActions.publishDatasetCompleteAction({ + const action = fromActions.createDataPublicationCompleteAction({ publishedData, }); expect({ ...action }).toEqual({ - type: "[PublishedData] Publish Dataset Complete", + type: "[PublishedData] Create Data Publication Complete", publishedData, }); }); @@ -117,9 +119,9 @@ describe("Published Data Actions", () => { describe("publishDatasetFailedAction", () => { it("should create an action", () => { - const action = fromActions.publishDatasetFailedAction(); + const action = fromActions.createDataPublicationFailedAction(); expect({ ...action }).toEqual({ - type: "[PublishedData] Publish Dataset Failed", + type: "[PublishedData] Create Data Publication Failed", }); }); }); @@ -149,9 +151,12 @@ describe("Published Data Actions", () => { describe("registerPublishedDataFailedAction", () => { it("should create an action", () => { - const action = fromActions.registerPublishedDataFailedAction(); + const action = fromActions.registerPublishedDataFailedAction({ + error: [], + }); expect({ ...action }).toEqual({ type: "[PublishedData] Register Published Data Failed", + error: [], }); }); }); diff --git a/src/app/state-management/actions/published-data.actions.ts b/src/app/state-management/actions/published-data.actions.ts index e0b363aa1..c07ebe455 100644 --- a/src/app/state-management/actions/published-data.actions.ts +++ b/src/app/state-management/actions/published-data.actions.ts @@ -1,8 +1,8 @@ import { createAction, props } from "@ngrx/store"; import { CreatePublishedDataDto, + PartialUpdatePublishedDataDto, PublishedData, - UpdatePublishedDataDto, } from "@scicatproject/scicat-sdk-ts-angular"; export const fetchAllPublishedDataAction = createAction( @@ -16,6 +16,17 @@ export const fetchAllPublishedDataFailedAction = createAction( "[PublishedData] Fetch All Published Datas Failed", ); +export const fetchPublishedDataConfigAction = createAction( + "[PublishedData] Fetch Published Data Config", +); +export const fetchPublishedDataConfigCompleteAction = createAction( + "[PublishedData] Fetch Published Data Config Complete", + props<{ publishedDataConfig: any }>(), +); +export const fetchPublishedDataConfigFailedAction = createAction( + "[PublishedData] Fetch Published Data Config Failed", +); + export const fetchCountAction = createAction("[PublishedData] Fetch Count"); export const fetchCountCompleteAction = createAction( "[PublishedData] Fetch Count Complete", @@ -37,16 +48,34 @@ export const fetchPublishedDataFailedAction = createAction( "[PublishedData] Fetch Published Data Failed", ); -export const publishDatasetAction = createAction( - "[PublishedData] Publish Dataset", +export const createDataPublicationAction = createAction( + "[PublishedData] Create Data Publication", props<{ data: CreatePublishedDataDto }>(), ); -export const publishDatasetCompleteAction = createAction( - "[PublishedData] Publish Dataset Complete", +export const createDataPublicationCompleteAction = createAction( + "[PublishedData] Create Data Publication Complete", + props<{ publishedData: PublishedData }>(), +); +export const createDataPublicationFailedAction = createAction( + "[PublishedData] Create Data Publication Failed", +); +export const saveDataPublicationAction = createAction( + "[PublishedData] Save Data Publication", + props<{ data: CreatePublishedDataDto }>(), +); +export const saveDataPublicationCompleteAction = createAction( + "[PublishedData] Save Data Publication Complete", + props<{ publishedData: PublishedData }>(), +); +export const saveDataPublicationFailedAction = createAction( + "[PublishedData] Save Data Publication Failed", +); +export const saveDataPublicationInLocalStorage = createAction( + "[PublishedData] Save Data Publication In Local Storage", props<{ publishedData: PublishedData }>(), ); -export const publishDatasetFailedAction = createAction( - "[PublishedData] Publish Dataset Failed", +export const clearDataPublicationFromLocalStorage = createAction( + "[PublishedData] Clear Data Publication In Local Storage", ); export const registerPublishedDataAction = createAction( @@ -59,20 +88,76 @@ export const registerPublishedDataCompleteAction = createAction( ); export const registerPublishedDataFailedAction = createAction( "[PublishedData] Register Published Data Failed", + props<{ error: string[] }>(), +); + +export const amendPublishedDataAction = createAction( + "[PublishedData] Amend Published Data", + props<{ doi: string }>(), +); +export const amendPublishedDataCompleteAction = createAction( + "[PublishedData] Amend Published Data Complete", + props<{ publishedData: PublishedData }>(), +); +export const amendPublishedDataFailedAction = createAction( + "[PublishedData] Amend Published Data Failed", + props<{ error: string[] }>(), +); + +export const deletePublishedDataAction = createAction( + "[PublishedData] Delete Published Data", + props<{ doi: string }>(), +); +export const deletePublishedDataCompleteAction = createAction( + "[PublishedData] Delete Published Data Complete", + props<{ doi: string }>(), +); +export const deletePublishedDataFailedAction = createAction( + "[PublishedData] Delete Published Data Failed", + props<{ error: string[] }>(), +); + +export const publishPublishedDataAction = createAction( + "[PublishedData] Publish Published Data", + props<{ doi: string }>(), +); +export const publishPublishedDataCompleteAction = createAction( + "[PublishedData] Publish Published Data Complete", + props<{ publishedData: PublishedData }>(), +); +export const publishPublishedDataFailedAction = createAction( + "[PublishedData] Publish Published Data Failed", + props<{ error: string[] }>(), ); export const resyncPublishedDataAction = createAction( "[PublishedData] Resync Published Data", - props<{ doi: string; data: UpdatePublishedDataDto }>(), + props<{ + doi: string; + data: PartialUpdatePublishedDataDto; + redirect: boolean; + }>(), ); export const resyncPublishedDataCompleteAction = createAction( "[PublishedData] Resync Published Data Complete", - props<{ publishedData: PublishedData }>(), + props<{ publishedData: PublishedData; redirect: boolean }>(), ); export const resyncPublishedDataFailedAction = createAction( "[PublishedData] Resync Published Data Failed", ); +export const updatePublishedDataAction = createAction( + "[PublishedData] Update Published Data", + props<{ doi: string; data: PartialUpdatePublishedDataDto }>(), +); +export const updatePublishedDataCompleteAction = createAction( + "[PublishedData] Update Published Data Complete", + props<{ publishedData: PublishedData }>(), +); +export const updatePublishedDataFailedAction = createAction( + "[PublishedData] Update Published Data Failed", +); + export const changePageAction = createAction( "[PublishedData] Change Page", props<{ page: number; limit: number }>(), @@ -86,3 +171,21 @@ export const sortByColumnAction = createAction( export const clearPublishedDataStateAction = createAction( "[PublischedData] Clear State", ); + +export const storeEditingPublishedDataDoiAction = createAction( + "[PublishedData] Store Editing Published Data DOI", + props<{ publishedDataDoi: string }>(), +); + +export const fetchRelatedDatasetsAndAddToBatchAction = createAction( + "[PublishedData] Fetch Related Datasets And Add To Batch", + props<{ datasetPids: string[]; publishedDataDoi: string }>(), +); + +export const fetchRelatedDatasetsAndAddToBatchCompleteAction = createAction( + "[PublishedData] Fetch Related Datasets And Add To Batch Complete", +); + +export const fetchRelatedDatasetsAndAddToBatchFailedAction = createAction( + "[PublishedData] Fetch Related Datasets And Add To Batch Failed", +); diff --git a/src/app/state-management/effects/published-data.effects.spec.ts b/src/app/state-management/effects/published-data.effects.spec.ts index 3f451e2fa..338670ee5 100644 --- a/src/app/state-management/effects/published-data.effects.spec.ts +++ b/src/app/state-management/effects/published-data.effects.spec.ts @@ -4,6 +4,7 @@ import { provideMockActions } from "@ngrx/effects/testing"; import { provideMockStore } from "@ngrx/store/testing"; import { selectQueryParams } from "state-management/selectors/published-data.selectors"; import * as fromActions from "state-management/actions/published-data.actions"; +import { clearBatchAction } from "state-management/actions/datasets.actions"; import { hot, cold } from "jasmine-marbles"; import { MessageType } from "state-management/models"; import { @@ -15,6 +16,7 @@ import { Type } from "@angular/core"; import { Router } from "@angular/router"; import { MockRouter, createMock } from "shared/MockStubs"; import { + DatasetsV4Service, PublishedData, PublishedDataService, } from "@scicatproject/scicat-sdk-ts-angular"; @@ -22,22 +24,22 @@ import { TestObservable } from "jasmine-marbles/src/test-observables"; const publishedData = createMock({ doi: "testDOI", - affiliation: "test affiliation", - creator: ["test creator"], - publisher: "test publisher", - publicationYear: 2019, title: "test title", abstract: "test abstract", - dataDescription: "test description", - resourceType: "test type", - pidArray: ["testPid"], + datasetPids: ["testPid"], createdAt: "", registeredTime: "", updatedAt: "", - url: "", numberOfFiles: 1, sizeOfArchive: 1, - status: "pending_registration", + metadata: { + creators: ["test creator"], + affiliation: "test affiliation", + publisher: { name: "test publisher" }, + resourceType: "test type", + url: "", + }, + status: PublishedData.StatusEnum.private, }); describe("PublishedDataEffects", () => { @@ -63,6 +65,12 @@ describe("PublishedDataEffects", () => { "publishedDataControllerRegisterV3", ]), }, + { + provide: DatasetsV4Service, + useValue: jasmine.createSpyObj("datasetsV4Service", [ + "datasetsV4ControllerFindAllV4", + ]), + }, { provide: Router, useClass: MockRouter }, ], }); @@ -211,11 +219,15 @@ describe("PublishedDataEffects", () => { describe("publishDataset$", () => { it("should result in a publishDatasetCompleteAction, a fetchPublishedDataAction", () => { const id = "testDOI"; - const action = fromActions.publishDatasetAction({ data: publishedData }); - const outcome1 = fromActions.publishDatasetCompleteAction({ + const action = fromActions.createDataPublicationAction({ + data: publishedData, + }); + const outcome1 = fromActions.createDataPublicationCompleteAction({ publishedData, }); const outcome2 = fromActions.fetchPublishedDataAction({ id }); + const outcome3 = clearBatchAction(); + const outcome4 = fromActions.clearDataPublicationFromLocalStorage(); actions = hot("-a", { a: action }); const response = cold("-a|", { a: publishedData }); @@ -223,16 +235,20 @@ describe("PublishedDataEffects", () => { response, ); - const expected = cold("--(bc)", { + const expected = cold("--(bcde)", { b: outcome1, c: outcome2, + d: outcome3, + e: outcome4, }); - expect(effects.publishDataset$).toBeObservable(expected); + expect(effects.createDataPublication$).toBeObservable(expected); }); it("should result in a publishDatasetFailedAction", () => { - const action = fromActions.publishDatasetAction({ data: publishedData }); - const outcome = fromActions.publishDatasetFailedAction(); + const action = fromActions.createDataPublicationAction({ + data: publishedData, + }); + const outcome = fromActions.createDataPublicationFailedAction(); actions = hot("-a", { a: action }); const response = cold("-#", {}); @@ -241,7 +257,7 @@ describe("PublishedDataEffects", () => { ); const expected = cold("--b", { b: outcome }); - expect(effects.publishDataset$).toBeObservable(expected); + expect(effects.createDataPublication$).toBeObservable(expected); }); }); @@ -252,7 +268,7 @@ describe("PublishedDataEffects", () => { content: "Publication Successful", duration: 5000, }; - const action = fromActions.publishDatasetCompleteAction({ + const action = fromActions.createDataPublicationCompleteAction({ publishedData, }); const outcome = showMessageAction({ message }); @@ -260,7 +276,9 @@ describe("PublishedDataEffects", () => { actions = hot("-a", { a: action }); const expected = cold("-b", { b: outcome }); - expect(effects.publishDatasetCompleteMessage$).toBeObservable(expected); + expect(effects.createDataPublicationCompleteMessage$).toBeObservable( + expected, + ); }); }); @@ -271,13 +289,15 @@ describe("PublishedDataEffects", () => { content: "Publication Failed", duration: 5000, }; - const action = fromActions.publishDatasetFailedAction(); + const action = fromActions.createDataPublicationFailedAction(); const outcome = showMessageAction({ message }); actions = hot("-a", { a: action }); const expected = cold("-b", { b: outcome }); - expect(effects.publishDatasetFailedMessage$).toBeObservable(expected); + expect(effects.createDataPublicationFailedMessage$).toBeObservable( + expected, + ); }); }); @@ -301,18 +321,23 @@ describe("PublishedDataEffects", () => { }); it("should result in a registerPublishedDataFailedAction", () => { - const doi = "testDOI"; - const action = fromActions.registerPublishedDataAction({ doi }); - const outcome = fromActions.registerPublishedDataFailedAction(); + const error = new Error("Test"); + const message = { + type: MessageType.Error, + content: "Registration Failed. " + error.message, + duration: 5000, + }; + const action = fromActions.registerPublishedDataFailedAction({ + error: [error.message], + }); + const outcome = showMessageAction({ message }); actions = hot("-a", { a: action }); - const response = cold("-#", {}); - publishedDataApi.publishedDataControllerRegisterV3.and.returnValue( - response, - ); - const expected = cold("--b", { b: outcome }); - expect(effects.registerPublishedData$).toBeObservable(expected); + const expected = cold("-b", { b: outcome }); + expect(effects.registerPublishedDataFailedMessage$).toBeObservable( + expected, + ); }); }); @@ -356,7 +381,7 @@ describe("PublishedDataEffects", () => { describe("ofType publishedDatasetAction", () => { it("should dispatch a loadingAction", () => { - const action = fromActions.publishDatasetAction({ + const action = fromActions.createDataPublicationAction({ data: publishedData, }); const outcome = loadingAction(); @@ -437,7 +462,7 @@ describe("PublishedDataEffects", () => { describe("ofType publishDatasetCompleteAction", () => { it("should dispatch a loadingCompleteAction", () => { - const action = fromActions.publishDatasetCompleteAction({ + const action = fromActions.createDataPublicationCompleteAction({ publishedData, }); const outcome = loadingCompleteAction(); @@ -451,7 +476,7 @@ describe("PublishedDataEffects", () => { describe("ofType publishDatasetFailedAction", () => { it("should dispatch a loadingCompleteAction", () => { - const action = fromActions.publishDatasetFailedAction(); + const action = fromActions.createDataPublicationFailedAction(); const outcome = loadingCompleteAction(); actions = hot("-a", { a: action }); @@ -477,7 +502,9 @@ describe("PublishedDataEffects", () => { describe("ofType registerPublishedDataFailedAction", () => { it("should dispatch a loadingCompleteAction", () => { - const action = fromActions.registerPublishedDataFailedAction(); + const action = fromActions.registerPublishedDataFailedAction({ + error: [], + }); const outcome = loadingCompleteAction(); actions = hot("-a", { a: action }); diff --git a/src/app/state-management/effects/published-data.effects.ts b/src/app/state-management/effects/published-data.effects.ts index d4cf8cba6..9d7482f53 100644 --- a/src/app/state-management/effects/published-data.effects.ts +++ b/src/app/state-management/effects/published-data.effects.ts @@ -2,6 +2,8 @@ import { Injectable } from "@angular/core"; import { Actions, createEffect, ofType } from "@ngrx/effects"; import { concatLatestFrom } from "@ngrx/operators"; import { + DatasetsV4Service, + OutputDatasetObsoleteDto, PublishedData, PublishedDataService, } from "@scicatproject/scicat-sdk-ts-angular"; @@ -11,6 +13,7 @@ import { selectQueryParams, } from "state-management/selectors/published-data.selectors"; import * as fromActions from "state-management/actions/published-data.actions"; +import * as datasetActions from "state-management/actions/datasets.actions"; import { mergeMap, map, @@ -18,6 +21,7 @@ import { switchMap, exhaustMap, filter, + tap, } from "rxjs/operators"; import { of } from "rxjs"; import { MessageType } from "state-management/models"; @@ -87,13 +91,31 @@ export class PublishedDataEffects { ); }); + fetchPublishedDataConfig$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.fetchPublishedDataConfigAction), + switchMap(() => + this.publishedDataService.publishedDataControllerGetConfigV3().pipe( + map((publishedDataConfig) => + fromActions.fetchPublishedDataConfigCompleteAction({ + publishedDataConfig, + }), + ), + catchError(() => + of(fromActions.fetchPublishedDataConfigFailedAction()), + ), + ), + ), + ); + }); + navigateToResyncedPublishedData$ = createEffect( () => { return this.actions$.pipe( ofType(fromActions.resyncPublishedDataCompleteAction), concatLatestFrom(() => this.store.select(selectCurrentPublishedData)), - filter(([_, publishedData]) => !!publishedData), - exhaustMap(([_, publishedData]) => + filter(([{ redirect }, publishedData]) => !!publishedData && redirect), + exhaustMap(([, publishedData]) => this.router.navigateByUrl( "/publishedDatasets/" + encodeURIComponent(publishedData.doi), ), @@ -103,24 +125,81 @@ export class PublishedDataEffects { { dispatch: false }, ); - publishDataset$ = createEffect(() => { + saveDataPublicationInLocalStorage$ = createEffect( + () => { + return this.actions$.pipe( + ofType(fromActions.saveDataPublicationInLocalStorage), + tap(({ publishedData }) => + localStorage.setItem("editingPublishedDataDoi", publishedData.doi), + ), + ); + }, + { dispatch: false }, + ); + + clearDataPublicationFromLocalStorage$ = createEffect( + () => { + return this.actions$.pipe( + ofType(fromActions.clearDataPublicationFromLocalStorage), + tap(() => localStorage.removeItem("editingPublishedDataDoi")), + ); + }, + { dispatch: false }, + ); + + saveDataPublication$ = createEffect(() => { return this.actions$.pipe( - ofType(fromActions.publishDatasetAction), + ofType(fromActions.saveDataPublicationAction), switchMap(({ data }) => this.publishedDataService.publishedDataControllerCreateV3(data).pipe( mergeMap((publishedData) => [ - fromActions.publishDatasetCompleteAction({ publishedData }), + fromActions.saveDataPublicationCompleteAction({ publishedData }), + fromActions.saveDataPublicationInLocalStorage({ publishedData }), + ]), + catchError(() => of(fromActions.saveDataPublicationFailedAction())), + ), + ), + ); + }); + + createDataPublication$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.createDataPublicationAction), + switchMap(({ data }) => + this.publishedDataService.publishedDataControllerCreateV3(data).pipe( + mergeMap((publishedData) => [ + fromActions.createDataPublicationCompleteAction({ + publishedData, + }), fromActions.fetchPublishedDataAction({ id: publishedData.doi }), + datasetActions.clearBatchAction(), + fromActions.clearDataPublicationFromLocalStorage(), + ]), + catchError(() => of(fromActions.createDataPublicationFailedAction())), + ), + ), + ); + }); + + publishPublishedData$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.publishPublishedDataAction), + switchMap(({ doi }) => + this.publishedDataService.publishedDataControllerPublishV3(doi).pipe( + mergeMap((publishedData) => [ + fromActions.publishPublishedDataCompleteAction({ publishedData }), ]), - catchError(() => of(fromActions.publishDatasetFailedAction())), + catchError((error) => + of(fromActions.publishPublishedDataFailedAction(error)), + ), ), ), ); }); - publishDatasetCompleteMessage$ = createEffect(() => { + createDataPublicationCompleteMessage$ = createEffect(() => { return this.actions$.pipe( - ofType(fromActions.publishDatasetCompleteAction), + ofType(fromActions.createDataPublicationCompleteAction), switchMap(() => { const message = { type: MessageType.Success, @@ -132,9 +211,9 @@ export class PublishedDataEffects { ); }); - publishDatasetFailedMessage$ = createEffect(() => { + createDataPublicationFailedMessage$ = createEffect(() => { return this.actions$.pipe( - ofType(fromActions.publishDatasetFailedAction), + ofType(fromActions.createDataPublicationFailedAction), switchMap(() => { const message = { type: MessageType.Error, @@ -157,22 +236,89 @@ export class PublishedDataEffects { }), fromActions.fetchPublishedDataAction({ id: doi }), ]), - catchError(() => of(fromActions.registerPublishedDataFailedAction())), + catchError((error) => + of(fromActions.registerPublishedDataFailedAction(error)), + ), + ), + ), + ); + }); + + amendPublishedData$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.amendPublishedDataAction), + switchMap(({ doi }) => + this.publishedDataService.publishedDataControllerAmendV3(doi).pipe( + mergeMap((publishedData: PublishedData) => [ + fromActions.amendPublishedDataCompleteAction({ + publishedData, + }), + fromActions.fetchPublishedDataAction({ id: doi }), + ]), + catchError((error) => + of(fromActions.amendPublishedDataFailedAction(error)), + ), ), ), ); }); + navigateToPublishedDatasets$ = createEffect( + () => { + return this.actions$.pipe( + ofType(fromActions.deletePublishedDataCompleteAction), + tap(() => this.router.navigateByUrl("/publishedDatasets")), + ); + }, + { dispatch: false }, + ); + + deletePublishedData$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.deletePublishedDataAction), + switchMap(({ doi }) => + this.publishedDataService.publishedDataControllerRemoveV3(doi).pipe( + mergeMap(() => [ + fromActions.deletePublishedDataCompleteAction({ doi }), + ]), + catchError((error) => + of(fromActions.deletePublishedDataFailedAction(error)), + ), + ), + ), + ); + }); + + updatePublishedData$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.updatePublishedDataAction), + switchMap(({ doi, data }) => + this.publishedDataService + .publishedDataControllerUpdateV3(doi, data) + .pipe( + mergeMap((publishedData) => [ + fromActions.updatePublishedDataCompleteAction({ publishedData }), + ]), + catchError(() => of(fromActions.updatePublishedDataFailedAction())), + ), + ), + ); + }); + resyncPublishedData$ = createEffect(() => { return this.actions$.pipe( ofType(fromActions.resyncPublishedDataAction), - switchMap(({ doi, data }) => + switchMap(({ doi, data, redirect }) => this.publishedDataService .publishedDataControllerResyncV3(doi, data) .pipe( mergeMap((publishedData) => [ - fromActions.resyncPublishedDataCompleteAction(publishedData), - fromActions.fetchPublishedDataAction({ id: doi }), + fromActions.resyncPublishedDataCompleteAction({ + publishedData, + redirect, + }), + datasetActions.clearBatchAction(), + fromActions.clearDataPublicationFromLocalStorage(), ]), catchError(() => of(fromActions.resyncPublishedDataFailedAction())), ), @@ -183,10 +329,24 @@ export class PublishedDataEffects { registerPublishedDataFailedMessage$ = createEffect(() => { return this.actions$.pipe( ofType(fromActions.registerPublishedDataFailedAction), - switchMap(() => { + switchMap((errors) => { + const message = { + type: MessageType.Error, + content: `Registration Failed. ${errors.error.map((e) => e.replaceAll("instance", "metadata")).join(", ")}`, + duration: 5000, + }; + return of(showMessageAction({ message })); + }), + ); + }); + + publishPublishedDataFailedMessage$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.publishPublishedDataFailedAction), + switchMap((errors) => { const message = { type: MessageType.Error, - content: "Registration Failed", + content: `Publishing Failed. ${errors.error.map((e) => e.replaceAll("instance", "metadata")).join(", ")}`, duration: 5000, }; return of(showMessageAction({ message })); @@ -194,6 +354,57 @@ export class PublishedDataEffects { ); }); + fetchRelatedDatasetsAndAddToBatch$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.fetchRelatedDatasetsAndAddToBatchAction), + switchMap(({ datasetPids, publishedDataDoi }) => + this.datasetsV4Service + .datasetsV4ControllerFindAllV4({ + filter: { where: { pid: { $in: datasetPids } } }, + }) + .pipe( + mergeMap((datasets) => [ + datasetActions.clearBatchAction(), + datasetActions.selectDatasetsAction({ + datasets: datasets as OutputDatasetObsoleteDto[], + }), + datasetActions.addToBatchAction(), + fromActions.fetchRelatedDatasetsAndAddToBatchCompleteAction(), + fromActions.storeEditingPublishedDataDoiAction({ + publishedDataDoi, + }), + ]), + catchError(() => + of(fromActions.fetchRelatedDatasetsAndAddToBatchFailedAction()), + ), + ), + ), + ); + }); + + storeEditingPublishedDataDoi$ = createEffect( + () => { + return this.actions$.pipe( + ofType(fromActions.storeEditingPublishedDataDoiAction), + tap(({ publishedDataDoi }) => { + localStorage.setItem("editingPublishedDataDoi", publishedDataDoi); + localStorage.setItem("editingDatasetList", "true"); + }), + ); + }, + { dispatch: false }, + ); + + navigateToBatch$ = createEffect( + () => { + return this.actions$.pipe( + ofType(fromActions.fetchRelatedDatasetsAndAddToBatchCompleteAction), + tap(() => this.router.navigateByUrl("/datasets/batch")), + ); + }, + { dispatch: false }, + ); + loading$ = createEffect(() => { return this.actions$.pipe( ofType( @@ -201,9 +412,15 @@ export class PublishedDataEffects { fromActions.fetchCountAction, fromActions.sortByColumnAction, fromActions.fetchPublishedDataAction, - fromActions.publishDatasetAction, + fromActions.createDataPublicationAction, + fromActions.saveDataPublicationAction, + fromActions.publishPublishedDataAction, fromActions.registerPublishedDataAction, - fromActions.resyncPublishedDataCompleteAction, + fromActions.resyncPublishedDataAction, + fromActions.updatePublishedDataAction, + fromActions.amendPublishedDataAction, + fromActions.deletePublishedDataAction, + fromActions.fetchRelatedDatasetsAndAddToBatchAction, ), switchMap(() => of(loadingAction())), ); @@ -218,11 +435,24 @@ export class PublishedDataEffects { fromActions.fetchCountFailedAction, fromActions.fetchPublishedDataCompleteAction, fromActions.fetchPublishedDataFailedAction, - fromActions.publishDatasetCompleteAction, - fromActions.publishDatasetFailedAction, + fromActions.createDataPublicationCompleteAction, + fromActions.saveDataPublicationCompleteAction, + fromActions.createDataPublicationFailedAction, + fromActions.saveDataPublicationFailedAction, + fromActions.publishPublishedDataCompleteAction, + fromActions.publishPublishedDataFailedAction, fromActions.registerPublishedDataCompleteAction, fromActions.registerPublishedDataFailedAction, fromActions.resyncPublishedDataCompleteAction, + fromActions.resyncPublishedDataFailedAction, + fromActions.updatePublishedDataCompleteAction, + fromActions.updatePublishedDataFailedAction, + fromActions.fetchRelatedDatasetsAndAddToBatchCompleteAction, + fromActions.fetchRelatedDatasetsAndAddToBatchFailedAction, + fromActions.amendPublishedDataCompleteAction, + fromActions.amendPublishedDataFailedAction, + fromActions.deletePublishedDataCompleteAction, + fromActions.deletePublishedDataFailedAction, ), switchMap(() => of(loadingCompleteAction())), ); @@ -231,6 +461,7 @@ export class PublishedDataEffects { constructor( private actions$: Actions, private publishedDataService: PublishedDataService, + private datasetsV4Service: DatasetsV4Service, private router: Router, private store: Store, ) {} diff --git a/src/app/state-management/reducers/datasets.reducer.ts b/src/app/state-management/reducers/datasets.reducer.ts index 4cb17be1f..f470e3fc9 100644 --- a/src/app/state-management/reducers/datasets.reducer.ts +++ b/src/app/state-management/reducers/datasets.reducer.ts @@ -202,6 +202,12 @@ const reducer = createReducer( return { ...state, selectedSets }; } }), + + on(fromActions.selectDatasetsAction, (state, { datasets }) => ({ + ...state, + selectedSets: datasets, + })), + on(fromActions.deselectDatasetAction, (state, { dataset }): DatasetState => { const selectedSets = state.selectedSets.filter( (selectedSet) => selectedSet.pid !== dataset.pid, diff --git a/src/app/state-management/reducers/published-data.reducer.spec.ts b/src/app/state-management/reducers/published-data.reducer.spec.ts index 5139b9940..0cc26984c 100644 --- a/src/app/state-management/reducers/published-data.reducer.spec.ts +++ b/src/app/state-management/reducers/published-data.reducer.spec.ts @@ -6,22 +6,24 @@ import { PublishedData } from "@scicatproject/scicat-sdk-ts-angular"; const publishedData = createMock({ doi: "testDOI", - affiliation: "test affiliation", - creator: ["test creator"], - publisher: "test publisher", - publicationYear: 2019, + title: "test title", abstract: "test abstract", - dataDescription: "test description", - resourceType: "test type", - pidArray: ["testPid"], + datasetPids: ["testPid"], createdAt: "", registeredTime: "", updatedAt: "", - url: "", numberOfFiles: 1, sizeOfArchive: 1, - status: "pending_registration", + metadata: { + creators: ["test creator"], + affiliation: "test affiliation", + publisher: { name: "test publisher" }, + publicationYear: 2019, + resourceType: "test type", + url: "", + }, + status: PublishedData.StatusEnum.private, }); describe("PublishedData Reducer", () => { diff --git a/src/app/state-management/reducers/published-data.reducer.ts b/src/app/state-management/reducers/published-data.reducer.ts index 66fd2a275..8d3502aed 100644 --- a/src/app/state-management/reducers/published-data.reducer.ts +++ b/src/app/state-management/reducers/published-data.reducer.ts @@ -8,6 +8,14 @@ import * as fromActions from "state-management/actions/published-data.actions"; const reducer = createReducer( initialPublishedDataState, + on( + fromActions.fetchPublishedDataConfigCompleteAction, + (state, { publishedDataConfig }): PublishedDataState => ({ + ...state, + publishedDataConfig, + }), + ), + on( fromActions.fetchAllPublishedDataCompleteAction, (state, { publishedData }): PublishedDataState => ({ @@ -24,6 +32,14 @@ const reducer = createReducer( }), ), + on( + fromActions.saveDataPublicationCompleteAction, + (state, { publishedData }): PublishedDataState => ({ + ...state, + currentPublishedData: publishedData, + }), + ), + on( fromActions.fetchPublishedDataCompleteAction, (state, { publishedData }): PublishedDataState => ({ @@ -32,6 +48,14 @@ const reducer = createReducer( }), ), + on( + fromActions.publishPublishedDataCompleteAction, + (state, { publishedData }): PublishedDataState => ({ + ...state, + currentPublishedData: publishedData, + }), + ), + on( fromActions.changePageAction, (state, { page, limit }): PublishedDataState => { diff --git a/src/app/state-management/selectors/published-data.selectors.spec.ts b/src/app/state-management/selectors/published-data.selectors.spec.ts index 961ee0a42..0d9aa2638 100644 --- a/src/app/state-management/selectors/published-data.selectors.spec.ts +++ b/src/app/state-management/selectors/published-data.selectors.spec.ts @@ -6,22 +6,22 @@ import { PublishedData } from "@scicatproject/scicat-sdk-ts-angular"; const publishedData = createMock({ doi: "testDOI", - affiliation: "test affiliation", - creator: ["test creator"], - publisher: "test publisher", - publicationYear: 2019, title: "test title", abstract: "test abstract", - dataDescription: "test description", - resourceType: "test type", - pidArray: ["testPid"], + datasetPids: ["testPid"], createdAt: "", registeredTime: "", updatedAt: "", - url: "", numberOfFiles: 1, sizeOfArchive: 1, - status: "pending_registration", + metadata: { + creators: ["test creator"], + affiliation: "test affiliation", + publisher: { name: "test publisher" }, + resourceType: "test type", + url: "", + }, + status: PublishedData.StatusEnum.private, }); const filters: GenericFilters = { diff --git a/src/app/state-management/selectors/published-data.selectors.ts b/src/app/state-management/selectors/published-data.selectors.ts index baa12d848..cd9dc71c4 100644 --- a/src/app/state-management/selectors/published-data.selectors.ts +++ b/src/app/state-management/selectors/published-data.selectors.ts @@ -14,6 +14,11 @@ export const selectCurrentPublishedData = createSelector( (state) => state.currentPublishedData, ); +export const selectPublishedDataConfig = createSelector( + selectPublishedDataState, + (state) => state.publishedDataConfig, +); + export const selectPublishedDataCount = createSelector( selectPublishedDataState, (state) => state.totalCount, diff --git a/src/app/state-management/state/published-data.store.ts b/src/app/state-management/state/published-data.store.ts index 4a45b196f..8c837f786 100644 --- a/src/app/state-management/state/published-data.store.ts +++ b/src/app/state-management/state/published-data.store.ts @@ -8,6 +8,8 @@ export interface PublishedDataState { totalCount: number; filters: GenericFilters; + + publishedDataConfig?: any; } export const initialPublishedDataState: PublishedDataState = { @@ -21,4 +23,6 @@ export const initialPublishedDataState: PublishedDataState = { skip: 0, limit: 25, }, + + publishedDataConfig: {}, }; diff --git a/src/styles.scss b/src/styles.scss index 629ad33d3..e7390f5a3 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -329,6 +329,14 @@ a:hover { input.mat-mdc-chip-input { margin-left: 0; } + + .mdc-evolution-chip-set__chips { + margin-left: 0 !important; + + input.mat-mdc-chip-input { + margin-left: 8px; + } + } } .mat-mdc-form-field {