From 0f7dd4becbfbbfd8671311c91351c87cef648a36 Mon Sep 17 00:00:00 2001 From: Farai Mutambara <71100224+mutambaraf@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:34:08 +0100 Subject: [PATCH 1/2] Release 23/10/2025 (#1237) Signed-off-by: dependabot[bot] Co-authored-by: Yoganandan Pandiyan Co-authored-by: Yoganandan Pandiyan <132274772+yoganandaness@users.noreply.github.com> Co-authored-by: Jekabs Karklins <58165815+jekabs-karklins@users.noreply.github.com> Co-authored-by: jekabskarklins Co-authored-by: Simon Fernandes Co-authored-by: Thomas Cottee Meldrum <81559866+TCMeldrum@users.noreply.github.com> Co-authored-by: Gergely Nyiri Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Ellen Wright <144028998+ellen-wright@users.noreply.github.com> Co-authored-by: Chi Kai Lam Co-authored-by: Deepak Jaison <149392207+deepaksftc@users.noreply.github.com> Co-authored-by: Gergely Nyiri Co-authored-by: Chi Kai Lam <114708346+bashanlam@users.noreply.github.com> --- .github/workflows/test-build.yml | 8 +- .../0183_AddVisitRegistrationClaim.sql | 26 + ...0184_AddDataAccessUserHasProposalTable.sql | 15 + .../db_patches/0199_AddNewWorkflowColumns.sql | 28 + .../0200_ChangeFapGradeToString.sql | 73 ++ .../db_patches/0200_MigrateWorkflows.sql | 58 ++ .../0200_Update_email_search_description.sql | 13 + .../db_seeds/0001_ProposalForScheduling.sql | 78 +- apps/backend/db_patches/db_seeds/0003_Esi.sql | 63 +- .../db_patches/db_seeds/0004_Experiments.sql | 63 +- .../db_patches/db_seeds/0004_Samples.sql | 125 ++- ...estions.sql => 0005_ProposalQuestions.sql} | 86 +- apps/backend/package-lock.json | 8 +- apps/backend/package.json | 4 +- .../auth/DataAccessUsersAuthorization.spec.ts | 59 ++ .../src/auth/DataAccessUsersAuthorization.ts | 52 + .../backend/src/auth/ProposalAuthorization.ts | 44 +- .../src/auth/QuestionaryAuthorization.ts | 4 +- apps/backend/src/buildContext.ts | 4 + apps/backend/src/config/Tokens.ts | 4 + .../src/config/dependencyConfigDefault.ts | 11 + .../backend/src/config/dependencyConfigE2E.ts | 11 + .../backend/src/config/dependencyConfigELI.ts | 12 + .../backend/src/config/dependencyConfigESS.ts | 12 + .../src/config/dependencyConfigSTFC.ts | 11 + .../src/config/dependencyConfigTest.ts | 11 + .../src/config/eli/configureELIEnvironment.ts | 4 - .../src/config/ess/configureESSEnvironment.ts | 5 +- .../config/stfc/configureSTFCEnvironment.ts | 4 - apps/backend/src/context/index.ts | 4 + apps/backend/src/customTypes/index.d.ts | 12 + .../datasources/DataAccessUsersDataSource.ts | 22 + .../src/datasources/ExperimentDataSource.ts | 4 +- apps/backend/src/datasources/FapDataSource.ts | 5 + .../src/datasources/InviteDataSource.ts | 4 + .../src/datasources/StatusDataSource.ts | 3 +- .../VisitRegistrationClaimDataSource.ts | 6 + .../src/datasources/WorkflowDataSource.ts | 29 +- .../src/datasources/mockups/CallDataSource.ts | 3 +- .../mockups/DataAccessUsersDataSource.ts | 138 +++ .../mockups/ExperimentDataSource.ts | 8 +- .../src/datasources/mockups/FapDataSource.ts | 10 +- .../datasources/mockups/InviteDataSource.ts | 12 + .../datasources/mockups/ReviewDataSource.ts | 10 +- .../VisitRegistrationClaimDataSource.ts | 40 + .../datasources/mockups/WorkflowDataSource.ts | 79 +- .../datasources/postgres/CallDataSource.ts | 3 +- .../postgres/DataAccessUsersDataSource.ts | 105 +++ .../postgres/ExperimentDataSource.ts | 89 +- .../src/datasources/postgres/FapDataSource.ts | 41 + .../datasources/postgres/InviteDataSource.ts | 29 + .../postgres/ProposalDataSource.ts | 121 +-- .../datasources/postgres/StatusDataSource.ts | 20 +- .../datasources/postgres/SystemDataSource.ts | 2 +- .../datasources/postgres/VisitDataSource.ts | 9 +- .../VisitRegistrationClaimDataSource.ts | 50 + .../postgres/WorkflowDataSource.ts | 219 ++--- .../postgres/databaseExtensions.ts | 22 + .../src/datasources/postgres/records.ts | 23 +- .../stfc/StfcProposalDataSource.ts | 11 +- .../eventHandlers/email/eliEmailHandler.ts | 2 +- .../email/essEmailHandler.spec.ts | 91 +- .../eventHandlers/email/essEmailHandler.ts | 53 +- apps/backend/src/eventHandlers/logging.ts | 52 +- .../src/eventHandlers/messageBroker.ts | 68 +- apps/backend/src/events/applicationEvents.ts | 35 +- apps/backend/src/events/event.enum.ts | 31 +- apps/backend/src/factory/pdf/proposal.ts | 4 + .../backend/src/middlewares/readinessCheck.ts | 33 +- apps/backend/src/models/Review.ts | 2 +- apps/backend/src/models/Settings.ts | 1 - .../src/models/VisitRegistrationClaim.ts | 6 + apps/backend/src/models/Workflow.ts | 3 +- .../backend/src/models/WorkflowConnections.ts | 10 +- .../models/questionTypes/FapReviewBasis.ts | 8 +- .../src/models/questionTypes/Interval.ts | 10 +- .../DataAccessUsersMutations.spec.ts | 100 ++ .../src/mutations/DataAccessUsersMutations.ts | 83 ++ .../src/mutations/InviteMutations.spec.ts | 86 +- apps/backend/src/mutations/InviteMutations.ts | 149 ++- .../src/mutations/ProposalMutations.ts | 22 +- .../ProposalSettingsMutations.spec.ts | 9 + .../src/mutations/ReviewMutations.spec.ts | 8 +- apps/backend/src/mutations/SampleMutations.ts | 6 +- .../src/mutations/ShipmentMutations.ts | 4 +- apps/backend/src/mutations/VisitMutations.ts | 1 + .../src/mutations/WorkflowMutations.ts | 493 ++-------- .../src/queries/DataAccessUsersQueries.ts | 20 + .../src/queries/ExperimentQueries.spec.ts | 51 +- apps/backend/src/queries/ExperimentQueries.ts | 84 +- apps/backend/src/queries/FapQueries.spec.ts | 1 + apps/backend/src/queries/FapQueries.ts | 35 +- apps/backend/src/queries/InviteQueries.ts | 25 +- apps/backend/src/queries/WorkflowQueries.ts | 67 +- .../mutations/AssignMembersToFapMutation.ts | 3 + .../mutations/CreateVisitMutation.ts | 25 +- .../mutations/NotifyProposalMutation.ts | 6 +- .../UpdateDataAccessUsersMutation.ts | 38 + .../mutations/UpdateReviewMutation.ts | 7 +- .../mutations/UpdateVisitMutation.ts | 20 +- .../settings/AddWorkflowStatusMutation.ts | 15 +- .../DeleteWorkflowConnectionMutation.ts | 18 + .../settings/MoveWorkflowStatusMutation.ts | 48 - .../settings/UpdateStatusMutation.ts | 14 +- .../settings/UpdateWorkflowMutation.ts | 13 +- .../settings/UpdateWorkflowStatusMutation.ts | 48 + .../resolvers/queries/DataAccessUsersQuery.ts | 6 +- .../src/resolvers/queries/ExperimentsQuery.ts | 5 +- .../backend/src/resolvers/queries/FapQuery.ts | 2 + .../src/resolvers/queries/WorkflowQuery.ts | 9 +- .../src/resolvers/types/FieldConfig.ts | 17 +- apps/backend/src/resolvers/types/Review.ts | 5 +- apps/backend/src/resolvers/types/Visit.ts | 12 + .../resolvers/types/VisitRegistrationClaim.ts | 14 + apps/backend/src/resolvers/types/Workflow.ts | 13 +- .../src/resolvers/types/WorkflowConnection.ts | 18 +- apps/backend/src/utils/mathFunctions.ts | 2 +- apps/backend/src/workflowEngine/proposal.ts | 133 ++- apps/e2e/cypress/e2e/FAPs.cy.ts | 305 ++++-- apps/e2e/cypress/e2e/appSettings.cy.ts | 4 +- apps/e2e/cypress/e2e/calls.cy.ts | 9 +- apps/e2e/cypress/e2e/eventLogs.cy.ts | 2 +- .../cypress/e2e/experimentSafetyReview.cy.ts | 68 +- apps/e2e/cypress/e2e/experiments.cy.ts | 61 +- apps/e2e/cypress/e2e/features.cy.ts | 4 +- apps/e2e/cypress/e2e/generalFaps.cy.ts | 12 +- apps/e2e/cypress/e2e/invites.cy.ts | 18 +- apps/e2e/cypress/e2e/pages.cy.ts | 2 +- apps/e2e/cypress/e2e/peopleTable.cy.ts | 2 - .../e2e/cypress/e2e/personalInformation.cy.ts | 4 +- apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts | 5 +- apps/e2e/cypress/e2e/proposals.cy.ts | 43 +- apps/e2e/cypress/e2e/samples.cy.ts | 35 + apps/e2e/cypress/e2e/settings.cy.ts | 460 +++++---- apps/e2e/cypress/e2e/shipments.cy.ts | 8 +- apps/e2e/cypress/e2e/statusActions.cy.ts | 83 +- apps/e2e/cypress/e2e/techniqueProposals.cy.ts | 4 +- apps/e2e/cypress/e2e/templatesBasic.cy.ts | 105 ++- .../fixtures/exampleCallFapExportSTFC.json | 8 +- .../fixtures/exampleFapExportSTFC.json | 8 +- .../e2e/cypress/fixtures/template_export.json | 350 +++---- apps/e2e/cypress/support/initialDBData.ts | 51 +- apps/e2e/cypress/support/template.ts | 21 +- apps/e2e/cypress/support/utils.ts | 10 + apps/e2e/cypress/support/workflow.ts | 89 +- apps/e2e/cypress/types/template.d.ts | 8 +- apps/e2e/cypress/types/workflow.d.ts | 36 + apps/frontend/package-lock.json | 889 +++++++++++++++++- apps/frontend/package.json | 3 +- .../call/AssignedInstrumentsTable.tsx | 7 + .../src/components/call/CallGeneralInfo.tsx | 14 +- .../src/components/call/CreateUpdateCall.tsx | 1 - .../components/common/UserManagementTable.tsx | 44 +- .../experiment/ExperimentVisitsTable.tsx | 4 +- .../experiment/ExperimentsTable.tsx | 6 +- apps/frontend/src/components/fap/FapPage.tsx | 19 + .../AssignFapMemberToProposalModal.tsx | 25 +- .../Proposals/FapAssignedReviewersTable.tsx | 221 +++-- .../fap/Proposals/FapLegacyProposalsTable.tsx | 431 +++++++++ .../FapProposalsAndAssignmentsTable.tsx | 16 +- .../FapProposalsAndAssignmentsView.tsx | 22 +- .../fap/Proposals/LegacyFapProposals.tsx | 95 ++ .../Proposals/MultiRankAssignmentDialog.tsx | 178 ++++ .../src/components/menu/MenuItems.tsx | 67 +- .../components/menu/ProposalMenuListItem.tsx | 84 ++ .../components/menu/SettingsMenuListItem.tsx | 31 +- .../src/components/proposal/CoProposers.tsx | 53 ++ .../proposal/DataAccessUsersModal.tsx | 41 +- .../src/components/proposal/NoOptionsText.tsx | 11 +- .../components/proposal/NotifyProposal.tsx | 83 ++ ...InviteUser.tsx => ParticipantSelector.tsx} | 93 +- ...posalParticipants.tsx => Participants.tsx} | 11 +- .../proposal/PrincipalInvestigator.tsx | 90 ++ .../components/proposal/ProposalContainer.tsx | 8 + ...pant.tsx => ProposalParticipantLegacy.tsx} | 2 +- .../components/proposal/ProposalSummary.tsx | 31 +- .../src/components/proposal/ProposalTable.tsx | 29 +- .../proposal/ProposalTableOfficer.tsx | 39 +- .../proposalBooking/CreateUpdateVisit.tsx | 23 +- .../questionary/NavigationFragment.tsx | 1 + .../QuestionFapReviewBasisForm.tsx | 51 + ...tionTemplateRelationFapReviewBasisForm.tsx | 65 +- .../QuestionaryComponentFapReviewBasis.tsx | 153 +-- .../createFapReviewBasisValidationSchema.ts | 43 +- .../Interval/IntervalDefinition.tsx | 5 +- .../Interval/QuestionIntervalForm.tsx | 30 +- .../QuestionTemplateRelationIntervalForm.tsx | 133 ++- .../Interval/QuestionaryComponentInterval.tsx | 4 +- .../QuestionaryComponentProposalBasis.tsx | 98 +- .../review/ProposalReviewContent.tsx | 7 +- .../review/ProposalTableReviewer.tsx | 2 +- .../components/review/ReviewQuestionary.tsx | 2 +- .../src/components/review/ReviewSummary.tsx | 4 +- .../review/TechnicalReviewInformation.tsx | 81 -- .../TechnicalReviewQuestionaryReview.tsx | 29 +- .../workflow/AddNewWorkflowConnectionsRow.tsx | 126 --- .../AddStatusChangingEventsToConnection.tsx | 18 + .../settings/workflow/LoadingOverlay.tsx | 32 + .../workflow/StatusEventsAndActionsDialog.tsx | 9 + .../settings/workflow/StatusNode.tsx | 123 +++ .../settings/workflow/StatusPicker.tsx | 193 ++-- .../settings/workflow/WorkflowCanvas.tsx | 92 ++ .../workflow/WorkflowConnectionsEditor.tsx | 417 -------- .../settings/workflow/WorkflowEdge.tsx | 116 +++ .../settings/workflow/WorkflowEditor.tsx | 592 ++++++++---- .../settings/workflow/WorkflowEditorModel.tsx | 288 ++---- .../workflow/WorkflowMetadataEditor.tsx | 85 +- .../template/TemplateQuestionEditor.tsx | 11 +- .../src/context/UserContextProvider.tsx | 9 +- .../call/getCallSubmissionDetails.graphql | 10 +- .../updateDataAccessUsers.graphql | 8 + .../experiment/getAllExperiments.graphql | 2 +- .../src/graphql/fap/getFapProposals.graphql | 4 +- .../graphql/proposal/notifyProposal.graphql | 7 +- .../src/graphql/review/updateReview.graphql | 2 +- .../settings/addWorkflowStatus.graphql | 10 +- .../graphql/settings/createWorkflow.graphql | 43 +- .../graphql/settings/deleteWorkflow.graphql | 1 + .../settings/deleteWorkflowConnection.graphql | 13 + ...Status.graphql => fragment.status.graphql} | 0 .../src/graphql/settings/getWorkflow.graphql | 47 +- .../src/graphql/settings/getWorkflows.graphql | 8 +- .../settings/moveWorkflowStatus.graphql | 11 - .../graphql/settings/updateWorkflow.graphql | 43 +- .../settings/updateWorkflowStatus.graphql | 32 + .../template/fragment.fieldConfig.graphql | 5 + .../graphql/user/getUserExperiments.graphql | 3 + .../src/graphql/visit/createVisit.graphql | 7 + .../src/graphql/visit/updateVisit.graphql | 6 + .../src/hooks/experiment/useAllExperiments.ts | 2 +- .../src/hooks/fap/useFapProposalsData.ts | 6 +- .../src/hooks/questionary/useSubmitActions.ts | 11 +- .../settings/usePersistWorkflowEditorModel.ts | 203 ++-- apps/frontend/src/utils/mathFunctions.ts | 2 +- .../src/utils/workflowInitialStatuses.ts | 1 + package-lock.json | 545 ++++------- package.json | 2 +- 237 files changed, 8532 insertions(+), 3980 deletions(-) create mode 100644 apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql create mode 100644 apps/backend/db_patches/0184_AddDataAccessUserHasProposalTable.sql create mode 100644 apps/backend/db_patches/0199_AddNewWorkflowColumns.sql create mode 100644 apps/backend/db_patches/0200_ChangeFapGradeToString.sql create mode 100644 apps/backend/db_patches/0200_MigrateWorkflows.sql create mode 100644 apps/backend/db_patches/0200_Update_email_search_description.sql rename apps/backend/db_patches/db_seeds/{0005_Questions.sql => 0005_ProposalQuestions.sql} (73%) create mode 100644 apps/backend/src/auth/DataAccessUsersAuthorization.spec.ts create mode 100644 apps/backend/src/auth/DataAccessUsersAuthorization.ts create mode 100644 apps/backend/src/datasources/DataAccessUsersDataSource.ts create mode 100644 apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts create mode 100644 apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts create mode 100644 apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts create mode 100644 apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts create mode 100644 apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts create mode 100644 apps/backend/src/models/VisitRegistrationClaim.ts create mode 100644 apps/backend/src/mutations/DataAccessUsersMutations.spec.ts create mode 100644 apps/backend/src/mutations/DataAccessUsersMutations.ts create mode 100644 apps/backend/src/queries/DataAccessUsersQueries.ts create mode 100644 apps/backend/src/resolvers/mutations/UpdateDataAccessUsersMutation.ts create mode 100644 apps/backend/src/resolvers/mutations/settings/DeleteWorkflowConnectionMutation.ts delete mode 100644 apps/backend/src/resolvers/mutations/settings/MoveWorkflowStatusMutation.ts create mode 100644 apps/backend/src/resolvers/mutations/settings/UpdateWorkflowStatusMutation.ts create mode 100644 apps/backend/src/resolvers/types/VisitRegistrationClaim.ts create mode 100644 apps/frontend/src/components/fap/Proposals/FapLegacyProposalsTable.tsx create mode 100644 apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx create mode 100644 apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx create mode 100644 apps/frontend/src/components/menu/ProposalMenuListItem.tsx create mode 100644 apps/frontend/src/components/proposal/CoProposers.tsx create mode 100644 apps/frontend/src/components/proposal/NotifyProposal.tsx rename apps/frontend/src/components/proposal/{InviteUser.tsx => ParticipantSelector.tsx} (79%) rename apps/frontend/src/components/proposal/{ProposalParticipants.tsx => Participants.tsx} (74%) create mode 100644 apps/frontend/src/components/proposal/PrincipalInvestigator.tsx rename apps/frontend/src/components/proposal/{ProposalParticipant.tsx => ProposalParticipantLegacy.tsx} (97%) delete mode 100644 apps/frontend/src/components/review/TechnicalReviewInformation.tsx delete mode 100644 apps/frontend/src/components/settings/workflow/AddNewWorkflowConnectionsRow.tsx create mode 100644 apps/frontend/src/components/settings/workflow/LoadingOverlay.tsx create mode 100644 apps/frontend/src/components/settings/workflow/StatusNode.tsx create mode 100644 apps/frontend/src/components/settings/workflow/WorkflowCanvas.tsx delete mode 100644 apps/frontend/src/components/settings/workflow/WorkflowConnectionsEditor.tsx create mode 100644 apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx create mode 100644 apps/frontend/src/graphql/dataAccessUser/updateDataAccessUsers.graphql create mode 100644 apps/frontend/src/graphql/settings/deleteWorkflowConnection.graphql rename apps/frontend/src/graphql/settings/{fragment.proposalStatus.graphql => fragment.status.graphql} (100%) delete mode 100644 apps/frontend/src/graphql/settings/moveWorkflowStatus.graphql create mode 100644 apps/frontend/src/graphql/settings/updateWorkflowStatus.graphql create mode 100644 apps/frontend/src/utils/workflowInitialStatuses.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index db182f9a78..f113ecc3a4 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -273,13 +273,15 @@ jobs: - id: p-am files: cypress/e2e/p[a-m]*.ts - id: p-nz - files: cypress/e2e/p[n-z]*.ts + files: cypress/e2e/p[n-z]!(oposals)*.ts + - id: proposals + files: cypress/e2e/proposals*.ts - id: rs files: cypress/e2e/[r-s]*.ts - id: t - files: cypress/e2e/t!(emplateDeleteAndArchive).ts + files: cypress/e2e/t!(emplatesBasic).ts - id: templateDelete - files: cypress/e2e/templateDeleteAndArchive*.ts + files: cypress/e2e/templatesBasic*.ts - id: uz files: cypress/e2e/[u-z]*.ts - id: AZ diff --git a/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql b/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql new file mode 100644 index 0000000000..e485bd4761 --- /dev/null +++ b/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql @@ -0,0 +1,26 @@ +DO +$$ +BEGIN + IF register_patch( + 'AddVisitRegistrationClaim.sql', + 'Jekabs Karklins', + 'Adding table to store visit registration claims', + '2025-06-04' + ) THEN + BEGIN + -- Create the visit_registration_claims table + CREATE TABLE IF NOT EXISTS visit_registration_claims ( + invite_id INT NOT NULL, + visit_id INT NOT NULL, + FOREIGN KEY (invite_id) REFERENCES invites(invite_id) ON DELETE CASCADE, + FOREIGN KEY (visit_id) REFERENCES visits(visit_id) ON DELETE CASCADE, + UNIQUE (invite_id, visit_id) + ); + + ALTER TABLE visit_registration_claims ADD CONSTRAINT visit_registration_unique + UNIQUE (invite_id, visit_id); + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/0184_AddDataAccessUserHasProposalTable.sql b/apps/backend/db_patches/0184_AddDataAccessUserHasProposalTable.sql new file mode 100644 index 0000000000..266838a672 --- /dev/null +++ b/apps/backend/db_patches/0184_AddDataAccessUserHasProposalTable.sql @@ -0,0 +1,15 @@ +DO +$$ +BEGIN + IF register_patch('AddDataAccessUserHasProposalTable.sql', 'jekabskarklins', 'Add data_access_user_has_proposal table to manage users with data access to proposals', '2025-07-11') THEN + BEGIN + CREATE TABLE data_access_user_has_proposal ( + proposal_pk int REFERENCES proposals (proposal_pk) ON DELETE CASCADE + , user_id int REFERENCES users (user_id) ON DELETE CASCADE + , PRIMARY KEY (proposal_pk, user_id) + ); + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/0199_AddNewWorkflowColumns.sql b/apps/backend/db_patches/0199_AddNewWorkflowColumns.sql new file mode 100644 index 0000000000..89f3ff8d43 --- /dev/null +++ b/apps/backend/db_patches/0199_AddNewWorkflowColumns.sql @@ -0,0 +1,28 @@ +DO +$$ +BEGIN + IF register_patch('Add new workflow columns', 'Jekabs', 'Add position and prev_connection_id columns to workflow_connections', '2025-07-16') THEN + BEGIN + -- Allow these to be nullable. We will drop the columns later as they are not used anymore. + ALTER TABLE public.workflow_connections + ALTER COLUMN droppable_group_id DROP NOT NULL; + + ALTER TABLE public.workflow_connections + ALTER COLUMN parent_droppable_group_id DROP NOT NULL; + + + -- Add position and prev_connection_id columns to workflow_connections + ALTER TABLE workflow_connections ADD COLUMN IF NOT EXISTS pos_x INTEGER DEFAULT 0; + ALTER TABLE workflow_connections ADD COLUMN IF NOT EXISTS pos_y INTEGER DEFAULT 0; + ALTER TABLE workflow_connections ADD COLUMN IF NOT EXISTS prev_connection_id INTEGER DEFAULT NULL + REFERENCES workflow_connections(workflow_connection_id) + ON DELETE SET NULL; + + -- Add connection line type column to workflows table + ALTER TABLE workflows ADD COLUMN IF NOT EXISTS connection_line_type VARCHAR(50) DEFAULT 'default'; + + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/0200_ChangeFapGradeToString.sql b/apps/backend/db_patches/0200_ChangeFapGradeToString.sql new file mode 100644 index 0000000000..2ad8e272da --- /dev/null +++ b/apps/backend/db_patches/0200_ChangeFapGradeToString.sql @@ -0,0 +1,73 @@ +DO +$$ +BEGIN + IF register_patch('ChangeFapGradeToString.sql', 'TCMeldrum', 'Make grade a string for FAPs', '2025-09-04') THEN + BEGIN + + DROP VIEW review_data; + + ALTER TABLE fap_reviews + ALTER COLUMN grade type varchar(30); + + CREATE OR REPLACE VIEW public.review_data + AS SELECT proposal.proposal_pk, + proposal.proposal_id, + proposal.title, + proposal.instrument_name, + proposal.availability_time, + proposal.time_allocation, + proposal.fap_id, + proposal.rank_order, + proposal.call_id, + proposal.proposer_id, + proposal.instrument_id, + proposal.fap_time_allocation, + proposal.questionary_id, + grade.avg AS average_grade, + proposal.public_comment AS comment + FROM ( SELECT fp.proposal_pk, + p.proposal_id, + p.title, + i.name AS instrument_name, + chi.availability_time, + tr.time_allocation, + f.fap_id, + fmd.rank_order, + c.call_id, + p.proposer_id, + i.instrument_id, + fp.fap_time_allocation, + p.questionary_id, + tr.public_comment + FROM fap_proposals fp + JOIN faps f ON f.fap_id = fp.fap_id + JOIN call c ON c.call_id = fp.call_id + JOIN proposals p ON p.proposal_pk = fp.proposal_pk + JOIN technical_review tr ON tr.proposal_pk = p.proposal_pk AND tr.instrument_id = fp.instrument_id + LEFT JOIN fap_meeting_decisions fmd ON fmd.proposal_pk = p.proposal_pk + JOIN call_has_instruments chi ON chi.instrument_id = fp.instrument_id AND chi.call_id = c.call_id + JOIN instruments i ON i.instrument_id = chi.instrument_id + WHERE p.status_id <> 9 AND p.status_id <> 1) proposal + LEFT JOIN ( SELECT fr.proposal_pk, + avg(CASE + WHEN fr.grade ~ '^\d+(\.\d+)\?$' THEN fr.grade::double precision + ELSE NULL + END) AS avg + FROM fap_proposals fp + JOIN fap_reviews fr ON fr.proposal_pk = fp.proposal_pk + GROUP BY fr.proposal_pk) grade ON grade.proposal_pk = proposal.proposal_pk; + + + UPDATE questions SET default_config = jsonb_set(default_config, '{maxGrade}', '10', true) WHERE question_id = 'fap_review_basis'; + UPDATE questions SET default_config = jsonb_set(default_config, '{minGrade}', '1', true) WHERE question_id = 'fap_review_basis'; + UPDATE questions SET default_config = jsonb_set(default_config, '{decimalPoints}', '0', true) WHERE question_id = 'fap_review_basis'; + UPDATE templates_has_questions SET config = jsonb_set(config, '{maxGrade}', '10', true) WHERE question_id = 'fap_review_basis'; + UPDATE templates_has_questions SET config = jsonb_set(config, '{minGrade}', '1', true) WHERE question_id = 'fap_review_basis'; + UPDATE templates_has_questions SET config = jsonb_set(config, '{decimalPoints}', '0', true) WHERE question_id = 'fap_review_basis'; + + DELETE FROM settings WHERE settings_id = 'GRADE_PRECISION'; + END; + END IF; +END; +$$ +LANGUAGE plpgsql; \ No newline at end of file diff --git a/apps/backend/db_patches/0200_MigrateWorkflows.sql b/apps/backend/db_patches/0200_MigrateWorkflows.sql new file mode 100644 index 0000000000..e5e320b768 --- /dev/null +++ b/apps/backend/db_patches/0200_MigrateWorkflows.sql @@ -0,0 +1,58 @@ +DO $$ +BEGIN + IF register_patch( + 'MigrateWorkflows', + 'Jekabs', + 'Migrate workflows', + '2025-07-16' + ) THEN + + -- Backfill: + -- same workflow connection group (earlier sort_order) first; + -- else parent connection group (largest sort_order) + WITH resolved AS ( + SELECT + c.workflow_connection_id, + ( + SELECT p.workflow_connection_id + FROM public.workflow_connections p + WHERE p.workflow_id = c.workflow_id + AND p.droppable_group_id = c.droppable_group_id + AND p.status_id = c.prev_status_id + AND p.sort_order < c.sort_order + ORDER BY p.sort_order DESC + LIMIT 1 + ) AS same_prev_id, + ( + SELECT q.workflow_connection_id + FROM public.workflow_connections q + WHERE q.workflow_id = c.workflow_id + AND q.droppable_group_id = c.parent_droppable_group_id + AND q.status_id = c.prev_status_id + ORDER BY q.sort_order DESC + LIMIT 1 + ) AS parent_prev_id + FROM public.workflow_connections c + WHERE c.prev_status_id IS NOT NULL + ) + UPDATE public.workflow_connections wc + SET prev_connection_id = COALESCE(resolved.same_prev_id, resolved.parent_prev_id) + FROM resolved + WHERE wc.workflow_connection_id = resolved.workflow_connection_id; + + -- Add FK (ignore if it already exists) + BEGIN + ALTER TABLE public.workflow_connections + ADD CONSTRAINT workflow_connections_prev_connection_id_fkey + FOREIGN KEY (prev_connection_id) + REFERENCES public.workflow_connections (workflow_connection_id) + ON UPDATE NO ACTION + ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN + -- constraint already exists; ignore + NULL; + END; + + END IF; +END; +$$ LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/0200_Update_email_search_description.sql b/apps/backend/db_patches/0200_Update_email_search_description.sql new file mode 100644 index 0000000000..b1caa98620 --- /dev/null +++ b/apps/backend/db_patches/0200_Update_email_search_description.sql @@ -0,0 +1,13 @@ +DO +$$ +BEGIN + IF register_patch('Update_email_search_description.sql', 'jekabskarklins', 'Updates email search description to make it more clear.', '2025-09-09') THEN + BEGIN + UPDATE features + SET description = 'By setting this feature ON, users can be searched *only* by their exact email addresses.' + WHERE feature_id = 'EMAIL_SEARCH'; + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/db_seeds/0001_ProposalForScheduling.sql b/apps/backend/db_patches/db_seeds/0001_ProposalForScheduling.sql index 88ab7a78ad..b25ef98568 100644 --- a/apps/backend/db_patches/db_seeds/0001_ProposalForScheduling.sql +++ b/apps/backend/db_patches/db_seeds/0001_ProposalForScheduling.sql @@ -1,7 +1,8 @@ DO $DO$ DECLARE - questionary_id_var int; + questionary_id_var1 int; + questionary_id_var2 int; technical_review_template_id_var int; technical_review_questionary_id_var int; BEGIN @@ -26,10 +27,18 @@ BEGIN INSERT INTO questionaries(template_id, created_at, creator_id) VALUES (1, NOW(), 1); SELECT questionaries.questionary_id - INTO questionary_id_var + INTO questionary_id_var1 FROM questionaries WHERE template_id = 1; + INSERT INTO questionaries(template_id, created_at, creator_id) VALUES (1, NOW(), 1); + + SELECT questionaries.questionary_id + INTO questionary_id_var2 + FROM questionaries + WHERE template_id = 1 + AND questionary_id <> questionary_id_var1; + SELECT templates.template_id INTO technical_review_template_id_var FROM templates @@ -61,20 +70,20 @@ BEGIN ) VALUES ( - 'Test proposal' -- title - , 'Lorem ipsum' -- abstract - , 8 -- status_id - , 1 -- proposer_id - , NOW() -- created_at - , NOW() -- updated_at - , '999999' -- proposal_id - , 1 -- final_status - , 1 -- call_id - , questionary_id_var -- questionary_id - , NULL -- comment_for_management - , NULL -- comment_for_user - , true -- notified - , true -- submitted + 'Test proposal' + , 'Lorem ipsum' + , 8 + , 1 + , NOW() + , NOW() + , '999999' + , 1 + , 1 + , questionary_id_var1 + , NULL + , NULL + , true + , true ); INSERT INTO instrument_has_proposals(instrument_id, proposal_pk) VALUES (1, 1); @@ -82,6 +91,43 @@ BEGIN INSERT INTO technical_review(technical_review_id, proposal_pk, comment, time_allocation, status, public_comment, reviewer_id, technical_review_assignee_id, instrument_id, questionary_id) VALUES (1, 1, '', 2, 0, '', 0, 0, 1, technical_review_questionary_id_var); + INSERT INTO proposals + ( + title + , abstract + , status_id + , proposer_id + , created_at + , updated_at + , proposal_id + , final_status + , call_id + , questionary_id + , comment_for_management + , comment_for_user + , notified + , submitted + ) + VALUES + ( + 'Test proposal 2' + , 'Lorem ipsum 2' + , 8 + , 1 + , NOW() + , NOW() + , '222222' + , 1 + , 1 + , questionary_id_var2 + , NULL + , NULL + , true + , true + ); + + INSERT INTO instrument_has_proposals(instrument_id, proposal_pk) VALUES (2, 2); + END; $DO$ LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/db_seeds/0003_Esi.sql b/apps/backend/db_patches/db_seeds/0003_Esi.sql index 0561cd127d..bc23e1301e 100644 --- a/apps/backend/db_patches/db_seeds/0003_Esi.sql +++ b/apps/backend/db_patches/db_seeds/0003_Esi.sql @@ -5,6 +5,8 @@ DECLARE proposal_esi_template_topic_id INTEGER; sample_esi_template_id INTEGER; sample_esi_template_topic_id INTEGER; + proposal_category_id INTEGER; + sample_category_id INTEGER; BEGIN INSERT INTO templates(name, description, is_archived, group_id) VALUES @@ -35,6 +37,13 @@ BEGIN UPDATE public.call SET esi_template_id=proposal_esi_template_id; + + -- Get Proposal category id + SELECT template_category_id INTO proposal_category_id FROM template_categories WHERE name='Proposal' limit 1; + + -- Get Sample category id + SELECT template_category_id INTO sample_category_id FROM template_categories WHERE name='Sample declaration' limit 1; + -- Insert Questions for Proposal ESI template -- BOOLEAN Question for Proposal ESI Template INSERT INTO questions( @@ -53,7 +62,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'proposal_esi_boolean_question', 1 + 'proposal_esi_boolean_question', proposal_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -61,7 +70,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'proposal_esi_boolean_question', 5, 5, 3, '{ + 'proposal_esi_boolean_question', proposal_esi_template_id, proposal_esi_template_topic_id, 3, '{ "tooltip": "", "required": false, "small_label": "", @@ -86,7 +95,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'proposal_esi_date_question', 1 + 'proposal_esi_date_question', proposal_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -94,7 +103,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'proposal_esi_date_question', 5, 5, 4, '{ "tooltip": "","required": false,"small_label": "","readPermissions": [] }' + 'proposal_esi_date_question', proposal_esi_template_id, proposal_esi_template_topic_id, 4, '{ "tooltip": "","required": false,"small_label": "","readPermissions": [] }' ); -- INTERVAL Question for Proposal ESI Template @@ -124,7 +133,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'proposal_esi_interval_question', 1 + 'proposal_esi_interval_question', proposal_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -132,7 +141,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'proposal_esi_interval_question', 5, 5, 6, '{ + 'proposal_esi_interval_question', proposal_esi_template_id, proposal_esi_template_topic_id, 6, '{ "units": [ { "id": "meter", @@ -182,7 +191,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'proposal_esi_number_question', 1 + 'proposal_esi_number_question', proposal_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -190,7 +199,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'proposal_esi_number_question', 5, 5, 7, '{ + 'proposal_esi_number_question', proposal_esi_template_id, proposal_esi_template_topic_id, 7, '{ "units": [ { "id": "meter", @@ -227,7 +236,7 @@ VALUES '{ "tooltip": "","required": false,"small_label": "", "readPermissions": [] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'proposal_esi_rich_text_input_question', 1 + 'proposal_esi_rich_text_input_question', proposal_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -235,7 +244,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'proposal_esi_rich_text_input_question', 5, 5, + 'proposal_esi_rich_text_input_question', proposal_esi_template_id, proposal_esi_template_topic_id, 8, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions": [] }' ); -- Selection from options Question for Proposal ESI Template @@ -252,7 +261,7 @@ VALUES '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', 'proposal_esi_selection_from_options_question', - 1 + proposal_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -261,7 +270,7 @@ INSERT INTO templates_has_questions( VALUES ( 'proposal_esi_selection_from_options_question', - 5, 5, 9, '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true,"readPermissions":[]}' + proposal_esi_template_id, proposal_esi_template_topic_id, 9, '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true,"readPermissions":[]}' ); -- Text input Question for Proposal ESI Template @@ -277,7 +286,7 @@ VALUES '{ "tooltip": "","required": false,"small_label": "","readPermissions": [] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'proposal_esi_text_input_question', 1 + 'proposal_esi_text_input_question', proposal_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -285,7 +294,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'proposal_esi_text_input_question', 5, 5, 11, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions": [] }' + 'proposal_esi_text_input_question', proposal_esi_template_id, proposal_esi_template_topic_id, 11, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions": [] }' ); -- -- Insert Questions for Sample ESI template @@ -306,7 +315,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_esi_boolean_question', 2 + 'sample_esi_boolean_question', sample_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -314,7 +323,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_esi_boolean_question', 6, 6, 3, '{ + 'sample_esi_boolean_question', sample_esi_template_id, sample_esi_template_topic_id, 3, '{ "tooltip": "", "required": false, "small_label": "", @@ -339,7 +348,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_esi_date_question', 2 + 'sample_esi_date_question', sample_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -347,7 +356,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_esi_date_question', 6, 6, 4, '{ "tooltip": "","required": false,"small_label": "", "readPermissions": [] }' + 'sample_esi_date_question', sample_esi_template_id, sample_esi_template_topic_id, 4, '{ "tooltip": "","required": false,"small_label": "", "readPermissions": [] }' ); -- INTERVAL Question for Sample ESI Template @@ -377,7 +386,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_esi_interval_question', 2 + 'sample_esi_interval_question', sample_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -385,7 +394,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_esi_interval_question', 6, 6, 6, '{ + 'sample_esi_interval_question', sample_esi_template_id, sample_esi_template_topic_id, 6, '{ "units": [ { "id": "meter", @@ -435,7 +444,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_esi_number_question', 2 + 'sample_esi_number_question', sample_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -443,7 +452,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_esi_number_question', 6, 6, 7, '{ + 'sample_esi_number_question', sample_esi_template_id, sample_esi_template_topic_id, 7, '{ "units": [ { "id": "meter", @@ -480,7 +489,7 @@ VALUES '{ "tooltip": "","required": false,"small_label": "","readPermissions": [] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_esi_rich_text_input_question', 2 + 'sample_esi_rich_text_input_question', sample_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -488,7 +497,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_esi_rich_text_input_question', 6, 6, + 'sample_esi_rich_text_input_question', sample_esi_template_id, sample_esi_template_topic_id, 8, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions": [] }' ); -- Selection from options Question for Sample ESI Template @@ -514,7 +523,7 @@ INSERT INTO templates_has_questions( VALUES ( 'sample_esi_selection_from_options_question', - 6, 6, 9, '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true,"readPermissions":[]}' + sample_esi_template_id, sample_esi_template_topic_id, 9, '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true,"readPermissions":[]}' ); @@ -532,7 +541,7 @@ VALUES '{ "tooltip": "","required": false,"small_label": "", "readPermissions": [] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_esi_text_input_question', 2 + 'sample_esi_text_input_question', sample_category_id ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -540,7 +549,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_esi_text_input_question', 6, 6, 11, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions": [] }' + 'sample_esi_text_input_question', sample_esi_template_id, sample_esi_template_topic_id, 11, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions": [] }' ); diff --git a/apps/backend/db_patches/db_seeds/0004_Experiments.sql b/apps/backend/db_patches/db_seeds/0004_Experiments.sql index 80006080c3..31df13299a 100644 --- a/apps/backend/db_patches/db_seeds/0004_Experiments.sql +++ b/apps/backend/db_patches/db_seeds/0004_Experiments.sql @@ -1,22 +1,47 @@ DO $DO$ +DECLARE + exp_safety_review_template_category_id_var int; + exp_safety_review_template_id_var int; + exp_safety_review_template_topic_id_var int; + instrument_id_1_var int; + instrument_id_2_var int; BEGIN + -- Get instrument ids + SELECT instrument_id INTO instrument_id_1_var FROM instruments WHERE name='Instrument 1' limit 1; + SELECT instrument_id INTO instrument_id_2_var FROM instruments WHERE name='Instrument 2' limit 1; INSERT INTO experiments( experiment_pk, experiment_id, scheduled_event_id, starts_at, ends_at, proposal_pk, status, local_contact_id, instrument_id) - VALUES (996,'000001', 996, '2030-01-07 10:00:00', '2030-01-07 11:00:00', 1, 'ACTIVE', 1, 1); - + VALUES (996,'000001', 996, '2030-01-07 10:00:00', '2030-01-07 11:00:00', 1, 'ACTIVE', 1, instrument_id_1_var); + INSERT INTO experiments( experiment_pk, experiment_id, scheduled_event_id, starts_at, ends_at, proposal_pk, status, instrument_id) - VALUES (997, '000002', 997, '2020-01-07 10:00:00', '2020-01-07 11:00:00', 1, 'ACTIVE', 1); + VALUES (997, '000002', 997, '2020-01-07 10:00:00', '2020-01-07 11:00:00', 2, 'ACTIVE', instrument_id_2_var); INSERT INTO experiments( experiment_pk, experiment_id, scheduled_event_id, starts_at, ends_at, proposal_pk, status, local_contact_id, instrument_id) - VALUES (998, '000003', 998, '2030-01-07 12:00:00', '2030-01-07 13:00:00', 1, 'DRAFT', 1, 2); + VALUES (998, '000003', 998, '2030-01-07 12:00:00', '2030-01-07 13:00:00', 1, 'DRAFT', 1, instrument_id_1_var); INSERT INTO experiments( experiment_pk, experiment_id, scheduled_event_id, starts_at, ends_at, proposal_pk, status, local_contact_id, instrument_id) - VALUES (999, '000004', 999, '2030-02-07 12:00:00', '2030-02-07 13:00:00', 1, 'COMPLETED', 1, 1); + VALUES (999, '000004', 999, '2030-02-07 12:00:00', '2030-02-07 13:00:00', 1, 'COMPLETED', 1, instrument_id_2_var); + + -- Get Experiment Safety Review (ESR) Template category id, template id and topic id + SELECT template_categories.template_category_id + INTO exp_safety_review_template_category_id_var + FROM template_categories + WHERE name = 'Experiment Safety Review' limit 1; + + select templates.template_id + INTO exp_safety_review_template_id_var + from templates + where group_id = 'EXPERIMENT_SAFETY_REVIEW_TEMPLATE' limit 1; + + select topics.topic_id + INTO exp_safety_review_template_topic_id_var + from topics + where template_id = exp_safety_review_template_id_var limit 1; -- Insert Questions for Experiment Safety Review (ESR) Template -- BOOLEAN Question for Experiment Safety Review (ESR) Template @@ -36,7 +61,7 @@ $DO$ }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'experiment_safety_review_boolean_question', 12 + 'experiment_safety_review_boolean_question', exp_safety_review_template_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -44,7 +69,7 @@ $DO$ ) VALUES ( - 'experiment_safety_review_boolean_question', 4, 4, 3, '{ + 'experiment_safety_review_boolean_question', exp_safety_review_template_id_var, exp_safety_review_template_topic_id_var, 3, '{ "tooltip": "", "required": false, "small_label": "", @@ -69,7 +94,7 @@ $DO$ }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'experiment_safety_review_date_question', 12 + 'experiment_safety_review_date_question', exp_safety_review_template_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -77,7 +102,7 @@ $DO$ ) VALUES ( - 'experiment_safety_review_date_question', 4, 4, 4, '{ "tooltip": "","required": false,"small_label": "" , "readPermissions":[]}' + 'experiment_safety_review_date_question', exp_safety_review_template_id_var, exp_safety_review_template_topic_id_var, 4, '{ "tooltip": "","required": false,"small_label": "" , "readPermissions":[]}' ); -- INTERVAL Question for Experiment Safety Review (ESR) Template @@ -107,7 +132,7 @@ $DO$ }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'experiment_safety_review_interval_question', 12 + 'experiment_safety_review_interval_question', exp_safety_review_template_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -115,7 +140,7 @@ $DO$ ) VALUES ( - 'experiment_safety_review_interval_question', 4, 4, 6, '{ + 'experiment_safety_review_interval_question', exp_safety_review_template_id_var, exp_safety_review_template_topic_id_var, 6, '{ "units": [ { "id": "meter", @@ -165,7 +190,7 @@ $DO$ }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'experiment_safety_review_number_question', 12 + 'experiment_safety_review_number_question', exp_safety_review_template_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -173,7 +198,7 @@ $DO$ ) VALUES ( - 'experiment_safety_review_number_question', 4, 4, 7, '{ + 'experiment_safety_review_number_question', exp_safety_review_template_id_var, exp_safety_review_template_topic_id_var, 7, '{ "units": [ { "id": "meter", @@ -210,7 +235,7 @@ $DO$ '{ "tooltip": "","required": false,"small_label": "", "readPermissions": [] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'experiment_safety_review_rich_text_input_question', 12 + 'experiment_safety_review_rich_text_input_question', exp_safety_review_template_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -218,7 +243,7 @@ $DO$ ) VALUES ( - 'experiment_safety_review_rich_text_input_question', 4, 4, + 'experiment_safety_review_rich_text_input_question', exp_safety_review_template_id_var, exp_safety_review_template_topic_id_var, 8, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions": [] }' ); -- Selection from options Question for Experiment Safety Review (ESR) Template @@ -234,7 +259,7 @@ $DO$ '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true,"readPermissions":[]}', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'experiment_safety_review_selection_from_options_question', 12 + 'experiment_safety_review_selection_from_options_question', exp_safety_review_template_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -243,7 +268,7 @@ $DO$ VALUES ( 'experiment_safety_review_selection_from_options_question', - 4, 4, 9, '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true,"readPermissions":[]}' + exp_safety_review_template_id_var, exp_safety_review_template_topic_id_var, 9, '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true,"readPermissions":[]}' ); -- Text input Question for Experiment Safety Review (ESR) Template @@ -259,7 +284,7 @@ $DO$ '{ "tooltip": "","required": false,"small_label": "","readPermissions":[] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'experiment_safety_review_text_input_question', 12 + 'experiment_safety_review_text_input_question', exp_safety_review_template_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -267,7 +292,7 @@ $DO$ ) VALUES ( - 'experiment_safety_review_text_input_question', 4, 4, 11, '{ "tooltip": "", "required": false, "small_label": "","readPermissions":[] }' + 'experiment_safety_review_text_input_question', exp_safety_review_template_id_var, exp_safety_review_template_topic_id_var, 11, '{ "tooltip": "", "required": false, "small_label": "","readPermissions":[] }' ); END; $DO$ diff --git a/apps/backend/db_patches/db_seeds/0004_Samples.sql b/apps/backend/db_patches/db_seeds/0004_Samples.sql index 982177adbd..54e7232e38 100644 --- a/apps/backend/db_patches/db_seeds/0004_Samples.sql +++ b/apps/backend/db_patches/db_seeds/0004_Samples.sql @@ -1,19 +1,46 @@ DO $DO$ +DECLARE + sample_declr_questionary_id INTEGER; + sample_declr_template_id_var INTEGER; + sample_declr_topic_id_var INTEGER; + sample_decl_question_topic_id_var INTEGER; + sample_decl_category_id_var INTEGER; + proposal_pk_var INTEGER; + proposal_template_id_var INTEGER; + proposal_category_id_var INTEGER; + sample_esi_template_id INTEGER; BEGIN + -- GET First Proposal Pk +SELECT proposal_pk INTO proposal_pk_var FROM proposals ORDER BY proposal_pk LIMIT 1; + INSERT INTO public.templates( name, description, is_archived, group_id) - VALUES ('default sample declaration template', 'default sample declaration template', false, 'SAMPLE'); + VALUES ('default sample declaration template', 'default sample declaration template', false, 'SAMPLE') + RETURNING template_id INTO sample_declr_template_id_var; INSERT INTO public.topics( topic_title, is_enabled, sort_order, template_id) - VALUES ('New sample', true, 0, 7); + VALUES ('New sample', true, 0, sample_declr_template_id_var) + RETURNING topic_id INTO sample_declr_topic_id_var; INSERT INTO public.questionaries( template_id, created_at, creator_id) - VALUES ( 7, '2021-07-20 13:59:08.597908+00', 2); + VALUES ( sample_declr_template_id_var, '2021-07-20 13:59:08.597908+00', 2) RETURNING questionary_id INTO sample_declr_questionary_id; + +-- Get Sample Declaration Template category id +SELECT template_category_id INTO sample_decl_category_id_var FROM template_categories WHERE name = 'Sample declaration' LIMIT 1; + +-- Get Proposal Template category id +SELECT template_category_id INTO proposal_category_id_var FROM template_categories WHERE name = 'Proposal' LIMIT 1; + +-- Get Proposal Template id +SELECT template_id INTO proposal_template_id_var FROM templates WHERE name = 'default template' LIMIT 1; + +-- Get Sample ESI Template id +SELECT template_id INTO sample_esi_template_id FROM templates WHERE name = 'default sample esi template' LIMIT 1; -- BOOLEAN Question for Sample Declaration Template INSERT INTO questions( @@ -32,7 +59,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_boolean_question', 2 + 'sample_boolean_question', sample_decl_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -40,7 +67,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_boolean_question', 7, 7, 3, '{ + 'sample_boolean_question', sample_declr_template_id_var, sample_declr_topic_id_var, 3, '{ "tooltip": "", "required": false, "small_label": "", @@ -52,7 +79,7 @@ INSERT INTO answers( ) VALUES ( - 4, 'sample_boolean_question', '{"value": true }' + sample_declr_questionary_id, 'sample_boolean_question', '{"value": true }' ); -- Date Question for Sample Declaration Template @@ -72,7 +99,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_date_question', 2 + 'sample_date_question', sample_decl_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -80,14 +107,14 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_date_question', 7, 7, 4, '{ "tooltip": "","required": false,"small_label": "", "readPermissions":[] }' + 'sample_date_question', sample_declr_template_id_var, sample_declr_topic_id_var, 4, '{ "tooltip": "","required": false,"small_label": "", "readPermissions":[] }' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 4, 'sample_date_question', '{"value": "2030-01-01" }' + sample_declr_questionary_id, 'sample_date_question', '{"value": "2030-01-01" }' ); -- File upload Question for Sample Declaration Template @@ -103,7 +130,7 @@ VALUES '{"tooltip": "", "required": false,"file_type": [".pdf",".doc",".docx"],"max_files": 0,"small_label": "", "readPermissions":[]}', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_file_upload_question', 2 + 'sample_file_upload_question', sample_decl_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -111,14 +138,14 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_file_upload_question', 7, 7, 5, '{"tooltip": "", "required": false,"file_type": [".pdf",".doc",".docx"],"max_files": 0,"small_label": "", "readPermissions":[]}' + 'sample_file_upload_question', sample_declr_template_id_var, sample_declr_topic_id_var, 5, '{"tooltip": "", "required": false,"file_type": [".pdf",".doc",".docx"],"max_files": 0,"small_label": "", "readPermissions":[]}' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 4, 'sample_file_upload_question', '{ + sample_declr_questionary_id, 'sample_file_upload_question', '{ "value": [{"id": "1c4b2ca8-f849-42db-b5d6-35aba2b26f8b"}]}' ); -- INTERVAL Question for Sample Declaration Template @@ -148,7 +175,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_interval_question', 2 + 'sample_interval_question', sample_decl_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -156,7 +183,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_interval_question', 7, 7, 6, '{ + 'sample_interval_question', sample_declr_template_id_var, sample_declr_topic_id_var, 6, '{ "units": [ { "id": "meter", @@ -177,7 +204,7 @@ INSERT INTO answers( ) VALUES ( - 4, 'sample_interval_question', '{ + sample_declr_questionary_id, 'sample_interval_question', '{ "value": { "max": 100, "min": 1, @@ -219,7 +246,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_number_question', 2 + 'sample_number_question', sample_decl_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -227,7 +254,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_number_question', 7, 7, 7, '{ + 'sample_number_question', sample_declr_template_id_var, sample_declr_topic_id_var, 7, '{ "units": [ { "id": "meter", @@ -256,7 +283,7 @@ INSERT INTO answers( ) VALUES ( - 4, 'sample_number_question', '{ + sample_declr_questionary_id, 'sample_number_question', '{ "value": { "unit": { "id": "centimeter", @@ -283,7 +310,7 @@ VALUES '{ "tooltip": "","required": false,"small_label": "", "readPermissions":[] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_rich_text_input_question', 2 + 'sample_rich_text_input_question', sample_decl_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -291,7 +318,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_rich_text_input_question', 7, 7, + 'sample_rich_text_input_question', sample_declr_template_id_var, sample_declr_topic_id_var, 8, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions": [] }' ); INSERT INTO answers( @@ -299,7 +326,7 @@ INSERT INTO answers( ) VALUES ( - 4, 'sample_rich_text_input_question', '{"value": "Rich text input value" }' + sample_declr_questionary_id, 'sample_rich_text_input_question', '{"value": "Rich text input value" }' ); -- Selection from options Question for Sample Declaration Template INSERT INTO questions( @@ -315,7 +342,7 @@ VALUES '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', 'sample_selection_from_options_question', - 2 + sample_decl_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -324,14 +351,14 @@ INSERT INTO templates_has_questions( VALUES ( 'sample_selection_from_options_question', - 7, 7, 9, '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true, "readPermissions":[]}' + sample_declr_template_id_var, sample_declr_topic_id_var, 9, '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true, "readPermissions":[]}' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 4, 'sample_selection_from_options_question', + sample_declr_questionary_id, 'sample_selection_from_options_question', '{"value": ["One"] }' ); @@ -350,7 +377,7 @@ VALUES '2023-02-08 10:23:10.285415+00', '2023-02-08 10:23:10.285415+00', 'sample_dynamic_multiple_choice_question', - 2 + sample_decl_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -359,7 +386,7 @@ INSERT INTO templates_has_questions( VALUES ( 'sample_dynamic_multiple_choice_question', - 7, 7, 10, '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[], + sample_declr_template_id_var, sample_declr_topic_id_var, 10, '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[], "readPermissions":[]}' ); INSERT INTO answers( @@ -367,7 +394,7 @@ INSERT INTO answers( ) VALUES ( - 4, 'sample_dynamic_multiple_choice_question', + sample_declr_questionary_id, 'sample_dynamic_multiple_choice_question', '{"value": ["One"] }' ); @@ -384,7 +411,7 @@ VALUES '{ "tooltip": "","required": false,"small_label": "", "readPermissions":[] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'sample_text_input_question', 2 + 'sample_text_input_question', sample_decl_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -392,54 +419,60 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'sample_text_input_question', 7, 7, 11, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions": [] }' + 'sample_text_input_question', sample_declr_template_id_var, sample_declr_topic_id_var, 11, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions": [] }' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 4, 'sample_text_input_question', '{"value": "Text input answer from seeds" }' + sample_declr_questionary_id, 'sample_text_input_question', '{"value": "Text input answer from seeds" }' ); - + +-- Basis Question for Sample Declaration Template INSERT INTO public.templates_has_questions( question_id, template_id, topic_id, sort_order, config, dependencies_operator) - VALUES ('sample_basis', 7, 7, 0, '{"titlePlaceholder":"Title","required":false,"small_label":"","tooltip":"", "readPermissions":[]}', 'AND'); + VALUES ('sample_basis', sample_declr_template_id_var, sample_declr_topic_id_var, 0, '{"titlePlaceholder":"Title","required":false,"small_label":"","tooltip":"", "readPermissions":[]}', 'AND'); + + -- need to softcode INSERT INTO public.questions( question_id, data_type, question, default_config, created_at, updated_at, natural_key, category_id) - VALUES ('sample_declaration_question', 'SAMPLE_DECLARATION', 'Add samples', '{"addEntryButtonLabel":"Add","minEntries":null,"maxEntries":null,"templateId":7,"esiTemplateId":6,"templateCategory":"SAMPLE_DECLARATION","required":false,"small_label":"","readPermissions":[]}', - '2021-07-20 13:53:29.246687+00', '2021-07-20 13:53:29.246687+00', 'sample_declaration_question', 1); + VALUES ('sample_declaration_question', 'SAMPLE_DECLARATION', 'Add samples', + jsonb_set( + jsonb_set('{"addEntryButtonLabel":"Add","minEntries":null,"maxEntries":null,"templateId":0,"esiTemplateId":0,"templateCategory":"SAMPLE_DECLARATION","required":false,"small_label":"","readPermissions":[]}', '{templateId}', sample_declr_template_id_var::text::jsonb), + '{esiTemplateId}', sample_esi_template_id::text::jsonb), + '2021-07-20 13:53:29.246687+00', '2021-07-20 13:53:29.246687+00', 'sample_declaration_question', proposal_category_id_var); + -- need to softcode the template_id 1 INSERT INTO public.topics( topic_title, is_enabled, sort_order, template_id) - VALUES ('Topic title', true, 1, 1); + VALUES ('Topic title', true, 1, proposal_template_id_var) RETURNING topic_id INTO sample_decl_question_topic_id_var; + -- need to softcode INSERT INTO public.templates_has_questions( question_id, template_id, topic_id, sort_order, config, dependencies_operator) - VALUES ('sample_declaration_question', 1, 8, 0, '{"addEntryButtonLabel":"Add","templateCategory":"SAMPLE_DECLARATION","templateId":7,"esiTemplateId":6,"small_label":"","required":false,"minEntries":null,"maxEntries":null, "readPermissions":[]}', 'AND'); + VALUES ('sample_declaration_question', proposal_template_id_var, sample_decl_question_topic_id_var, 0, + jsonb_set( + jsonb_set('{"addEntryButtonLabel":"Add","templateCategory":"SAMPLE_DECLARATION","templateId":0,"esiTemplateId":0,"small_label":"","required":false,"minEntries":null,"maxEntries":null, "readPermissions":[]}', '{templateId}', sample_declr_template_id_var::text::jsonb), + '{esiTemplateId}', sample_esi_template_id::text::jsonb), 'AND'); -INSERT INTO public.questionaries( - template_id, created_at, creator_id) - VALUES ( 7, '2021-07-20 13:59:08.597908+00', 2); INSERT INTO public.samples( title, creator_id, questionary_id, safety_status, created_at, safety_comment, proposal_pk, question_id, shipment_id) - VALUES ('My sample title', 2, 4, 0, '2021-07-20 13:59:08.602853+00', '', 1, 'sample_declaration_question', null); + VALUES ('My sample title', 2, sample_declr_questionary_id, 0, '2021-07-20 13:59:08.602853+00', '', proposal_pk_var, 'sample_declaration_question', null); INSERT INTO public.topic_completenesses( questionary_id, topic_id, is_complete) - VALUES (4, 7, true); - + VALUES (sample_declr_questionary_id, sample_declr_topic_id_var, true); + INSERT INTO public.topic_completenesses( questionary_id, topic_id, is_complete) - VALUES (3, 1, true); + VALUES (2, sample_decl_question_topic_id_var, true); INSERT INTO public.topic_completenesses( questionary_id, topic_id, is_complete) - VALUES (3, 7, true); - - + VALUES (3, sample_decl_question_topic_id_var, true); END; $DO$ diff --git a/apps/backend/db_patches/db_seeds/0005_Questions.sql b/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql similarity index 73% rename from apps/backend/db_patches/db_seeds/0005_Questions.sql rename to apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql index 9bafa1a07d..ac01b60896 100644 --- a/apps/backend/db_patches/db_seeds/0005_Questions.sql +++ b/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql @@ -1,10 +1,22 @@ DO $DO$ +DECLARE + proposal_template_id_var INT; + proposal_topic_id_var INT; + proposal_category_id_var INT; + proposal_questionary_id_var INT; BEGIN +-- Get Proposal Template category id +SELECT template_category_id INTO proposal_category_id_var FROM template_categories WHERE name = 'Proposal' LIMIT 1; +-- Get Proposal Template id +SELECT template_id INTO proposal_template_id_var FROM templates WHERE name = 'default template' LIMIT 1; +-- Get Proposal Template topic id +SELECT topic_id INTO proposal_topic_id_var FROM topics WHERE template_id = proposal_template_id_var and topic_title = 'Topic title' LIMIT 1; - +-- Create a questionary for proposal +SELECT questionary_id INTO proposal_questionary_id_var FROM questionaries WHERE template_id = proposal_template_id_var LIMIT 1; -- BOOLEAN INSERT INTO questions( question_id, data_type, question, @@ -22,7 +34,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'boolean_question', 1 + 'boolean_question', proposal_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -30,7 +42,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'boolean_question', 1, 8, 3, '{ + 'boolean_question', proposal_template_id_var, proposal_topic_id_var, 3, '{ "tooltip": "", "required": false, "small_label": "", @@ -42,7 +54,7 @@ INSERT INTO answers( ) VALUES ( - 2, 'boolean_question', '{"value": true }' + proposal_questionary_id_var, 'boolean_question', '{"value": true }' ); -- Date INSERT INTO questions( @@ -61,7 +73,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'date_question', 1 + 'date_question', proposal_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -69,14 +81,14 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'date_question', 1, 8, 5, '{ "tooltip": "","required": false,"small_label": "", "readPermissions":[] }' + 'date_question', proposal_template_id_var, proposal_topic_id_var, 5, '{ "tooltip": "","required": false,"small_label": "", "readPermissions":[] }' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 2, 'date_question', '{"value": "2030-01-01" }' + proposal_questionary_id_var, 'date_question', '{"value": "2030-01-01" }' ); -- Embellishment INSERT INTO questions( @@ -91,7 +103,7 @@ VALUES '{ "tooltip": "","required": false,"small_label": "", "readPermissions":[] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'embellishment_question', 1 + 'embellishment_question', proposal_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -99,7 +111,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'embellishment_question', 1, 8, 9, + 'embellishment_question', proposal_template_id_var, proposal_topic_id_var, 9, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions":[] }' ); INSERT INTO answers( @@ -107,7 +119,7 @@ INSERT INTO answers( ) VALUES ( - 2, 'embellishment_question', '{"value": "

Embellishment value

" }' + proposal_questionary_id_var, 'embellishment_question', '{"value": "

Embellishment value

" }' ); -- File upload INSERT INTO questions( @@ -122,7 +134,7 @@ VALUES '{"tooltip": "", "required": false,"file_type": [".pdf",".doc",".docx"],"max_files": 0,"small_label": "", "readPermissions":[]}', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'file_upload_question', 1 + 'file_upload_question', proposal_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -130,14 +142,14 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'file_upload_question', 1, 8, 8, '{"tooltip": "", "required": false,"file_type": [".pdf",".doc",".docx"],"max_files": 0,"small_label": "", "readPermissions":[]}' + 'file_upload_question', proposal_template_id_var, proposal_topic_id_var, 8, '{"tooltip": "", "required": false,"file_type": [".pdf",".doc",".docx"],"max_files": 0,"small_label": "", "readPermissions":[]}' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 2, 'file_upload_question', '{ + proposal_questionary_id_var, 'file_upload_question', '{ "value": [{"id": "1c4b2ca8-f849-42db-b5d6-35aba2b26f8b"}]}' ); -- INTERVAL @@ -167,7 +179,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'interval_question', 1 + 'interval_question', proposal_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -175,7 +187,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'interval_question', 1, 8, 4, '{ + 'interval_question', proposal_template_id_var, proposal_topic_id_var, 4, '{ "units": [ { "id": "meter", @@ -196,7 +208,7 @@ INSERT INTO answers( ) VALUES ( - 2, 'interval_question', '{ + proposal_questionary_id_var, 'interval_question', '{ "value": { "max": 100, "min": 1, @@ -238,7 +250,7 @@ VALUES }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'number_question', 1 + 'number_question', proposal_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -246,7 +258,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'number_question', 1, 8, 2, '{ + 'number_question', proposal_template_id_var, proposal_topic_id_var, proposal_questionary_id_var, '{ "units": [ { "id": "meter", @@ -275,7 +287,7 @@ INSERT INTO answers( ) VALUES ( - 2, 'number_question', '{ + proposal_questionary_id_var, 'number_question', '{ "value": { "unit": { "id": "centimeter", @@ -302,7 +314,7 @@ VALUES '{ "tooltip": "","required": false,"small_label": "", "readPermissions":[] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'rich_text_input_question', 1 + 'rich_text_input_question', proposal_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -310,7 +322,7 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'rich_text_input_question', 1, 8, + 'rich_text_input_question', proposal_template_id_var, proposal_topic_id_var, 8, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions":[] }' ); INSERT INTO answers( @@ -318,7 +330,7 @@ INSERT INTO answers( ) VALUES ( - 2, 'rich_text_input_question', '{"value": "Rich text input value" }' + proposal_questionary_id_var, 'rich_text_input_question', '{"value": "Rich text input value" }' ); -- Selection from options INSERT INTO questions( @@ -334,7 +346,7 @@ VALUES '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', 'selection_from_options_question', - 1 + proposal_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -343,14 +355,14 @@ INSERT INTO templates_has_questions( VALUES ( 'selection_from_options_question', - 1, 8, 6, '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true, "readPermissions":[]}' + proposal_template_id_var, proposal_topic_id_var, 6, '{"variant":"dropdown","options":["One","Two","Three"],"isMultipleSelect":true, "readPermissions":[]}' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 2, 'selection_from_options_question', + proposal_questionary_id_var, 'selection_from_options_question', '{"value": ["One"] }' ); @@ -368,7 +380,7 @@ VALUES '2023-02-08 10:23:10.285415+00', '2023-02-08 10:23:10.285415+00', 'dynamic_multiple_choice_question', - 1 + proposal_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -377,14 +389,14 @@ INSERT INTO templates_has_questions( VALUES ( 'dynamic_multiple_choice_question', - 1, 8, 6, '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[],"readPermissions":[]}' + proposal_template_id_var, proposal_topic_id_var, 10, '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[],"readPermissions":[]}' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 2, 'dynamic_multiple_choice_question', + proposal_questionary_id_var, 'dynamic_multiple_choice_question', '{"value": ["One"] }' ); @@ -401,7 +413,7 @@ VALUES '{ "tooltip": "","required": false,"small_label": "", "readPermissions":[] }', '2022-02-08 10:23:10.285415+00', '2022-02-08 10:23:10.285415+00', - 'text_input_question', 1 + 'text_input_question', proposal_category_id_var ); INSERT INTO templates_has_questions( question_id, template_id, topic_id, @@ -409,14 +421,14 @@ INSERT INTO templates_has_questions( ) VALUES ( - 'text_input_question', 1, 8, 7, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions":[] }' + 'text_input_question', proposal_template_id_var, proposal_topic_id_var, 7, '{ "tooltip": "", "required": false, "small_label": "", "readPermissions":[] }' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 2, 'text_input_question', '{"value": "Text input answer from seeds" }' + proposal_questionary_id_var, 'text_input_question', '{"value": "Text input answer from seeds" }' ); @@ -434,7 +446,7 @@ VALUES '2023-02-08 10:23:10.285415+00', '2023-02-08 10:23:10.285415+00', 'instrument_picker_question', - 1 + proposal_category_id_var ); INSERT INTO templates_has_questions( @@ -444,14 +456,14 @@ INSERT INTO templates_has_questions( VALUES ( 'instrument_picker_question', - 1, 8, 6, '{"variant":"dropdown","options":[1, 2], "readPermissions":[]}' + proposal_template_id_var, proposal_topic_id_var, 11, '{"variant":"dropdown","options":[1, 2], "readPermissions":[]}' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 2, 'instrument_picker_question', + proposal_questionary_id_var, 'instrument_picker_question', '{"value": 1 }' ); @@ -469,7 +481,7 @@ VALUES '2024-06-08 10:23:10.285415+00', '2024-06-08 10:23:10.285415+00', 'technique_picker_question', - 1 + proposal_category_id_var ); INSERT INTO templates_has_questions( @@ -479,14 +491,14 @@ INSERT INTO templates_has_questions( VALUES ( 'technique_picker_question', - 1, 8, 6, '{"variant":"dropdown","options":[1, 2], "readPermissions":[]}' + proposal_template_id_var, proposal_topic_id_var, 12, '{"variant":"dropdown","options":[1, 2], "readPermissions":[]}' ); INSERT INTO answers( questionary_id, question_id, answer ) VALUES ( - 2, 'technique_picker_question', + proposal_questionary_id_var, 'technique_picker_question', '{"value": 1 }' ); diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index a43a032275..286dabc3c1 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -25,7 +25,7 @@ "@user-office-software/duo-localisation": "^1.2.0", "@user-office-software/duo-logger": "^2.3.2", "@user-office-software/duo-message-broker": "^1.6.0", - "@user-office-software/duo-validation": "5.1.17", + "@user-office-software/duo-validation": "^5.1.20", "@user-office-software/openid": "^1.4.0", "await-to-js": "^2.1.1", "bcryptjs": "^2.4.3", @@ -3849,9 +3849,9 @@ } }, "node_modules/@user-office-software/duo-validation": { - "version": "5.1.17", - "resolved": "https://registry.npmjs.org/@user-office-software/duo-validation/-/duo-validation-5.1.17.tgz", - "integrity": "sha512-jHbkG5vJq92+BZbuCSmEtQABaPGy0rG3GilgVgPKikKcDktxEQ+x/Y3OLNymMI5dwLg+nVCWdyNxCU/u6yX0Vg==", + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@user-office-software/duo-validation/-/duo-validation-5.1.20.tgz", + "integrity": "sha512-Rm+vP2w413rj3NUV1jn8N7VYoVR0EhsWsGpoZVh1FLbGBvdOvfZ05+cY9jY56DDukBShndT9R95bPTBlfx7cFg==", "license": "ISC", "dependencies": { "luxon": "^2.5.0", diff --git a/apps/backend/package.json b/apps/backend/package.json index d1ba75e8d9..06eca5abc6 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -47,7 +47,7 @@ "@user-office-software/duo-localisation": "^1.2.0", "@user-office-software/duo-logger": "^2.3.2", "@user-office-software/duo-message-broker": "^1.6.0", - "@user-office-software/duo-validation": "5.1.17", + "@user-office-software/duo-validation": "^5.1.20", "@user-office-software/openid": "^1.4.0", "await-to-js": "^2.1.1", "bcryptjs": "^2.4.3", @@ -129,4 +129,4 @@ "npm": ">=10.9.2", "node": ">=22.0.0" } -} +} \ No newline at end of file diff --git a/apps/backend/src/auth/DataAccessUsersAuthorization.spec.ts b/apps/backend/src/auth/DataAccessUsersAuthorization.spec.ts new file mode 100644 index 0000000000..1a606a30cb --- /dev/null +++ b/apps/backend/src/auth/DataAccessUsersAuthorization.spec.ts @@ -0,0 +1,59 @@ +import { container } from 'tsyringe'; + +import { + dummyPrincipalInvestigatorWithRole, + dummyUserNotOnProposalWithRole, + dummyUserOfficerWithRole, + dummyUserWithRole, +} from '../datasources/mockups/UserDataSource'; +import { DataAccessUsersAuthorization } from './DataAccessUsersAuthorization'; + +const dataAccessUsersAuth = container.resolve(DataAccessUsersAuthorization); + +describe('DataAccessUsersAuthorization', () => { + describe('hasWriteRights', () => { + test('A user officer has write rights to manage data access users for any proposal', async () => { + return expect( + dataAccessUsersAuth.hasWriteRights(dummyUserOfficerWithRole, 1) + ).resolves.toEqual(true); + }); + + test('A principal investigator has write rights to manage data access users for their proposal', async () => { + return expect( + dataAccessUsersAuth.hasWriteRights( + dummyPrincipalInvestigatorWithRole, + 1 + ) + ).resolves.toEqual(true); + }); + + test('Co Proposer Has write rights to manage data access users for their proposal', async () => { + return expect( + dataAccessUsersAuth.hasWriteRights(dummyUserWithRole, 1) + ).resolves.toEqual(true); + }); + + test('A user without proposal access does not have write rights to manage data access users', async () => { + return expect( + dataAccessUsersAuth.hasWriteRights(dummyUserNotOnProposalWithRole, 1) + ).resolves.toEqual(false); + }); + + test('A null agent does not have write rights to manage data access users', async () => { + return expect( + dataAccessUsersAuth.hasWriteRights(null, 1) + ).resolves.toEqual(false); + }); + + test('A user does not have rights to manage a non-existent proposal', async () => { + const nonExistentProposalId = 999; + + return expect( + dataAccessUsersAuth.hasWriteRights( + dummyUserOfficerWithRole, + nonExistentProposalId + ) + ).resolves.toEqual(false); + }); + }); +}); diff --git a/apps/backend/src/auth/DataAccessUsersAuthorization.ts b/apps/backend/src/auth/DataAccessUsersAuthorization.ts new file mode 100644 index 0000000000..9e7d1aed6e --- /dev/null +++ b/apps/backend/src/auth/DataAccessUsersAuthorization.ts @@ -0,0 +1,52 @@ +import { inject, injectable } from 'tsyringe'; + +import { Tokens } from '../config/Tokens'; +import { ProposalDataSource } from '../datasources/ProposalDataSource'; +import { UserWithRole } from '../models/User'; +import { Proposal } from '../resolvers/types/Proposal'; +import { ProposalAuthorization } from './ProposalAuthorization'; +import { UserAuthorization } from './UserAuthorization'; + +@injectable() +export class DataAccessUsersAuthorization { + constructor( + @inject(Tokens.ProposalDataSource) + private proposalDataSource: ProposalDataSource, + @inject(Tokens.UserAuthorization) protected userAuth: UserAuthorization, + @inject(Tokens.ProposalAuthorization) + protected proposalAuth: ProposalAuthorization + ) {} + + private async resolveProposal( + proposalOrProposalId: Proposal | number + ): Promise { + let proposal; + + if (typeof proposalOrProposalId === 'number') { + proposal = await this.proposalDataSource.get(proposalOrProposalId); + } else { + proposal = proposalOrProposalId; + } + + return proposal; + } + + public async hasWriteRights( + agent: UserWithRole | null, + proposalOrProposalId: Proposal | number + ): Promise { + if (agent == null) { + return false; + } + + const proposal = await this.resolveProposal(proposalOrProposalId); + if (proposal == null) { + return false; + } + const hasAccess = + this.userAuth.isUserOfficer(agent) || + this.proposalAuth.isMemberOfProposal(agent, proposal); + + return hasAccess; + } +} diff --git a/apps/backend/src/auth/ProposalAuthorization.ts b/apps/backend/src/auth/ProposalAuthorization.ts index af1919fba5..e98aa30a1a 100644 --- a/apps/backend/src/auth/ProposalAuthorization.ts +++ b/apps/backend/src/auth/ProposalAuthorization.ts @@ -2,11 +2,11 @@ import { inject, injectable } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; +import { DataAccessUsersDataSource } from '../datasources/DataAccessUsersDataSource'; import { FapDataSource } from '../datasources/FapDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { ReviewDataSource } from '../datasources/ReviewDataSource'; import { StatusDataSource } from '../datasources/StatusDataSource'; -import { TechniqueDataSource } from '../datasources/TechniqueDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; import { Roles } from '../models/Role'; import { ProposalStatusDefaultShortCodes } from '../models/Status'; @@ -33,8 +33,8 @@ export class ProposalAuthorization { private callDataSource: CallDataSource, @inject(Tokens.StatusDataSource) private statusDataSource: StatusDataSource, - @inject(Tokens.TechniqueDataSource) - private techniqueDataSource: TechniqueDataSource, + @inject(Tokens.DataAccessUsersDataSource) + private dataAccessUsersDataSource: DataAccessUsersDataSource, @inject(Tokens.UserAuthorization) protected userAuth: UserAuthorization ) {} @@ -53,22 +53,29 @@ export class ProposalAuthorization { } isPrincipalInvestigatorOfProposal( - agent: UserJWT | null, + agentOrUserId: UserJWT | number | null, proposal: Proposal | null ) { - if (agent == null || proposal == null) { + if (agentOrUserId == null || proposal == null) { return false; } - if (agent.id === proposal.proposerId) { + let userId: number; + if (typeof agentOrUserId === 'number') { + userId = agentOrUserId; + } else { + userId = agentOrUserId.id; + } + + if (userId === proposal.proposerId) { return true; } } async isMemberOfProposal( - agent: UserJWT | null, + agentOrUserId: UserJWT | number | null, proposalOrPk: Proposal | number | null ) { - if (agent == null || proposalOrPk == null) { + if (agentOrUserId == null || proposalOrPk == null) { return false; } @@ -83,14 +90,21 @@ export class ProposalAuthorization { proposal = proposalOrPk; } - if (this.isPrincipalInvestigatorOfProposal(agent, proposal)) { + let userId: number; + if (typeof agentOrUserId === 'number') { + userId = agentOrUserId; + } else { + userId = agentOrUserId.id; + } + + if (this.isPrincipalInvestigatorOfProposal(userId, proposal)) { return true; } return this.userDataSource .getProposalUsers(proposal.primaryKey) .then((users) => { - return users.some((user) => user.id === agent.id); + return users.some((user) => user.id === userId); }); } @@ -178,6 +192,13 @@ export class ProposalAuthorization { return this.visitDataSource.isVisitorOfProposal(agent.id, proposalPk); } + async isDataAccessUserOfProposal(agent: UserWithRole, proposalPk: number) { + return this.dataAccessUsersDataSource.isDataAccessUserOfProposal( + agent.id, + proposalPk + ); + } + async isChairOrSecretaryOfProposal( agent: UserJWT | null, proposalPk: number @@ -243,7 +264,8 @@ export class ProposalAuthorization { case Roles.USER: hasAccess = (await this.isMemberOfProposal(agent, proposal)) || - (await this.isVisitorOfProposal(agent, proposal.primaryKey)); + (await this.isVisitorOfProposal(agent, proposal.primaryKey)) || + (await this.isDataAccessUserOfProposal(agent, proposal.primaryKey)); break; case Roles.INSTRUMENT_SCIENTIST: hasAccess = diff --git a/apps/backend/src/auth/QuestionaryAuthorization.ts b/apps/backend/src/auth/QuestionaryAuthorization.ts index 46222f91c4..ab803c1ffc 100644 --- a/apps/backend/src/auth/QuestionaryAuthorization.ts +++ b/apps/backend/src/auth/QuestionaryAuthorization.ts @@ -2,7 +2,6 @@ import { container, inject, injectable } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { QuestionaryDataSource } from '../datasources/QuestionaryDataSource'; -import { SampleDataSource } from '../datasources/SampleDataSource'; import { TemplateDataSource } from '../datasources/TemplateDataSource'; import { TemplateGroupId } from '../models/Template'; import { UserJWT } from '../models/User'; @@ -34,8 +33,7 @@ export class QuestionaryAuthorization { @inject(Tokens.QuestionaryDataSource) private questionaryDataSource: QuestionaryDataSource, @inject(Tokens.TemplateDataSource) - private templateDataSource: TemplateDataSource, - @inject(Tokens.SampleDataSource) private sampleDataSource: SampleDataSource + private templateDataSource: TemplateDataSource ) { this.authorizers.set( TemplateGroupId.PROPOSAL, diff --git a/apps/backend/src/buildContext.ts b/apps/backend/src/buildContext.ts index d518dc49c6..295cc67524 100644 --- a/apps/backend/src/buildContext.ts +++ b/apps/backend/src/buildContext.ts @@ -6,6 +6,7 @@ import UsersLoader from './loaders/UsersLoader'; import PDFServices from './middlewares/factory/factoryServices'; import AdminMutations from './mutations/AdminMutations'; import CallMutations from './mutations/CallMutations'; +import DataAccessUsersMutations from './mutations/DataAccessUsersMutations'; import ExperimentMutations from './mutations/ExperimentMutation'; import ExperimentSafetyPdfTemplateMutations from './mutations/ExperimentSafetyPdfTemplateMutations'; import FapMutations from './mutations/FapMutations'; @@ -34,6 +35,7 @@ import VisitMutations from './mutations/VisitMutations'; import WorkflowMutations from './mutations/WorkflowMutations'; import AdminQueries from './queries/AdminQueries'; import CallQueries from './queries/CallQueries'; +import DataAccessUsersQueries from './queries/DataAccessUsersQueries'; import EventLogQueries from './queries/EventLogQueries'; import ExperimentQueries from './queries/ExperimentQueries'; import ExperimentSafetyPdfTemplateQueries from './queries/ExperimentSafetyPdfTemplateQueries'; @@ -69,6 +71,7 @@ const context: BasicResolverContext = { queries: { admin: container.resolve(AdminQueries), call: container.resolve(CallQueries), + dataAccessUsers: container.resolve(DataAccessUsersQueries), eventLogs: container.resolve(EventLogQueries), feedback: container.resolve(FeedbackQueries), file: container.resolve(FileQueries), @@ -105,6 +108,7 @@ const context: BasicResolverContext = { mutations: { admin: container.resolve(AdminMutations), call: container.resolve(CallMutations), + dataAccessUsers: container.resolve(DataAccessUsersMutations), feedback: container.resolve(FeedbackMutations), file: container.resolve(FileMutations), genericTemplate: container.resolve(GenericTemplateMutations), diff --git a/apps/backend/src/config/Tokens.ts b/apps/backend/src/config/Tokens.ts index d7e0433a6a..44cbf95bc4 100644 --- a/apps/backend/src/config/Tokens.ts +++ b/apps/backend/src/config/Tokens.ts @@ -6,6 +6,8 @@ export const Tokens = { ConfigureEnvironment: Symbol('ConfigureEnvironment'), ConfigureLogger: Symbol('ConfigureLogger'), CoProposerClaimDataSource: Symbol('CoProposerClaimDataSource'), + DataAccessUsersDataSource: Symbol('DataAccessUsersDataSource'), + DataAccessUsersAuthorization: Symbol('DataAccessUsersAuthorization'), EmailEventHandler: Symbol('EmailEventHandler'), EventBus: Symbol('EventBus'), EventLogsDataSource: Symbol('EventLogsDataSource'), @@ -40,9 +42,11 @@ export const Tokens = { UnitDataSource: Symbol('UnitDataSource'), UserAuthorization: Symbol('UserAuthorization'), ProposalAuthorization: Symbol('ProposalAuthorization'), + VisitAuthorization: Symbol('VisitAuthorization'), VisitRegistrationAuthorization: Symbol('VisitRegistrationAuthorization'), UserDataSource: Symbol('UserDataSource'), VisitDataSource: Symbol('VisitDataSource'), + VisitRegistrationClaimDataSource: Symbol('VisitRegistrationClaimDataSource'), PredefinedMessageDataSource: Symbol('PredefinedMessageDataSource'), InternalReviewDataSource: Symbol('InternalReviewDataSource'), FapDataColumns: Symbol('FapDataColumns'), diff --git a/apps/backend/src/config/dependencyConfigDefault.ts b/apps/backend/src/config/dependencyConfigDefault.ts index 741b88aa3b..f271d12ed8 100644 --- a/apps/backend/src/config/dependencyConfigDefault.ts +++ b/apps/backend/src/config/dependencyConfigDefault.ts @@ -5,12 +5,15 @@ import { } from '@user-office-software/duo-logger'; import 'reflect-metadata'; +import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; import { OAuthAuthorization } from '../auth/OAuthAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; import PostgresExperimentSafetyPdfTemplateDataSource from '../datasources/postgres/ExperimentSafetyPdfTemplateDataSource'; @@ -41,6 +44,7 @@ import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSour import PostgresUnitDataSource from '../datasources/postgres/UnitDataSource'; import PostgresUserDataSource from '../datasources/postgres/UserDataSource'; import PostgresVisitDataSource from '../datasources/postgres/VisitDataSource'; +import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/VisitRegistrationClaimDataSource'; import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import { createSkipLoggingHandler } from '../eventHandlers/logging'; import { SkipSendMailService } from '../eventHandlers/MailService/SkipSendMailService'; @@ -68,6 +72,7 @@ async function skipEmailHandler(event: ApplicationEvent) { mapClass(Tokens.AdminDataSource, PostgresAdminDataSourceWithAutoUpgrade); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); mapClass(Tokens.FeedbackDataSource, PostgresFeedbackDataSource); @@ -103,6 +108,11 @@ mapClass(Tokens.TemplateDataSource, PostgresTemplateDataSource); mapClass(Tokens.UnitDataSource, PostgresUnitDataSource); mapClass(Tokens.UserDataSource, PostgresUserDataSource); mapClass(Tokens.VisitDataSource, PostgresVisitDataSource); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + PostgresVisitRegistrationClaimDataSource +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.TechniqueDataSource, PostgresTechniqueDataSource); mapClass( @@ -117,6 +127,7 @@ mapClass(Tokens.TagDataSource, PostgresTagDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); +mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); mapClass(Tokens.AssetRegistrar, SkipAssetRegistrar); diff --git a/apps/backend/src/config/dependencyConfigE2E.ts b/apps/backend/src/config/dependencyConfigE2E.ts index a768991d8e..f99f9948cd 100644 --- a/apps/backend/src/config/dependencyConfigE2E.ts +++ b/apps/backend/src/config/dependencyConfigE2E.ts @@ -1,12 +1,15 @@ import { ConsoleLogger, setLogger } from '@user-office-software/duo-logger'; import 'reflect-metadata'; +import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; import { OAuthAuthorization } from '../auth/OAuthAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import PostgresAdminDataSource from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; import PostgresExperimentSafetyPdfTemplateDataSource from '../datasources/postgres/ExperimentSafetyPdfTemplateDataSource'; @@ -37,6 +40,7 @@ import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSour import PostgresUnitDataSource from '../datasources/postgres/UnitDataSource'; import PostgresUserDataSource from '../datasources/postgres/UserDataSource'; import PostgresVisitDataSource from '../datasources/postgres/VisitDataSource'; +import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/VisitRegistrationClaimDataSource'; import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; @@ -60,6 +64,7 @@ import { mapClass, mapValue } from './utils'; mapClass(Tokens.AdminDataSource, PostgresAdminDataSource); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); mapClass(Tokens.FeedbackDataSource, PostgresFeedbackDataSource); @@ -94,6 +99,11 @@ mapClass(Tokens.TemplateDataSource, PostgresTemplateDataSource); mapClass(Tokens.UnitDataSource, PostgresUnitDataSource); mapClass(Tokens.UserDataSource, PostgresUserDataSource); mapClass(Tokens.VisitDataSource, PostgresVisitDataSource); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + PostgresVisitRegistrationClaimDataSource +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.TechniqueDataSource, PostgresTechniqueDataSource); mapClass( @@ -108,6 +118,7 @@ mapClass(Tokens.TagDataSource, PostgresTagDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); +mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); mapClass(Tokens.AssetRegistrar, SkipAssetRegistrar); diff --git a/apps/backend/src/config/dependencyConfigELI.ts b/apps/backend/src/config/dependencyConfigELI.ts index 16005a17d8..6a158f0b8c 100644 --- a/apps/backend/src/config/dependencyConfigELI.ts +++ b/apps/backend/src/config/dependencyConfigELI.ts @@ -1,11 +1,14 @@ import 'reflect-metadata'; +import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; import { OAuthAuthorization } from '../auth/OAuthAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; import PostgresExperimentSafetyPdfTemplateDataSource from '../datasources/postgres/ExperimentSafetyPdfTemplateDataSource'; @@ -36,6 +39,7 @@ import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSour import PostgresUnitDataSource from '../datasources/postgres/UnitDataSource'; import PostgresUserDataSource from '../datasources/postgres/UserDataSource'; import PostgresVisitDataSource from '../datasources/postgres/VisitDataSource'; +import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/VisitRegistrationClaimDataSource'; import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import { eliEmailHandler } from '../eventHandlers/email/eliEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; @@ -62,6 +66,7 @@ const isProduction = process.env.NODE_ENV === 'production'; mapClass(Tokens.AdminDataSource, PostgresAdminDataSourceWithAutoUpgrade); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); mapClass(Tokens.FeedbackDataSource, PostgresFeedbackDataSource); @@ -96,6 +101,11 @@ mapClass(Tokens.TemplateDataSource, PostgresTemplateDataSource); mapClass(Tokens.UnitDataSource, PostgresUnitDataSource); mapClass(Tokens.UserDataSource, PostgresUserDataSource); mapClass(Tokens.VisitDataSource, PostgresVisitDataSource); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + PostgresVisitRegistrationClaimDataSource +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.InternalReviewDataSource, PostgresInternalReviewDataSource); mapClass(Tokens.TechniqueDataSource, PostgresTechniqueDataSource); @@ -111,6 +121,7 @@ mapClass(Tokens.TagDataSource, PostgresTagDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); +mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); mapClass(Tokens.AssetRegistrar, EAMAssetRegistrar); @@ -135,3 +146,4 @@ mapValue( mapValue(Tokens.ConfigureLogger, configureGraylogLogger); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); +mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); diff --git a/apps/backend/src/config/dependencyConfigESS.ts b/apps/backend/src/config/dependencyConfigESS.ts index 765686003f..ddf4cdaa05 100644 --- a/apps/backend/src/config/dependencyConfigESS.ts +++ b/apps/backend/src/config/dependencyConfigESS.ts @@ -1,12 +1,15 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import 'reflect-metadata'; +import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; import { OAuthAuthorization } from '../auth/OAuthAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; import PostgresExperimentSafetyPdfTemplateDataSource from '../datasources/postgres/ExperimentSafetyPdfTemplateDataSource'; @@ -37,6 +40,7 @@ import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSour import PostgresUnitDataSource from '../datasources/postgres/UnitDataSource'; import PostgresUserDataSource from '../datasources/postgres/UserDataSource'; import PostgresVisitDataSource from '../datasources/postgres/VisitDataSource'; +import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/VisitRegistrationClaimDataSource'; import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; @@ -62,6 +66,7 @@ import { mapClass, mapValue } from './utils'; mapClass(Tokens.AdminDataSource, PostgresAdminDataSourceWithAutoUpgrade); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); mapClass(Tokens.FeedbackDataSource, PostgresFeedbackDataSource); @@ -95,6 +100,11 @@ mapClass(Tokens.TemplateDataSource, PostgresTemplateDataSource); mapClass(Tokens.UnitDataSource, PostgresUnitDataSource); mapClass(Tokens.UserDataSource, PostgresUserDataSource); mapClass(Tokens.VisitDataSource, PostgresVisitDataSource); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + PostgresVisitRegistrationClaimDataSource +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.InternalReviewDataSource, PostgresInternalReviewDataSource); mapClass(Tokens.TechniqueDataSource, PostgresTechniqueDataSource); @@ -111,6 +121,7 @@ mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); +mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); mapClass(Tokens.AssetRegistrar, EAMAssetRegistrar); @@ -135,3 +146,4 @@ mapValue( mapValue(Tokens.ConfigureLogger, configureGraylogLogger); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); +mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); diff --git a/apps/backend/src/config/dependencyConfigSTFC.ts b/apps/backend/src/config/dependencyConfigSTFC.ts index ecd34437c4..bcc7995ff9 100644 --- a/apps/backend/src/config/dependencyConfigSTFC.ts +++ b/apps/backend/src/config/dependencyConfigSTFC.ts @@ -1,11 +1,14 @@ import 'reflect-metadata'; +import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; import { StfcProposalAuthorization } from '../auth/StfcProposalAuthorization'; import { StfcUserAuthorization } from '../auth/StfcUserAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; import PostgresExperimentSafetyPdfTemplateDataSource from '../datasources/postgres/ExperimentSafetyPdfTemplateDataSource'; @@ -30,6 +33,7 @@ import PostgresTagDataSource from '../datasources/postgres/TagDataSource'; import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSource'; import PostgresUnitDataSource from '../datasources/postgres/UnitDataSource'; import PostgresVisitDataSource from '../datasources/postgres/VisitDataSource'; +import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/VisitRegistrationClaimDataSource'; import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import StfcFapDataSource from '../datasources/stfc/StfcFapDataSource'; import StfcInstrumentDataSource from '../datasources/stfc/StfcInstrumentDataSource'; @@ -60,6 +64,7 @@ import { mapClass, mapValue } from './utils'; mapClass(Tokens.AdminDataSource, PostgresAdminDataSourceWithAutoUpgrade); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); mapClass(Tokens.FeedbackDataSource, PostgresFeedbackDataSource); @@ -93,6 +98,11 @@ mapClass(Tokens.TemplateDataSource, PostgresTemplateDataSource); mapClass(Tokens.UnitDataSource, PostgresUnitDataSource); mapClass(Tokens.UserDataSource, StfcUserDataSource); mapClass(Tokens.VisitDataSource, PostgresVisitDataSource); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + PostgresVisitRegistrationClaimDataSource +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.InternalReviewDataSource, StfcInternalReviewDataSource); mapClass(Tokens.TechniqueDataSource, StfcTechniqueDataSource); @@ -108,6 +118,7 @@ mapClass(Tokens.TagDataSource, PostgresTagDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.UserAuthorization, StfcUserAuthorization); mapClass(Tokens.ProposalAuthorization, StfcProposalAuthorization); +mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); mapClass(Tokens.AssetRegistrar, SkipAssetRegistrar); diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index 748c731f93..b3f0c39c0a 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -2,12 +2,15 @@ import { ConsoleLogger, setLogger } from '@user-office-software/duo-logger'; import 'reflect-metadata'; +import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; import { UserAuthorizationMock } from '../auth/mockups/UserAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import { AdminDataSourceMock } from '../datasources/mockups/AdminDataSource'; import { CallDataSourceMock } from '../datasources/mockups/CallDataSource'; import { CoProposerClaimDataSourceMock } from '../datasources/mockups/CoProposerClaimDataSource'; +import MockDataAccessUsersDataSource from '../datasources/mockups/DataAccessUsersDataSource'; import { EventLogsDataSourceMock } from '../datasources/mockups/EventLogsDataSource'; import { ExperimentDataSourceMock } from '../datasources/mockups/ExperimentDataSource'; import { ExperimentSafetyPdfTemplateDataSourceMock } from '../datasources/mockups/ExperimentSafetyPdfTemplateDataSource'; @@ -37,6 +40,7 @@ import { TechniqueDataSourceMock } from '../datasources/mockups/TechniqueDataSou import { TemplateDataSourceMock } from '../datasources/mockups/TemplateDataSource'; import { UnitDataSourceMock } from '../datasources/mockups/UnitDataSource'; import { UserDataSourceMock } from '../datasources/mockups/UserDataSource'; +import { VisitRegistrationClaimDataSourceMock } from '../datasources/mockups/VisitRegistrationClaimDataSource'; import { WorkflowDataSourceMock } from '../datasources/mockups/WorkflowDataSource'; import { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; @@ -54,6 +58,7 @@ import { mapClass, mapValue } from './utils'; mapClass(Tokens.AdminDataSource, AdminDataSourceMock); mapClass(Tokens.CoProposerClaimDataSource, CoProposerClaimDataSourceMock); +mapClass(Tokens.DataAccessUsersDataSource, MockDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, CallDataSourceMock); mapClass(Tokens.EventLogsDataSource, EventLogsDataSourceMock); mapClass(Tokens.FeedbackDataSource, FeedbackDataSourceMock); @@ -92,6 +97,11 @@ mapClass(Tokens.TemplateDataSource, TemplateDataSourceMock); mapClass(Tokens.UnitDataSource, UnitDataSourceMock); mapClass(Tokens.UserDataSource, UserDataSourceMock); mapClass(Tokens.VisitDataSource, VisitDataSourceMock); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + VisitRegistrationClaimDataSourceMock +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.PredefinedMessageDataSource, PredefinedMessageDataSourceMock); mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSourceMock); @@ -99,6 +109,7 @@ mapClass(Tokens.TagDataSource, TagDataSourceMock); mapClass(Tokens.UserAuthorization, UserAuthorizationMock); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); +mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); mapClass(Tokens.AssetRegistrar, SkipAssetRegistrar); diff --git a/apps/backend/src/config/eli/configureELIEnvironment.ts b/apps/backend/src/config/eli/configureELIEnvironment.ts index 122e162c7b..56d4ee16d6 100644 --- a/apps/backend/src/config/eli/configureELIEnvironment.ts +++ b/apps/backend/src/config/eli/configureELIEnvironment.ts @@ -102,10 +102,6 @@ async function enableDefaultELIFeatures() { settingsId: SettingsId.DEFAULT_INST_SCI_STATUS_FILTER, settingsValue: 'FEASIBILITY_REVIEW', }); - await db.updateSettings({ - settingsId: SettingsId.GRADE_PRECISION, - settingsValue: '1', - }); } async function setELIRoleNames() { diff --git a/apps/backend/src/config/ess/configureESSEnvironment.ts b/apps/backend/src/config/ess/configureESSEnvironment.ts index 4aa0ff6b5a..8e046cb9a5 100644 --- a/apps/backend/src/config/ess/configureESSEnvironment.ts +++ b/apps/backend/src/config/ess/configureESSEnvironment.ts @@ -85,6 +85,7 @@ async function enableDefaultEssFeatures() { FeatureId.OAUTH, FeatureId.CONFLICT_OF_INTEREST_WARNING, FeatureId.EXPERIMENT_SAFETY_REVIEW, + FeatureId.DATA_ACCESS_USERS, ], true ); @@ -97,10 +98,6 @@ async function enableDefaultEssFeatures() { settingsId: SettingsId.DEFAULT_INST_SCI_STATUS_FILTER, settingsValue: 'FEASIBILITY_REVIEW', }); - await db.updateSettings({ - settingsId: SettingsId.GRADE_PRECISION, - settingsValue: '1', - }); await db.updateSettings({ settingsId: SettingsId.DISPLAY_FAQ_LINK, settingsValue: 'true', diff --git a/apps/backend/src/config/stfc/configureSTFCEnvironment.ts b/apps/backend/src/config/stfc/configureSTFCEnvironment.ts index 8220f30818..21460dc795 100644 --- a/apps/backend/src/config/stfc/configureSTFCEnvironment.ts +++ b/apps/backend/src/config/stfc/configureSTFCEnvironment.ts @@ -114,10 +114,6 @@ async function enableDefaultStfcFeatures() { settingsId: SettingsId.IDLE_TIMEOUT, settingsValue: '1200000', }); - await db.updateSettings({ - settingsId: SettingsId.GRADE_PRECISION, - settingsValue: '0.01', - }); await db.updateSettings({ settingsId: SettingsId.TECH_REVIEW_OPTIONAL_WORKFLOW_STATUS, settingsValue: 'FEASIBILITY', diff --git a/apps/backend/src/context/index.ts b/apps/backend/src/context/index.ts index 798529996c..2ae3032b37 100644 --- a/apps/backend/src/context/index.ts +++ b/apps/backend/src/context/index.ts @@ -5,6 +5,7 @@ import { Sdk } from '../middlewares/graphqlClient'; import { UserWithRole } from '../models/User'; import AdminMutations from '../mutations/AdminMutations'; import CallMutations from '../mutations/CallMutations'; +import DataAccessUsersMutations from '../mutations/DataAccessUsersMutations'; import ExperimentMutations from '../mutations/ExperimentMutation'; import ExperimentSafetyPdfTemplateMutations from '../mutations/ExperimentSafetyPdfTemplateMutations'; import FapMutations from '../mutations/FapMutations'; @@ -33,6 +34,7 @@ import VisitMutations from '../mutations/VisitMutations'; import WorkflowMutations from '../mutations/WorkflowMutations'; import AdminQueries from '../queries/AdminQueries'; import CallQueries from '../queries/CallQueries'; +import DataAccessUsersQueries from '../queries/DataAccessUsersQueries'; import EventLogQueries from '../queries/EventLogQueries'; import ExperimentQueries from '../queries/ExperimentQueries'; import ExperimentSafetyPdfTemplateQueries from '../queries/ExperimentSafetyPdfTemplateQueries'; @@ -67,6 +69,7 @@ import WorkflowQueries from '../queries/WorkflowQueries'; interface ResolverContextQueries { admin: AdminQueries; call: CallQueries; + dataAccessUsers: DataAccessUsersQueries; eventLogs: EventLogQueries; feedback: FeedbackQueries; file: FileQueries; @@ -102,6 +105,7 @@ interface ResolverContextQueries { interface ResolverContextMutations { admin: AdminMutations; call: CallMutations; + dataAccessUsers: DataAccessUsersMutations; feedback: FeedbackMutations; file: FileMutations; genericTemplate: GenericTemplateMutations; diff --git a/apps/backend/src/customTypes/index.d.ts b/apps/backend/src/customTypes/index.d.ts index 66322d7c4d..7b4a2ead3e 100644 --- a/apps/backend/src/customTypes/index.d.ts +++ b/apps/backend/src/customTypes/index.d.ts @@ -34,6 +34,18 @@ declare module 'knex' { query: string, userInput: string ): QueryBuilder; + + whereJsonFieldLikeEscaped( + column: string, + field: string, + userInput: string + ) : QueryBuilder; + + orWhereJsonFieldLikeEscaped( + column: string, + field: string, + userInput: string + ) : QueryBuilder; } } } diff --git a/apps/backend/src/datasources/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/DataAccessUsersDataSource.ts new file mode 100644 index 0000000000..d86c1765d4 --- /dev/null +++ b/apps/backend/src/datasources/DataAccessUsersDataSource.ts @@ -0,0 +1,22 @@ +import { Country } from '../models/Country'; +import { Institution } from '../models/Institution'; +import { Rejection } from '../models/Rejection'; +import { BasicUserDetails, User } from '../models/User'; + +export type UserWithInstitution = { + user: User; + institution: Institution; + country: Country; +}; + +export interface DataAccessUsersDataSource { + findByProposalPk(proposalPk: number): Promise; + getDataAccessUsersWithInstitution( + proposalPk: number + ): Promise; + updateDataAccessUsers( + proposalPk: number, + userIds: number[] + ): Promise; + isDataAccessUserOfProposal(id: number, proposalPk: number): Promise; +} diff --git a/apps/backend/src/datasources/ExperimentDataSource.ts b/apps/backend/src/datasources/ExperimentDataSource.ts index 6bbf0cac3f..a7ca421f84 100644 --- a/apps/backend/src/datasources/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/ExperimentDataSource.ts @@ -9,7 +9,6 @@ import { import { Rejection } from '../models/Rejection'; import { User } from '../models/User'; import { - ExperimentsArgs, ExperimentsFilter, UserExperimentsFilter, } from '../resolvers/queries/ExperimentsQuery'; @@ -84,8 +83,7 @@ export interface ExperimentDataSource { sampleId: number, isSubmitted: boolean ): Promise; - getExperiments({ filter }: ExperimentsArgs): Promise; - getAllExperiments( + getExperiments( filter?: ExperimentsFilter, first?: number, offset?: number, diff --git a/apps/backend/src/datasources/FapDataSource.ts b/apps/backend/src/datasources/FapDataSource.ts index d782d20f45..495c313d09 100644 --- a/apps/backend/src/datasources/FapDataSource.ts +++ b/apps/backend/src/datasources/FapDataSource.ts @@ -76,6 +76,11 @@ export interface FapDataSource { callId?: number | null; instrumentId?: number | null; }): Promise; + getLegacyFapProposals(filter: { + fapId: number; + callId?: number | null; + instrumentId?: number | null; + }): Promise; getFapUsersByProposalPkAndCallId( proposalPk: number, callId: number diff --git a/apps/backend/src/datasources/InviteDataSource.ts b/apps/backend/src/datasources/InviteDataSource.ts index 07974c0250..ca73fc5670 100644 --- a/apps/backend/src/datasources/InviteDataSource.ts +++ b/apps/backend/src/datasources/InviteDataSource.ts @@ -22,6 +22,10 @@ export interface InviteDataSource { proposalPk: number, isClaimed?: boolean ): Promise; + findVisitRegistrationInvites( + visitId: number, + isClaimed?: boolean + ): Promise; getInvites(filter: GetInvitesFilter): Promise; update(args: { diff --git a/apps/backend/src/datasources/StatusDataSource.ts b/apps/backend/src/datasources/StatusDataSource.ts index ca085a5fbb..ef326d4d35 100644 --- a/apps/backend/src/datasources/StatusDataSource.ts +++ b/apps/backend/src/datasources/StatusDataSource.ts @@ -1,4 +1,5 @@ import { Status } from '../models/Status'; +import { UpdateStatusInput } from '../resolvers/mutations/settings/UpdateStatusMutation'; export interface StatusDataSource { createStatus( @@ -6,7 +7,7 @@ export interface StatusDataSource { ): Promise; getStatus(statusId: number): Promise; getAllStatuses(entityType: Status['entityType']): Promise; - updateStatus(status: Omit): Promise; + updateStatus(status: UpdateStatusInput): Promise; deleteStatus(statusId: number): Promise; getDefaultStatus(entityType: Status['entityType']): Promise; } diff --git a/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts new file mode 100644 index 0000000000..6ab7626e21 --- /dev/null +++ b/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts @@ -0,0 +1,6 @@ +import { VisitRegistrationClaim } from '../models/VisitRegistrationClaim'; + +export interface VisitRegistrationClaimDataSource { + create(inviteId: number, visitId: number): Promise; + findByInviteId(inviteId: number): Promise; +} diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index 3ee0a74984..e7bd47c9f9 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -6,29 +6,38 @@ import { WorkflowConnection, WorkflowConnectionWithStatus, } from '../models/WorkflowConnections'; +import { CreateWorkflowInput } from '../resolvers/mutations/settings/CreateWorkflowMutation'; +import { UpdateWorkflowInput } from '../resolvers/mutations/settings/UpdateWorkflowMutation'; export interface WorkflowDataSource { - createWorkflow(newWorkflowInput: Omit): Promise; + createWorkflow(newWorkflowInput: CreateWorkflowInput): Promise; getWorkflow(workflowId: number): Promise; getAllWorkflows(entityType: Workflow['entityType']): Promise; - updateWorkflow(workflow: Omit): Promise; + updateWorkflow(workflow: UpdateWorkflowInput): Promise; deleteWorkflow(workflowId: number): Promise; + deleteWorkflowConnection( + connectionId: number + ): Promise; getWorkflowConnections( - workflowId: WorkflowConnection['workflowId'], - droppableGroupId?: WorkflowConnection['droppableGroupId'], - byParentGroupId?: boolean | undefined + workflowId: WorkflowConnection['workflowId'] ): Promise; + getWorkflowConnection( + connectionId: WorkflowConnection['id'] + ): Promise; getWorkflowConnectionsById( workflowId: WorkflowConnection['workflowId'], - statusId: Status['id'], + statusId: Status['id'] | undefined, { nextStatusId, prevStatusId, sortOrder }: NextAndPreviousStatuses ): Promise; addWorkflowStatus( - newWorkflowStatusInput: Omit + newWorkflowStatusInput: Omit< + WorkflowConnection, + 'id' | 'entityType' | 'prevConnectionId' + > + ): Promise; + updateWorkflowStatus( + workflowStatuses: WorkflowConnection ): Promise; - updateWorkflowStatuses( - workflowStatuses: WorkflowConnection[] - ): Promise; deleteWorkflowStatus( statusId: number, workflowId: number, diff --git a/apps/backend/src/datasources/mockups/CallDataSource.ts b/apps/backend/src/datasources/mockups/CallDataSource.ts index 6de918f9ff..982429fa07 100644 --- a/apps/backend/src/datasources/mockups/CallDataSource.ts +++ b/apps/backend/src/datasources/mockups/CallDataSource.ts @@ -16,7 +16,8 @@ export const dummyWorkflow = new Workflow( 1, 'Test workflow', 'This is description', - WorkflowType.PROPOSAL + WorkflowType.PROPOSAL, + 'default' ); export const dummyCallFactory = (values?: Partial) => { diff --git a/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts new file mode 100644 index 0000000000..2f0b066253 --- /dev/null +++ b/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts @@ -0,0 +1,138 @@ +import { Country } from '../../models/Country'; +import { Institution } from '../../models/Institution'; +import { Rejection } from '../../models/Rejection'; +import { BasicUserDetails, User } from '../../models/User'; +import { + DataAccessUsersDataSource, + UserWithInstitution, +} from '../DataAccessUsersDataSource'; +import { basicDummyUser, basicDummyUserNotOnProposal } from './UserDataSource'; + +// Mock full user objects for getDataAccessUsersWithInstitution +export const dummyDataAccessFullUser = new User( + 100, + 'Dr.', + 'Jane', + 'Smith', + 'jane.smith', + 'Jane', + 'jane.smith.oidc', + 'refresh-token', + 'oauth-issuer', + 'female', + new Date('1985-01-15'), + 10, + 'Data Access Institute', + 'Research Department', + 'Data Analyst', + 'jane.smith@dataaccess.org', + '+1-555-0100', + false, + '2023-01-15 10:30:00+00', + '2023-01-15 10:30:00+00' +); + +export const dummyDataAccessFullUser2 = new User( + 101, + 'Dr.', + 'Bob', + 'Johnson', + 'bob.johnson', + 'Bob', + 'bob.johnson.oidc', + 'refresh-token', + 'oauth-issuer', + 'male', + new Date('1982-02-20'), + 11, + 'Research Center', + 'Advanced Research', + 'Senior Researcher', + 'bob.johnson@research.org', + '+1-555-0101', + false, + '2023-02-20 14:15:00+00', + '2023-02-20 14:15:00+00' +); + +export const dummyInstitution = new Institution(10, 'Data Access Institute', 1); +export const dummyInstitution2 = new Institution(11, 'Research Center', 2); + +export const dummyCountry = new Country(1, 'Denmark'); +export const dummyCountry2 = new Country(2, 'United Kingdom'); + +export default class MockDataAccessUsersDataSource + implements DataAccessUsersDataSource +{ + private mockData: Map = new Map([[1, []]]); + + async findByProposalPk(proposalPk: number): Promise { + const userIds = this.mockData.get(proposalPk) || []; + + // Return mock users based on stored user IDs + const users: BasicUserDetails[] = []; + if (userIds.includes(basicDummyUser.id)) { + users.push(basicDummyUser); + } + + if (userIds.includes(basicDummyUserNotOnProposal.id)) { + users.push(basicDummyUserNotOnProposal); + } + + return users; + } + + async getDataAccessUsersWithInstitution( + proposalPk: number + ): Promise { + const userIds = this.mockData.get(proposalPk) || []; + + // Return mock users with institution and country based on stored user IDs + const users: UserWithInstitution[] = []; + + if (userIds.includes(100)) { + users.push({ + user: dummyDataAccessFullUser, + institution: dummyInstitution, + country: dummyCountry, + }); + } + if (userIds.includes(101)) { + users.push({ + user: dummyDataAccessFullUser2, + institution: dummyInstitution2, + country: dummyCountry2, + }); + } + + return users; + } + + async updateDataAccessUsers( + proposalPk: number, + userIds: number[] + ): Promise { + try { + // Update mock data + this.mockData.set(proposalPk, userIds); + + // Return updated list + return this.findByProposalPk(proposalPk); + } catch (error) { + return new Rejection('Failed to update data access users', { + proposalPk, + userIds, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + async isDataAccessUserOfProposal( + id: number, + proposalPk: number + ): Promise { + const userIds = this.mockData.get(proposalPk) || []; + + return Promise.resolve(userIds.includes(id)); + } +} diff --git a/apps/backend/src/datasources/mockups/ExperimentDataSource.ts b/apps/backend/src/datasources/mockups/ExperimentDataSource.ts index 231dba7445..67b9c50d54 100644 --- a/apps/backend/src/datasources/mockups/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/mockups/ExperimentDataSource.ts @@ -12,7 +12,6 @@ import { User } from '../../models/User'; import { SubmitExperimentSafetyArgs } from '../../resolvers/mutations/SubmitExperimentSafetyMutation'; import { UserExperimentsFilter, - ExperimentsArgs, ExperimentsFilter, } from '../../resolvers/queries/ExperimentsQuery'; import { ExperimentDataSource } from '../ExperimentDataSource'; @@ -118,7 +117,7 @@ export class ExperimentDataSourceMock implements ExperimentDataSource { this.init(); } - async getAllExperiments( + async getExperiments( filter?: ExperimentsFilter, first?: number, offset?: number, @@ -320,11 +319,6 @@ export class ExperimentDataSourceMock implements ExperimentDataSource { return Promise.resolve(samples); } - getExperiments({ filter }: ExperimentsArgs): Promise { - // For simplicity, ignoring filter and returning all experiments - return Promise.resolve(this.experiments); - } - updateExperimentSample( experimentPk: number, sampleId: number, diff --git a/apps/backend/src/datasources/mockups/FapDataSource.ts b/apps/backend/src/datasources/mockups/FapDataSource.ts index 0a25578653..217cfbb6c1 100644 --- a/apps/backend/src/datasources/mockups/FapDataSource.ts +++ b/apps/backend/src/datasources/mockups/FapDataSource.ts @@ -96,7 +96,7 @@ export const dummyFapReview = new Review( 1, 1, 'Dummy Fap review', - 7, + '7', 0, 1, 1, @@ -239,6 +239,14 @@ export class FapDataSourceMock implements FapDataSource { ); } + getLegacyFapProposals(filter: { + fapId: number; + callId?: number | null; + instrumentId?: number | null; + }): Promise { + throw new Error('Method not implemented.'); + } + updateTimeAllocation( fapId: number, proposalPk: number, diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index 855822aec8..5447b1ed66 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -27,6 +27,18 @@ export class InviteDataSourceMock implements InviteDataSource { return invites.filter((invite) => invite !== null) as Invite[]; } + async findVisitRegistrationInvites( + visitId: number, + includeExpired: boolean + ): Promise { + const invites = this.invites.filter( + (invite) => + invite.id === visitId && + (includeExpired || !invite.expiresAt || invite.expiresAt > new Date()) + ); + + return invites; + } async delete(id: number): Promise { this.invites = this.invites.filter((invite) => invite.id !== id); } diff --git a/apps/backend/src/datasources/mockups/ReviewDataSource.ts b/apps/backend/src/datasources/mockups/ReviewDataSource.ts index e5d3084d28..c0cd722e71 100644 --- a/apps/backend/src/datasources/mockups/ReviewDataSource.ts +++ b/apps/backend/src/datasources/mockups/ReviewDataSource.ts @@ -16,7 +16,7 @@ export const dummyReview = new Review( 1, 1, 'Good proposal', - 9, + '9', 0, 1, 1, @@ -31,7 +31,7 @@ export const dummySubmittedReview = new Review( 1, 1, 'Good proposal', - 9, + '9', 1, 1, 1, @@ -62,7 +62,7 @@ export const dummyReviewBad = new Review( 1, 1, 'bad proposal', - 1, + '1', 0, 1, 1, @@ -116,7 +116,7 @@ export class ReviewDataSourceMock implements ReviewDataSource { proposalPk, userID, ' ', - 1, + '1', 1, 1, 1, @@ -133,7 +133,7 @@ export class ReviewDataSourceMock implements ReviewDataSource { 1, 1, ' ', - 1, + '1', 1, 1, 1, diff --git a/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts new file mode 100644 index 0000000000..5fab6e1fce --- /dev/null +++ b/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts @@ -0,0 +1,40 @@ +import { injectable } from 'tsyringe'; + +import { VisitRegistrationClaim } from '../../models/VisitRegistrationClaim'; +import { VisitRegistrationClaimDataSource } from '../VisitRegistrationClaimDataSource'; + +@injectable() +export class VisitRegistrationClaimDataSourceMock + implements VisitRegistrationClaimDataSource +{ + private visitRegistrationClaims: VisitRegistrationClaim[]; + + constructor() { + this.init(); + } + + public init() { + this.visitRegistrationClaims = [ + new VisitRegistrationClaim(1, 1), + new VisitRegistrationClaim(2, 1), + new VisitRegistrationClaim(3, 2), + new VisitRegistrationClaim(1, 3), + ]; + } + + async create( + inviteId: number, + visitId: number + ): Promise { + const newClaim = new VisitRegistrationClaim(inviteId, visitId); + this.visitRegistrationClaims.push(newClaim); + + return newClaim; + } + + async findByInviteId(inviteId: number): Promise { + return this.visitRegistrationClaims.filter( + (claim) => claim.inviteId === inviteId + ); + } +} diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 5c8a403017..317aadf095 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -3,10 +3,12 @@ import { StatusChangingEvent } from '../../models/StatusChangingEvent'; import { Workflow, WorkflowType } from '../../models/Workflow'; import { NextAndPreviousStatuses, + WorkflowConnection, WorkflowConnectionWithStatus, } from '../../models/WorkflowConnections'; import { AddWorkflowStatusInput } from '../../resolvers/mutations/settings/AddWorkflowStatusMutation'; import { CreateWorkflowInput } from '../../resolvers/mutations/settings/CreateWorkflowMutation'; +import { UpdateWorkflowInput } from '../../resolvers/mutations/settings/UpdateWorkflowMutation'; import { WorkflowDataSource } from '../WorkflowDataSource'; export const dummyStatuses = [ @@ -24,7 +26,8 @@ export const dummyWorkflow = new Workflow( 1, 'Test workflow', 'This is description', - WorkflowType.PROPOSAL + WorkflowType.PROPOSAL, + 'default' ); export const dummyWorkflowConnection = new WorkflowConnectionWithStatus( @@ -32,17 +35,18 @@ export const dummyWorkflowConnection = new WorkflowConnectionWithStatus( 1, 1, 1, - { - id: 1, - shortCode: 'TEST_STATUS', - name: 'Test status', - description: 'Test status', - isDefault: false, - entityType: WorkflowType.PROPOSAL, - }, + new Status( + 1, + 'TEST_STATUS', + 'Test status', + 'Test status', + false, + WorkflowType.PROPOSAL + ), null, null, - 'proposalWorkflowConnections_0', + 100, + 100, null ); @@ -51,17 +55,18 @@ export const anotherDummyWorkflowConnection = new WorkflowConnectionWithStatus( 2, 1, 2, - { - id: 2, - shortCode: 'TEST_STATUS_2', - name: 'Test status 2', - description: 'Test status 2', - isDefault: false, - entityType: WorkflowType.PROPOSAL, - }, + new Status( + 2, + 'TEST_STATUS_2', + 'Test status 2', + 'Test status 2', + false, + WorkflowType.PROPOSAL + ), null, 1, - 'proposalWorkflowConnections_0', + 200, + 150, null ); @@ -88,7 +93,7 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { return [dummyWorkflow]; } - async updateWorkflow(Workflow: Workflow): Promise { + async updateWorkflow(Workflow: UpdateWorkflowInput): Promise { return dummyWorkflow; } @@ -102,6 +107,19 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { return [dummyWorkflowConnection, anotherDummyWorkflowConnection]; } + async getWorkflowConnection( + connectionId: number + ): Promise { + if (connectionId === dummyWorkflowConnection.id) { + return dummyWorkflowConnection; + } + if (connectionId === anotherDummyWorkflowConnection.id) { + return anotherDummyWorkflowConnection; + } + + return null; + } + async getWorkflowConnectionsById( workflowId: number, workflowConnectionId: number, @@ -116,10 +134,10 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { return dummyWorkflowConnection; } - async updateWorkflowStatuses( - workflowStatuses: WorkflowConnectionWithStatus[] - ): Promise { - return [dummyWorkflowConnection]; + async updateWorkflowStatus( + workflowStatus: WorkflowConnection + ): Promise { + return dummyWorkflowConnection; } async deleteWorkflowStatus( @@ -130,6 +148,19 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { return dummyWorkflowConnection; } + async deleteWorkflowConnection( + connectionId: number + ): Promise { + if (connectionId === dummyWorkflowConnection.id) { + return dummyWorkflowConnection; + } + if (connectionId === anotherDummyWorkflowConnection.id) { + return anotherDummyWorkflowConnection; + } + + return null; + } + async addStatusChangingEventsToConnection( workflowConnectionId: number, statusChangingEvents: string[] diff --git a/apps/backend/src/datasources/postgres/CallDataSource.ts b/apps/backend/src/datasources/postgres/CallDataSource.ts index dfd3b51073..00520c5e18 100644 --- a/apps/backend/src/datasources/postgres/CallDataSource.ts +++ b/apps/backend/src/datasources/postgres/CallDataSource.ts @@ -587,7 +587,8 @@ export default class PostgresCallDataSource implements CallDataSource { proposalWorkflow.workflow_id, proposalWorkflow.name, proposalWorkflow.description, - proposalWorkflow.entity_type + proposalWorkflow.entity_type, + proposalWorkflow.connection_line_type ); } diff --git a/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts new file mode 100644 index 0000000000..fbe6fce097 --- /dev/null +++ b/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts @@ -0,0 +1,105 @@ +import { Rejection } from '../../models/Rejection'; +import { BasicUserDetails } from '../../models/User'; +import { + DataAccessUsersDataSource, + UserWithInstitution, +} from '../DataAccessUsersDataSource'; +import database from './database'; +import { + CountryRecord, + InstitutionRecord, + UserRecord, + createBasicUserObject, + createUserObject, + createInstitutionObject, + createCountryObject, +} from './records'; + +export default class PostgresDataAccessUsersDataSource + implements DataAccessUsersDataSource +{ + async findByProposalPk(proposalPk: number): Promise { + return database + .select() + .from('users as u') + .join('institutions as i', { 'u.institution_id': 'i.institution_id' }) + .join('countries as c', { 'i.country_id': 'c.country_id' }) + .join('data_access_user_has_proposal as dauhp', { + 'u.user_id': 'dauhp.user_id', + }) + .where('dauhp.proposal_pk', proposalPk) + .then((users: Array) => + users.map((user) => createBasicUserObject(user)) + ); + } + + async getDataAccessUsersWithInstitution( + proposalPk: number + ): Promise { + return database + .select('i.*', 'c.*', 'u.*') + .from('users as u') + .join('data_access_user_has_proposal as dauhp', { + 'u.user_id': 'dauhp.user_id', + }) + .leftJoin('institutions as i', { 'u.institution_id': 'i.institution_id' }) + .leftJoin('countries as c', { 'c.country_id': 'i.country_id' }) + .where('dauhp.proposal_pk', proposalPk) + .then((users: (UserRecord & InstitutionRecord & CountryRecord)[]) => { + return users.map((user) => { + return { + user: createUserObject(user), + institution: createInstitutionObject(user), + country: createCountryObject(user), + }; + }); + }); + } + + async updateDataAccessUsers( + proposalPk: number, + userIds: number[] + ): Promise { + try { + // Start a transaction + await database.transaction(async (trx) => { + // Delete existing entries for this proposal + await trx('data_access_user_has_proposal') + .where('proposal_pk', proposalPk) + .del(); + + // Insert new entries if userIds is not empty + if (userIds.length > 0) { + const insertData = userIds.map((userId) => ({ + proposal_pk: proposalPk, + user_id: userId, + })); + + await trx('data_access_user_has_proposal').insert(insertData); + } + }); + + // Return updated list of users + return this.findByProposalPk(proposalPk); + } catch (error) { + return new Rejection('Failed to update data access users', { + proposalPk, + userIds, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + async isDataAccessUserOfProposal( + id: number, + proposalPk: number + ): Promise { + const result = await database + .select('user_id') + .from('data_access_user_has_proposal') + .where({ proposal_pk: proposalPk, user_id: id }) + .first(); + + return result !== undefined; + } +} diff --git a/apps/backend/src/datasources/postgres/ExperimentDataSource.ts b/apps/backend/src/datasources/postgres/ExperimentDataSource.ts index ed9bf97b8d..02b9a1cb9a 100644 --- a/apps/backend/src/datasources/postgres/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/postgres/ExperimentDataSource.ts @@ -13,7 +13,6 @@ import { import { Rejection } from '../../models/Rejection'; import { SubmitExperimentSafetyArgs } from '../../resolvers/mutations/SubmitExperimentSafetyMutation'; import { - ExperimentsArgs, ExperimentsFilter, UserExperimentsFilter, } from '../../resolvers/queries/ExperimentsQuery'; @@ -567,7 +566,7 @@ export default class PostgresExperimentDataSource ); } - async getAllExperiments( + async getExperiments( filter?: ExperimentsFilter, first?: number, offset?: number, @@ -575,23 +574,45 @@ export default class PostgresExperimentDataSource sortDirection?: string, searchText?: string ): Promise<{ totalCount: number; experiments: Experiment[] }> { - return database('experiments') + //print all arguments + + const query = database('experiments') .select(['*', database.raw('count(*) OVER() AS full_count')]) .join( 'proposals', 'proposals.proposal_pk', '=', 'experiments.proposal_pk' - ) + ); + + // Add instrument scientist filtering if provided + if (filter?.instrumentScientistUserId) { + query + .join( + 'instrument_has_scientists', + 'experiments.instrument_id', + 'instrument_has_scientists.instrument_id' + ) + .where( + 'instrument_has_scientists.user_id', + filter.instrumentScientistUserId + ); + } + + return query .modify((query) => { if (filter?.experimentEndDate) { - query.where('ends_at', '<=', filter.experimentEndDate); + query.where('experiments.ends_at', '<=', filter.experimentEndDate); } if (filter?.experimentStartDate) { - query.where('starts_at', '>=', filter.experimentStartDate); + query.where( + 'experiments.starts_at', + '>=', + filter.experimentStartDate + ); } if (filter?.instrumentId) { - query.where('instrument_id', filter.instrumentId); + query.where('experiments.instrument_id', filter.instrumentId); } if (filter?.experimentSafetyStatusId) { query @@ -672,60 +693,6 @@ export default class PostgresExperimentDataSource }); } - async getExperiments({ filter }: ExperimentsArgs): Promise { - return database('experiments') - .select('*') - .modify((query) => { - if (filter?.experimentEndDate) { - query.where('ends_at', '<=', filter.experimentEndDate); - } - if (filter?.experimentStartDate) { - query.where('starts_at', '>=', filter.experimentStartDate); - } - if (filter?.instrumentId) { - query.where('instrument_id', filter.instrumentId); - } - if (filter?.callId) { - query - .leftJoin( - 'proposals', - 'proposals.proposal_pk', - 'experiments.proposal_pk' - ) - .where('proposals.call_id', filter.callId); - } - const { from, to } = filter?.overlaps || {}; - if (from && to) { - query.where((builder) => - builder - .orWhere((subBuilder) => - subBuilder - .where('experiments.starts_at', '>=', from) - .andWhere('experiments.starts_at', '<=', to) - ) - .orWhere((subBuilder) => - subBuilder - .where('experiments.ends_at', '>=', from) - .andWhere('experiments.ends_at', '<=', to) - ) - .orWhere((subBuilder) => - subBuilder - .where('experiments.starts_at', '<=', from) - .andWhere('experiments.ends_at', '>=', from) - ) - .orWhere((subBuilder) => - subBuilder - .where('experiments.starts_at', '<=', to) - .andWhere('experiments.ends_at', '>=', to) - ) - ); - } - }) - .then((rows: ExperimentRecord[]) => - rows.map((row) => createExperimentObject(row)) - ); - } - async getExperimentsByProposalPk(proposalPk: number): Promise { return database('experiments') .select('*') diff --git a/apps/backend/src/datasources/postgres/FapDataSource.ts b/apps/backend/src/datasources/postgres/FapDataSource.ts index b9fc23fb1a..61e21e26a7 100644 --- a/apps/backend/src/datasources/postgres/FapDataSource.ts +++ b/apps/backend/src/datasources/postgres/FapDataSource.ts @@ -340,6 +340,45 @@ export default class PostgresFapDataSource implements FapDataSource { ); } + async getLegacyFapProposals(filter: { + fapId: number; + callId?: number | null; + instrumentId?: number | null; + }): Promise { + const fapProposals: FapProposalRecord[] = await database + .select(['fp.*']) + .from('fap_proposals as fp') + .modify((query) => { + query + .join('proposals as p', { + 'p.proposal_pk': 'fp.proposal_pk', + }) + .join('statuses as s', { + 'p.status_id': 's.status_id', + }) + .join('call as c', { + 'p.call_id': 'c.call_id', + }) + .where(function () { + this.where('p.submitted', true); + this.andWhere('c.call_fap_review_ended', true); + }); + + if (filter.callId) { + query.andWhere('fp.call_id', filter.callId); + } + if (filter.instrumentId) { + query.andWhere('fp.instrument_id', filter.instrumentId); + } + }) + .where('fp.fap_id', filter.fapId) + .distinctOn('fp.proposal_pk'); + + return fapProposals.map((fapProposal) => + createFapProposalObject(fapProposal) + ); + } + async getFapUsersByProposalPkAndCallId( proposalPk: number, callId: number @@ -864,6 +903,7 @@ export default class PostgresFapDataSource implements FapDataSource { memberId: number; fapProposalId: number; questionaryId: number; + rank?: number; }[], fapId: number ) { @@ -875,6 +915,7 @@ export default class PostgresFapDataSource implements FapDataSource { fap_id: fapId, fap_proposal_id: assignment.fapProposalId, questionary_id: assignment.questionaryId, + rank: assignment.rank || null, })) ); diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 3b54669135..58a819267d 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -30,6 +30,35 @@ export default class PostgresInviteDataSource implements InviteDataSource { }) .then((invites: InviteRecord[]) => invites.map(createInviteObject)); } + findVisitRegistrationInvites( + visitId: number, + isClaimed?: boolean + ): Promise { + return database + .select('invites.*') + .from('visit_registration_claims') + .where('visit_id', visitId) + .modify((query) => { + if (isClaimed !== undefined) { + if (isClaimed) { + query.whereNotNull('claimed_at'); + } else { + query.whereNull('claimed_at'); + } + } + }) + .leftJoin( + 'invites', + 'visit_registration_claims.invite_id', + 'invites.invite_id' + ) + .catch((error: Error) => { + throw new Error( + `Could not find visit registration invites: ${error.message}` + ); + }) + .then((invites: InviteRecord[]) => invites.map(createInviteObject)); + } findByCode(code: string): Promise { return database .select('*') diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index c359271514..e1609b21f0 100644 --- a/apps/backend/src/datasources/postgres/ProposalDataSource.ts +++ b/apps/backend/src/datasources/postgres/ProposalDataSource.ts @@ -377,11 +377,6 @@ export default class PostgresProposalDataSource implements ProposalDataSource { return query; } - safeEscapeRegexExpression(expression: string): string { - const escaped = expression.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - return escaped; - } async getProposalsFromView( filter?: ProposalsFilter, first?: number, @@ -442,10 +437,6 @@ export default class PostgresProposalDataSource implements ProposalDataSource { searchText !== null && searchText !== undefined ) { - const regexLiteral = this.safeEscapeRegexExpression(searchText); - // NOTE: Using jsonpath we check the jsonb (instruments) field if it contains object with name equal to searchText case insensitive - const jsonPathPattern = `$[*] ? (@.name.type() == "string" && @.name like_regex "${regexLiteral}" flag "i")`; - query.andWhere((qb) => qb .orWhereRaw('proposal_id ILIKE ?', [`%${searchText}%`]) @@ -455,9 +446,11 @@ export default class PostgresProposalDataSource implements ProposalDataSource { .orWhere('users.firstname', 'ilike', `%${searchText}%`) .orWhere('users.lastname', 'ilike', `%${searchText}%`) .orWhere('principal_investigator', 'in', principalInvestigator) - .orWhereRaw('jsonb_path_exists(instruments, ?)', [ - jsonPathPattern, - ]) + .orWhereJsonFieldLikeEscaped( + 'instruments', + 'name', + `${searchText}` + ) ); } @@ -631,13 +624,13 @@ export default class PostgresProposalDataSource implements ProposalDataSource { .orWhereRaw('title ILIKE ?', `%${filter.text}%`) .orWhereRaw('proposal_id ILIKE ?', `%${filter.text}%`) .orWhereRaw('proposal_status_name ILIKE ?', `%${filter.text}%`) - .orWhereRaw('users.email ILIKE', `%${filter.text}%`) - .orWhereRaw('users.firstname ILIKE', `%${filter.text}%`) - .orWhereRaw('users.lastname ILIKE', `%${filter.text}%`) - // NOTE: Using jsonpath we check the jsonb (instruments) field if it contains object with name equal to searchText case insensitive - .orWhereRaw( - 'jsonb_path_exists(instruments, \'$[*].name \\? (@.type() == "string" && @ like_regex :searchText: flag "i")\')', - { searchText: filter.text } + .orWhereRaw('users.email ILIKE ?', `%${filter.text}%`) + .orWhereRaw('users.firstname ILIKE ?', `%${filter.text}%`) + .orWhereRaw('users.lastname ILIKE ?', `%${filter.text}%`) + .orWhereJsonFieldLikeEscaped( + 'instruments', + 'name', + `${filter.text}` ) ); } @@ -653,7 +646,7 @@ export default class PostgresProposalDataSource implements ProposalDataSource { ).orWhereRaw( // This query finds proposals where the current user is a scientist on an instrument that allows multiple technical reviews // eslint-disable-next-line prettier/prettier - 'jsonb_path_exists(instruments, \'$[*] \\? (@.multipleTechReviewsEnabled == true && @.scientists[*].id == :userId:)\')', + "jsonb_path_exists(instruments, '$[*] \\? (@.multipleTechReviewsEnabled == true && @.scientists[*].id == :userId:)')", { userId: user.id } ); }); @@ -716,48 +709,56 @@ export default class PostgresProposalDataSource implements ProposalDataSource { id: number, filter?: UserProposalsFilter ): Promise { - return database - .select('p.*') - .from('proposals as p') - .where('p.proposer_id', id) // Principal investigator - .orWhereIn('p.proposal_pk', function () { - // co-proposer - this.select('proposal_pk').from('proposal_user').where('user_id', id); - }) - .orWhereIn('p.proposal_pk', function () { - // visitor - this.select('proposal_pk') - .from('visits') - .leftJoin( - 'visits_has_users', - 'visits.visit_id', - 'visits_has_users.visit_id' - ) - .where('visits_has_users.user_id', id); - }) - .modify((qb) => { - if (filter?.instrumentId) { - qb.innerJoin('instrument_has_proposals as ihp', { - 'p.proposal_pk': 'ihp.proposal_pk', - }); - qb.where('ihp.instrument_id', filter.instrumentId); - } + return ( + database + .select('p.*') + .from('proposals as p') + .where('p.proposer_id', id) // Principal investigator + .orWhereIn('p.proposal_pk', function () { + // co-proposer + this.select('proposal_pk').from('proposal_user').where('user_id', id); + }) + // data access + .orWhereIn('p.proposal_pk', function () { + this.select('proposal_pk') + .from('data_access_user_has_proposal') + .where('user_id', id); + }) + .orWhereIn('p.proposal_pk', function () { + // visitor + this.select('proposal_pk') + .from('visits') + .leftJoin( + 'visits_has_users', + 'visits.visit_id', + 'visits_has_users.visit_id' + ) + .where('visits_has_users.user_id', id); + }) + .modify((qb) => { + if (filter?.instrumentId) { + qb.innerJoin('instrument_has_proposals as ihp', { + 'p.proposal_pk': 'ihp.proposal_pk', + }); + qb.where('ihp.instrument_id', filter.instrumentId); + } - if (filter?.managementDecisionSubmitted) { - qb.where( - 'p.management_decision_submitted', - filter.managementDecisionSubmitted - ); - } + if (filter?.managementDecisionSubmitted) { + qb.where( + 'p.management_decision_submitted', + filter.managementDecisionSubmitted + ); + } - if (filter?.finalStatus) { - qb.where('p.final_status', filter.finalStatus); - } - }) - .groupBy('p.proposal_pk') - .then((proposals: ProposalRecord[]) => - proposals.map((proposal) => createProposalObject(proposal)) - ); + if (filter?.finalStatus) { + qb.where('p.final_status', filter.finalStatus); + } + }) + .groupBy('p.proposal_pk') + .then((proposals: ProposalRecord[]) => + proposals.map((proposal) => createProposalObject(proposal)) + ) + ); } async getProposalsByPks(proposalPks: number[]): Promise { diff --git a/apps/backend/src/datasources/postgres/StatusDataSource.ts b/apps/backend/src/datasources/postgres/StatusDataSource.ts index 59a6c8d620..496cfe19c3 100644 --- a/apps/backend/src/datasources/postgres/StatusDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusDataSource.ts @@ -2,6 +2,8 @@ import { GraphQLError } from 'graphql'; import { injectable } from 'tsyringe'; import { Status } from '../../models/Status'; +import { WorkflowType } from '../../models/Workflow'; +import { UpdateStatusInput } from '../../resolvers/mutations/settings/UpdateStatusMutation'; import { StatusDataSource } from '../StatusDataSource'; import database from './database'; import { StatusRecord } from './records'; @@ -59,7 +61,7 @@ export default class PostgresStatusDataSource implements StatusDataSource { return statuses.map((status) => this.createStatusObject(status)); } - async updateStatus(status: Omit): Promise { + async updateStatus(status: UpdateStatusInput): Promise { const [updatedStatus]: StatusRecord[] = await database .update( { @@ -104,4 +106,20 @@ export default class PostgresStatusDataSource implements StatusDataSource { return status ? this.createStatusObject(status) : null; } + + async getInitialStatus( + entityType: Status['entityType'] + ): Promise { + const shortCode = + entityType === WorkflowType.PROPOSAL ? 'DRAFT' : 'AWAITING_ESF'; + + const status: StatusRecord = await database + .select() + .from('statuses') + .where('entity_type', entityType) + .andWhere('short_code', shortCode) + .first(); + + return status ? this.createStatusObject(status) : null; + } } diff --git a/apps/backend/src/datasources/postgres/SystemDataSource.ts b/apps/backend/src/datasources/postgres/SystemDataSource.ts index 577351c4db..2cb5e951cf 100644 --- a/apps/backend/src/datasources/postgres/SystemDataSource.ts +++ b/apps/backend/src/datasources/postgres/SystemDataSource.ts @@ -7,7 +7,7 @@ export default class PostgresSystemDataSource implements SystemDataSource { async connectivityCheck(): Promise { try { return database - .raw('select * FROM "settings" LIMIT 1') + .raw('SELECT 1') .then(() => { return true; }) diff --git a/apps/backend/src/datasources/postgres/VisitDataSource.ts b/apps/backend/src/datasources/postgres/VisitDataSource.ts index df4213894c..653aff0da9 100644 --- a/apps/backend/src/datasources/postgres/VisitDataSource.ts +++ b/apps/backend/src/datasources/postgres/VisitDataSource.ts @@ -43,12 +43,17 @@ class PostgresVisitDataSource implements VisitDataSource { .then((visit) => (visit ? createVisitObject(visit) : null)); } - getRegistration(userId: number, visitId: number): Promise { + getRegistration( + userId: number, + visitId: number + ): Promise { return database('visits_has_users') .where({ visit_id: visitId }) .andWhere({ user_id: userId }) .first() - .then((registration) => createVisitRegistrationObject(registration)); + .then((registration) => + registration ? createVisitRegistrationObject(registration) : null + ); } getRegistrations( diff --git a/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts new file mode 100644 index 0000000000..4b48acc5cd --- /dev/null +++ b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts @@ -0,0 +1,50 @@ +import { injectable } from 'tsyringe'; + +import { VisitRegistrationClaim } from '../../models/VisitRegistrationClaim'; +import { VisitRegistrationClaimDataSource } from '../VisitRegistrationClaimDataSource'; +import database from './database'; +import { + VisitRegistrationClaimRecord, + createVisitRegistrationClaimObject, +} from './records'; + +@injectable() +export default class PostgresVisitRegistrationClaimDataSource + implements VisitRegistrationClaimDataSource +{ + async create( + inviteId: number, + visitId: number + ): Promise { + const records = await database('visit_registration_claims') + .insert( + { + invite_id: inviteId, + visit_id: visitId, + }, + '*' + ) + .onConflict(['invite_id', 'visit_id']) + .ignore(); + + if (records.length !== 1) { + throw new Error('Failed to create visit registration claim'); + } + + return createVisitRegistrationClaimObject( + records[0] as VisitRegistrationClaimRecord + ); + } + + async findByInviteId(inviteId: number): Promise { + const records = await database( + 'visit_registration_claims' + ) + .select('*') + .where('invite_id', inviteId); + + return records.map((record: VisitRegistrationClaimRecord) => + createVisitRegistrationClaimObject(record) + ); + } +} diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index 030c201391..e778ecc377 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -1,14 +1,17 @@ import { GraphQLError } from 'graphql'; -import { injectable } from 'tsyringe'; +import { inject, injectable } from 'tsyringe'; +import { Tokens } from '../../config/Tokens'; import { Status } from '../../models/Status'; import { StatusChangingEvent } from '../../models/StatusChangingEvent'; -import { Workflow, WorkflowType } from '../../models/Workflow'; +import { Workflow } from '../../models/Workflow'; import { WorkflowConnection, NextAndPreviousStatuses, WorkflowConnectionWithStatus, } from '../../models/WorkflowConnections'; +import { CreateWorkflowInput } from '../../resolvers/mutations/settings/CreateWorkflowMutation'; +import { UpdateWorkflowInput } from '../../resolvers/mutations/settings/UpdateWorkflowMutation'; import { WorkflowDataSource } from '../WorkflowDataSource'; import database from './database'; import { @@ -17,15 +20,21 @@ import { WorkflowConnectionRecord, WorkflowRecord, } from './records'; +import StatusDataSource from './StatusDataSource'; @injectable() export default class PostgresWorkflowDataSource implements WorkflowDataSource { + constructor( + @inject(Tokens.StatusDataSource) private statusDataSource: StatusDataSource + ) {} + private createWorkflowObject(workflow: WorkflowRecord) { return new Workflow( workflow.workflow_id, workflow.name, workflow.description, - workflow.entity_type + workflow.entity_type, + workflow.connection_line_type ); } @@ -39,8 +48,9 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { workflowConnection.status_id, workflowConnection.next_status_id, workflowConnection.prev_status_id, - workflowConnection.droppable_group_id, - workflowConnection.parent_droppable_group_id + workflowConnection.pos_x, + workflowConnection.pos_y, + workflowConnection.prev_connection_id ); } @@ -62,50 +72,25 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { }, workflowConnection.next_status_id, workflowConnection.prev_status_id, - workflowConnection.droppable_group_id, - workflowConnection.parent_droppable_group_id + workflowConnection.pos_x, + workflowConnection.pos_y, + workflowConnection.prev_connection_id ); } + async createWorkflow( - newWorkflowInput: Omit + newWorkflowInput: CreateWorkflowInput ): Promise { - let defaultStatusId: number | null = null; - let droppableGroupId: string | null = null; - - if (newWorkflowInput.entityType === WorkflowType.PROPOSAL) { - const defaultProposalStatus = await database() - .select('status_id') - .from('statuses') - .where('is_default', true) - .andWhere('entity_type', WorkflowType.PROPOSAL) - .first(); - - defaultStatusId = defaultProposalStatus?.status_id; - droppableGroupId = 'proposalWorkflowConnections_0'; - } else if (newWorkflowInput.entityType === WorkflowType.EXPERIMENT) { - const defaultExperimentStatus = await database() - .select('status_id') - .from('statuses') - .where('is_default', true) - .andWhere('entity_type', WorkflowType.EXPERIMENT) - .first(); - - defaultStatusId = defaultExperimentStatus?.status_id; - droppableGroupId = 'experimentWorkflowConnections_0'; - } + const initialStatus = await this.statusDataSource.getInitialStatus( + newWorkflowInput.entityType + ); - if (!defaultStatusId) { + if (!initialStatus) { throw new GraphQLError( `Could not find default status for ${newWorkflowInput.entityType}` ); } - if (!droppableGroupId) { - throw new GraphQLError( - `Could not find droppable group id for ${newWorkflowInput.entityType}` - ); - } - const [workflowRecord]: WorkflowRecord[] = await database .insert({ name: newWorkflowInput.name, @@ -116,17 +101,20 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { .returning('*'); if (!workflowRecord) { - throw new GraphQLError('Could not create status'); + throw new GraphQLError( + `Could not create workflow ${newWorkflowInput.name}` + ); } await this.addWorkflowStatus({ sortOrder: 0, - droppableGroupId: droppableGroupId, nextStatusId: null, prevStatusId: null, - parentDroppableGroupId: null, - statusId: defaultStatusId, + statusId: initialStatus.id, workflowId: workflowRecord.workflow_id, + posX: 0, + posY: 0, + prevConnectionId: null, }); return this.createWorkflowObject(workflowRecord); @@ -153,15 +141,14 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { return workflows.map((workflow) => this.createWorkflowObject(workflow)); }); } - async updateWorkflow( - workflow: Omit - ): Promise { + async updateWorkflow(workflow: UpdateWorkflowInput): Promise { return database .update( { workflow_id: workflow.id, name: workflow.name, description: workflow.description, + connection_line_type: workflow.connectionLineType, }, ['*'] ) @@ -191,35 +178,19 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { }); } async getWorkflowConnections( - workflowId: WorkflowConnection['workflowId'], - droppableGroupId?: WorkflowConnection['droppableGroupId'], - byParentGroupId?: boolean | undefined + workflowId: WorkflowConnection['workflowId'] ): Promise { - const andConditionIfDroppableGroupIdDefined = `AND droppable_group_id = '${droppableGroupId}'`; - const andConditionIfParentDroppableGroupIdDefined = - byParentGroupId && - !!droppableGroupId && - `AND parent_droppable_group_id = '${droppableGroupId}'`; - const andWhereCondition = - !byParentGroupId && !!droppableGroupId - ? andConditionIfDroppableGroupIdDefined - : byParentGroupId && !!droppableGroupId - ? andConditionIfParentDroppableGroupIdDefined - : ''; - const getUniqueOrderedWorkflowConnectionsQuery = ` SELECT * FROM ( - SELECT DISTINCT ON (wc.status_id, wc.sort_order, wc.droppable_group_id) * + SELECT * FROM workflow_connections as wc LEFT JOIN statuses as s ON s.status_id = wc.status_id WHERE workflow_id = ${workflowId} - ${andWhereCondition} ) t ORDER BY - droppable_group_id ASC, sort_order ASC `; @@ -234,9 +205,28 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ) : []; } + + async getWorkflowConnection( + connectionId: WorkflowConnection['id'] + ): Promise { + const query = ` + SELECT wc.*, s.* + FROM workflow_connections as wc + LEFT JOIN statuses as s ON s.status_id = wc.status_id + WHERE wc.workflow_connection_id = ? + `; + + const result = await database.raw(query, [connectionId]); + const workflowConnection: (WorkflowConnectionRecord & StatusRecord) | null = + result.rows[0] || null; + + return workflowConnection + ? this.createWorkflowConnectionWithStatusObject(workflowConnection) + : null; + } async getWorkflowConnectionsById( workflowId: WorkflowConnection['workflowId'], - statusId: Status['id'], + statusId: Status['id'] | undefined, { nextStatusId, prevStatusId, sortOrder }: NextAndPreviousStatuses ): Promise { const workflowConnectionRecords: (WorkflowConnectionRecord & @@ -247,8 +237,11 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { 's.status_id': 'wc.status_id', }) .where('workflow_id', workflowId) - .andWhere('wc.status_id', statusId) .modify((query) => { + if (statusId) { + query.andWhere('wc.status_id', statusId); + } + if (nextStatusId) { query.andWhere('wc.next_status_id', nextStatusId); } @@ -293,9 +286,9 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { next_status_id: newWorkflowStatusInput.nextStatusId, prev_status_id: newWorkflowStatusInput.prevStatusId, sort_order: newWorkflowStatusInput.sortOrder, - droppable_group_id: newWorkflowStatusInput.droppableGroupId, - parent_droppable_group_id: - newWorkflowStatusInput.parentDroppableGroupId, + pos_x: newWorkflowStatusInput.posX, + pos_y: newWorkflowStatusInput.posY, + prev_connection_id: newWorkflowStatusInput.prevConnectionId, }) .into('workflow_connections as wc') .returning('*') @@ -311,50 +304,64 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ); } - async upsertWorkflowStatuses(connections: WorkflowConnection[]) { - const dataToInsert = connections.map((connection) => ({ - workflow_connection_id: connection.id, - workflow_id: connection.workflowId, - status_id: connection.statusId, - next_status_id: connection.nextStatusId, - prev_status_id: connection.prevStatusId, - sort_order: connection.sortOrder, - droppable_group_id: connection.droppableGroupId, - parent_droppable_group_id: connection.parentDroppableGroupId, - })); - + async updateWorkflowStatus(connection: WorkflowConnection) { const result = await database.raw( - `? ON CONFLICT (workflow_connection_id) - DO UPDATE SET - status_id = EXCLUDED.status_id, - next_status_id = EXCLUDED.next_status_id, - prev_status_id = EXCLUDED.prev_status_id, - sort_order = EXCLUDED.sort_order, - droppable_group_id = EXCLUDED.droppable_group_id, - parent_droppable_group_id = EXCLUDED.parent_droppable_group_id - RETURNING *;`, - [database('workflow_connections').insert(dataToInsert)] + `WITH updated AS ( + INSERT INTO workflow_connections ( + workflow_connection_id, workflow_id, status_id, next_status_id, + prev_status_id, sort_order, pos_x, pos_y, prev_connection_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (workflow_connection_id) + DO UPDATE SET + status_id = EXCLUDED.status_id, + next_status_id = EXCLUDED.next_status_id, + prev_status_id = EXCLUDED.prev_status_id, + sort_order = EXCLUDED.sort_order, + pos_x = EXCLUDED.pos_x, + pos_y = EXCLUDED.pos_y, + prev_connection_id = EXCLUDED.prev_connection_id + RETURNING * + ) + SELECT wc.*, s.* + FROM updated wc + LEFT JOIN statuses s ON s.status_id = wc.status_id;`, + [ + connection.id, + connection.workflowId, + connection.statusId, + connection.nextStatusId, + connection.prevStatusId, + connection.sortOrder, + connection.posX, + connection.posY, + connection.prevConnectionId, + ] ); - return result.rows as WorkflowConnectionRecord[]; + if (!result.rows[0]) { + throw new GraphQLError('Could not update workflow status'); + } + + return this.createWorkflowConnectionWithStatusObject(result.rows[0]); } - async updateWorkflowStatuses( - workflowStatuses: WorkflowConnection[] - ): Promise { - const connectionsResult = - await this.upsertWorkflowStatuses(workflowStatuses); - if (connectionsResult) { - // NOTE: Return result as WorkflowConnection[] but do not care about name and description. - return connectionsResult.map((connection) => - this.createWorkflowConnectionObject({ - ...connection, - }) - ); - } else { - return []; + async deleteWorkflowConnection( + connectionId: number + ): Promise { + const deletedConnection: WorkflowConnectionRecord[] = await database( + 'workflow_connections' + ) + .where('workflow_connection_id', connectionId) + .del() + .returning('*'); + + if (deletedConnection.length === 0) { + return null; } + + return this.createWorkflowConnectionObject(deletedConnection[0]); } + async deleteWorkflowStatus( statusId: number, workflowId: number, diff --git a/apps/backend/src/datasources/postgres/databaseExtensions.ts b/apps/backend/src/datasources/postgres/databaseExtensions.ts index b4c3d8990c..2957a1ebc9 100644 --- a/apps/backend/src/datasources/postgres/databaseExtensions.ts +++ b/apps/backend/src/datasources/postgres/databaseExtensions.ts @@ -3,6 +3,10 @@ import Knex from 'knex'; const escapeLike = (s: string) => s.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&'); +function safeJsonPath(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\"]/g, '\\$&').trim(); +} + const addExtensions = () => { // Add the custom methods Knex.QueryBuilder.extend( @@ -23,6 +27,24 @@ const addExtensions = () => { return this.orWhereILike(column, finalQuery); } ); + Knex.QueryBuilder.extend( + 'whereJsonFieldLikeEscaped', + function (column: string, field: string, userInput: string) { + const escapedInput = safeJsonPath(userInput); + const jsonPath = `$[*].${field}? (@ like_regex "${escapedInput}" flag "i")`; + + return this.whereRaw('jsonb_path_exists(??, ?)', [column, jsonPath]); + } + ); + Knex.QueryBuilder.extend( + 'orWhereJsonFieldLikeEscaped', + function (column: string, field: string, userInput: string) { + const escapedInput = safeJsonPath(userInput); + const jsonPath = `$[*].${field}? (@ like_regex "${escapedInput}" flag "i")`; + + return this.orWhereRaw('jsonb_path_exists(??, ?)', [column, jsonPath]); + } + ); }; export default addExtensions; diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index d8f8b799f9..7927e0770d 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -66,6 +66,7 @@ import { VisitRegistration, VisitRegistrationStatus, } from '../../models/VisitRegistration'; +import { VisitRegistrationClaim } from '../../models/VisitRegistrationClaim'; import { WorkflowType } from '../../models/Workflow'; import { FapInstrument, @@ -275,6 +276,11 @@ export interface VisitRegistrationRecord { status: string; } +export interface VisitRegistrationClaimRecord { + readonly invite_id: number; + readonly visit_id: number; +} + export interface RoleRecord { readonly role_id: number; readonly short_code: string; @@ -287,7 +293,7 @@ export interface ReviewRecord { readonly user_id: number; readonly proposal_pk: number; readonly comment: string; - readonly grade: number; + readonly grade: string; readonly status: number; readonly fap_id: number; readonly questionary_id: number; @@ -591,6 +597,7 @@ export interface WorkflowRecord { readonly description: string; readonly full_count: number; readonly entity_type: WorkflowType; + readonly connection_line_type: string; } export interface WorkflowConnectionRecord { @@ -600,8 +607,9 @@ export interface WorkflowConnectionRecord { readonly status_id: number; readonly next_status_id: number | null; readonly prev_status_id: number | null; - readonly droppable_group_id: string; - readonly parent_droppable_group_id: string; + readonly pos_x: number; + readonly pos_y: number; + readonly prev_connection_id: number | null; } export interface StatusChangingEventRecord { @@ -1212,6 +1220,15 @@ export const createVisitObject = (visit: VisitRecord) => { ); }; +export const createVisitRegistrationClaimObject = ( + visitRegistrationClaim: VisitRegistrationClaimRecord +) => { + return new VisitRegistrationClaim( + visitRegistrationClaim.invite_id, + visitRegistrationClaim.visit_id + ); +}; + export const createGenericTemplateObject = ( genericTemplate: GenericTemplateRecord ) => { diff --git a/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts b/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts index 9e1e62c369..0303aa7b6e 100644 --- a/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts @@ -18,13 +18,14 @@ import { createProposalViewObject, ProposalViewRecord, } from '../postgres/records'; +import PostgresStatusDataSource from '../postgres/StatusDataSource'; import PostgresWorkflowDataSource from '../postgres/WorkflowDataSource'; import { ProposalsFilter } from './../../resolvers/queries/ProposalsQuery'; import PostgresProposalDataSource from './../postgres/ProposalDataSource'; import { StfcUserDataSource } from './StfcUserDataSource'; const postgresProposalDataSource = new PostgresProposalDataSource( - new PostgresWorkflowDataSource(), + new PostgresWorkflowDataSource(new PostgresStatusDataSource()), new PostgresAdminDataSource() ); @@ -128,10 +129,10 @@ export default class StfcProposalDataSource extends PostgresProposalDataSource { .orWhere('users.firstname', 'ilike', `%${filter.text}%`) .orWhere('users.lastname', 'ilike', `%${filter.text}%`) .orWhere('principal_investigator', 'in', stfcUserIds) - // NOTE: Using jsonpath we check the jsonb (instruments) field if it contains object with name equal to searchText case insensitive - .orWhereRaw( - 'jsonb_path_exists(instruments, \'$[*].name \\? (@.type() == "string" && @ like_regex :searchText: flag "i")\')', - { searchText: filter.text } + .orWhereJsonFieldLikeEscaped( + 'instruments', + 'name', + `${filter.text}` ); }); } diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index a785f61f2d..f5f93dd1a8 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -131,7 +131,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { ).then(async () => { await eventBus.publish({ ...event, - type: Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, invite, }); }); diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts index 18ca13c165..95dca29597 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts @@ -5,10 +5,14 @@ import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { CoProposerClaimDataSourceMock } from '../../datasources/mockups/CoProposerClaimDataSource'; -import { ProposalDataSourceMock } from '../../datasources/mockups/ProposalDataSource'; import { - UserDataSourceMock, + ProposalDataSourceMock, + dummyProposal, +} from '../../datasources/mockups/ProposalDataSource'; +import { + basicDummyUser, dummyUser, + UserDataSourceMock, } from '../../datasources/mockups/UserDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; @@ -20,7 +24,7 @@ const mockMailService = { sendMail: jest.fn(), }; -describe('essEmailHandler', () => { +describe('essEmailHandler co-proposer invites', () => { let proposalDataSourceMock: ProposalDataSourceMock; let coProposerDataSourceMock: CoProposerClaimDataSourceMock; let userDataSourceMock: UserDataSourceMock; @@ -94,6 +98,56 @@ describe('essEmailHandler', () => { }); }); + test('mailService should be invoked when PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED event is sent', async () => { + const inviteEmail = faker.internet.email(); + const inviterId = 1; + const inviteId = 123; + const redeemCode = faker.string.alphanumeric(10); + + // Mock UserDataSource.getBasicUserInfo to return a dummy inviter + const userDataSourceMock = container.resolve( + Tokens.UserDataSource + ); + jest.spyOn(userDataSourceMock, 'getBasicUserInfo').mockResolvedValue({ + id: inviterId, + firstname: 'Inviter', + lastname: 'User', + institution: 'TestOrg', + email: 'inviter@email.com', + } as any); + + const mockEvent = { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, + array: [ + { + id: inviteId, + email: inviteEmail, + code: redeemCode, + createdByUserId: inviterId, + isEmailSent: false, + }, + ], + isRejection: false, + } as ApplicationEvent; + + await essEmailHandler(mockEvent); + + expect(mockMailService.sendMail).toHaveBeenCalledWith({ + content: { + template_id: + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, + }, + substitution_data: { + email: inviteEmail, + inviterName: 'Inviter', + inviterLastname: 'User', + inviterOrg: 'TestOrg', + redeemCode: redeemCode, + }, + recipients: [{ address: inviteEmail }], + }); + }); + it('should log error when proposal is not found', async () => { const mockInvite = new Invite( 1, @@ -222,4 +276,35 @@ describe('essEmailHandler', () => { }) ); }); + + describe('handling PROPOSAL_SUBMITTED event', () => { + it('Should have PI and CoProposals in the payload', async () => { + const event: ApplicationEvent = { + type: Event.PROPOSAL_SUBMITTED, + proposal: dummyProposal, + key: 'proposal', + loggedInUserId: 1, + isRejection: false, + }; + + const sendMailsSpy = jest.spyOn(mockMailService, 'sendMail'); + + await essEmailHandler(event); + + expect(sendMailsSpy).toHaveBeenCalledTimes(1); + const arg = sendMailsSpy.mock.calls[0][0]; + expect(arg.content.template_id).toBe(EmailTemplateId.PROPOSAL_SUBMITTED); + + // Recipients: first is PI, rest are co-proposers with header_to pointing to PI + expect(arg.recipients).toEqual([ + { address: dummyUser.email }, + { + address: { + email: basicDummyUser.email, + header_to: dummyUser.email, + }, + }, + ]); + }); + }); }); diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index 488616b2ed..9d1b64cdbe 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -17,6 +17,7 @@ import { ProposalEndStatus } from '../../models/Proposal'; import { BasicUserDetails } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; + export enum EmailTemplateId { CO_PROPOSER_INVITE_ACCEPTED = 'co-proposer-invite-accepted', PROPOSAL_SUBMITTED = 'proposal-submitted', @@ -158,11 +159,7 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } - const participants = await userDataSource.getProposalUsersFull( - event.proposal.primaryKey - ); - - const invites = await inviteDataSource.findCoProposerInvites( + const participants = await userDataSource.getProposalUsers( event.proposal.primaryKey ); @@ -192,14 +189,6 @@ export async function essEmailHandler(event: ApplicationEvent) { }, }; }), - ...invites.map((invite) => { - return { - address: { - email: invite.email, - header_to: principalInvestigator.email, - }, - }; - }), ], }; @@ -282,6 +271,42 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } + case Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED: { + const invites = event.array; + + for (const invite of invites) { + if (invite.isEmailSent) { + continue; + } + const inviter = await userDataSource.getBasicUserInfo( + invite.createdByUserId + ); + + if (!inviter) { + logger.logError('No inviter found when trying to send email', { + inviter, + event, + }); + + return; + } + + await sendInviteEmail( + invite, + inviter, + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION + ).then(async () => { + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, + description: 'Visit registration invite sent', + invite, + }); + }); + } + break; + } + case Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED: { const invites = event.array; @@ -309,7 +334,7 @@ export async function essEmailHandler(event: ApplicationEvent) { ).then(async () => { await eventBus.publish({ ...event, - type: Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, invite, }); }); diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index f410b55ab3..3d193a76b6 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -69,13 +69,13 @@ export default function createLoggingHandler() { event.type, json, proposalPKey.toString(), - `Co-proposer invite issued to ${invite.email} accepted by userId: ${event.loggedInUserId}`, + `Co-proposer invite issued to ${invite.email} accepted by userId ${event.loggedInUserId}`, event.impersonatingUserId ); break; } - case Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT: { + case Event.PROPOSAL_CO_PROPOSER_INVITE_SENT: { const { invite, proposalPKey } = event; eventLogsDataSource.set( @@ -83,12 +83,41 @@ export default function createLoggingHandler() { event.type, json, proposalPKey.toString(), - `Co-proposer invite sent to: ${invite.email} by userId: ${event.loggedInUserId}`, + `Co-proposer invite issued to ${invite.email} by userId ${event.loggedInUserId}`, event.impersonatingUserId ); break; } + case Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT: { + const { invite, proposalPKey: proposalPk } = event; + + eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + proposalPk.toString(), + `Visit invite issued to ${invite.email} by userId ${event.loggedInUserId}`, + event.impersonatingUserId + ); + + break; + } + case Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED: { + const { invite, proposalPKey } = event; + + await eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + proposalPKey.toString(), + `Visit invite sent to ${invite.email} accepted by userId ${event.loggedInUserId}`, + event.impersonatingUserId + ); + + break; + } + case Event.PROPOSAL_INSTRUMENTS_SELECTED: { await Promise.all( event.instrumentshasproposals.proposalPks.map( @@ -359,6 +388,19 @@ export default function createLoggingHandler() { } } break; + case Event.DATA_ACCESS_USERS_UPDATED: + { + const description = 'Data access users updated'; + await eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + event.proposalPKey.toString(), + description, + event.impersonatingUserId + ); + } + break; default: { let changedObjectId: number; if (typeof (event as any)[event.key].primaryKey === 'number') { @@ -372,6 +414,10 @@ export default function createLoggingHandler() { } else { changedObjectId = (event as any)[event.key].id; } + + if (!changedObjectId) { + return; + } const description = event.description || ''; await eventLogsDataSource.set( diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index ce07927119..cfbd7f0048 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -8,6 +8,10 @@ import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; import { CoProposerClaimDataSource } from '../datasources/CoProposerClaimDataSource'; +import { + DataAccessUsersDataSource, + UserWithInstitution, +} from '../datasources/DataAccessUsersDataSource'; import { ExperimentDataSource } from '../datasources/ExperimentDataSource'; import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; @@ -56,6 +60,7 @@ type ProposalMessageData = { callId: number; instruments?: { id: number; shortCode: string; allocatedTime: number }[]; members: Member[]; + dataAccessUsers: Member[]; newStatus?: string; proposalPk: number; proposer?: Member; @@ -104,6 +109,11 @@ export const getProposalMessageData = async (proposal: Proposal) => { Tokens.UserDataSource ); + const dataAccessUsersDataSource = + container.resolve( + Tokens.DataAccessUsersDataSource + ); + const statusDataSource = container.resolve( Tokens.StatusDataSource ); @@ -119,6 +129,10 @@ export const getProposalMessageData = async (proposal: Proposal) => { const proposalUsersWithInstitution = await userDataSource.getProposalUsersWithInstitution(proposal.primaryKey); + const dataAccessUsersWithInstitution = + await dataAccessUsersDataSource.getDataAccessUsersWithInstitution( + proposal.primaryKey + ); const maybeInstruments = await instrumentDataSource.getInstrumentsByProposalPk(proposal.primaryKey); @@ -138,6 +152,20 @@ export const getProposalMessageData = async (proposal: Proposal) => { })) : undefined; + // Helper function to map user with institution to Member + const mapUserWithInstitutionToMember = ( + userWithInstitution: UserWithInstitution + ): Member => ({ + firstName: userWithInstitution.user.firstname, + lastName: userWithInstitution.user.lastname, + email: userWithInstitution.user.email, + id: userWithInstitution.user.id.toString(), + oidcSub: userWithInstitution.user.oidcSub, + oauthIssuer: userWithInstitution.user.oauthIssuer, + institution: userWithInstitution.institution, + country: userWithInstitution.country, + }); + const messageData: ProposalMessageData = { proposalPk: proposal.primaryKey, shortCode: proposal.proposalId, @@ -145,17 +173,9 @@ export const getProposalMessageData = async (proposal: Proposal) => { title: proposal.title, abstract: proposal.abstract, callId: call.id, - members: proposalUsersWithInstitution.map( - (proposalUserWithInstitution) => ({ - firstName: proposalUserWithInstitution.user.firstname, - lastName: proposalUserWithInstitution.user.lastname, - email: proposalUserWithInstitution.user.email, - id: proposalUserWithInstitution.user.id.toString(), - oidcSub: proposalUserWithInstitution.user.oidcSub, - oauthIssuer: proposalUserWithInstitution.user.oauthIssuer, - institution: proposalUserWithInstitution.institution, - country: proposalUserWithInstitution.country, - }) + members: proposalUsersWithInstitution.map(mapUserWithInstitutionToMember), + dataAccessUsers: dataAccessUsersWithInstitution.map( + mapUserWithInstitutionToMember ), newStatus: proposalStatus?.shortCode, submitted: proposal.submitted, @@ -164,16 +184,9 @@ export const getProposalMessageData = async (proposal: Proposal) => { proposal.proposerId ); if (proposerWithInstitution) { - messageData.proposer = { - firstName: proposerWithInstitution.user.firstname, - lastName: proposerWithInstitution.user.lastname, - email: proposerWithInstitution.user.email, - id: proposerWithInstitution.user.id.toString(), - oidcSub: proposerWithInstitution.user.oidcSub, - oauthIssuer: proposerWithInstitution.user.oauthIssuer, - institution: proposerWithInstitution.institution, - country: proposerWithInstitution.country, - }; + messageData.proposer = mapUserWithInstitutionToMember( + proposerWithInstitution + ); } return JSON.stringify(messageData); @@ -369,6 +382,19 @@ export async function createPostToRabbitMQHandler() { ); break; } + case Event.DATA_ACCESS_USERS_UPDATED: { + const { proposalPKey } = event; + + const proposal = await proposalDataSource.get(proposalPKey); + + const jsonMessage = await getProposalMessageData(proposal!); + await rabbitMQ.sendMessageToExchange( + EXCHANGE_NAME, + Event.PROPOSAL_UPDATED, + jsonMessage + ); + break; + } } }; } diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index af2bdbe021..2bff444905 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -12,6 +12,7 @@ import { Sample } from '../models/Sample'; import { TechnicalReview } from '../models/TechnicalReview'; import { Technique } from '../models/Technique'; import { User, UserRole } from '../models/User'; +import { Visit } from '../models/Visit'; import { VisitRegistration } from '../models/VisitRegistration'; import { Event } from './event.enum'; @@ -224,7 +225,7 @@ interface ProposalCoProposerInvitesUpdatedEvent extends GeneralEvent { } interface ProposalCoProposerInviteSentEvent extends GeneralEvent { - type: Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT; + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT; invite: Invite; proposalPKey: number; } @@ -235,6 +236,24 @@ interface ProposalCoProposerInviteAcceptedEvent extends GeneralEvent { proposalPKey: number; } +interface ProposalVisitRegistrationInvitesUpdatedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED; + array: Invite[]; + proposalPKey: number; +} + +interface ProposalVisitRegistrationInviteSentEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT; + invite: Invite; + proposalPKey: number; +} + +interface ProposalVisitRegistrationInviteAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED; + invite: Invite; + proposalPKey: number; +} + interface UserUpdateEvent extends GeneralEvent { type: Event.USER_UPDATED; user: User; @@ -381,6 +400,10 @@ interface InternalReviewDeleted extends GeneralEvent { internalreview: InternalReview; } +interface VisitCreatedEvent extends GeneralEvent { + type: Event.VISIT_CREATED; + visit: Visit; +} interface VisitRegistrationApprovedEvent extends GeneralEvent { type: Event.VISIT_REGISTRATION_APPROVED; visitregistration: VisitRegistration; @@ -391,6 +414,11 @@ interface VisitRegistrationCancelledEvent extends GeneralEvent { visitregistration: VisitRegistration; } +interface UserDataAccessUpdatedEvent extends GeneralEvent { + type: Event.DATA_ACCESS_USERS_UPDATED; + proposalPKey: number; +} + interface ExperimentSafetyManagementDecisionSubmittedByISEvent extends GeneralEvent { type: Event.EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_IS; @@ -489,6 +517,9 @@ export type ApplicationEvent = | ProposalCoProposerInvitesUpdatedEvent | ProposalCoProposerInviteSentEvent | ProposalCoProposerInviteAcceptedEvent + | ProposalVisitRegistrationInvitesUpdatedEvent + | ProposalVisitRegistrationInviteSentEvent + | ProposalVisitRegistrationInviteAcceptedEvent | InstrumentCreatedEvent | InstrumentUpdatedEvent | InstrumentDeletedEvent @@ -503,8 +534,10 @@ export type ApplicationEvent = | InternalReviewCreated | InternalReviewUpdated | InternalReviewDeleted + | VisitCreatedEvent | VisitRegistrationApprovedEvent | VisitRegistrationCancelledEvent + | UserDataAccessUpdatedEvent | ExperimentESFApprovedByIsEvent | ExperimentESFApprovedByESREvent | ExperimentESFRejectedByIsEvent diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 94d659fb6b..e98a24165e 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -38,7 +38,6 @@ export enum Event { CALL_ENDED_INTERNAL = 'CALL_ENDED_INTERNAL', CALL_REVIEW_ENDED = 'CALL_REVIEW_ENDED', CALL_FAP_REVIEW_ENDED = 'CALL_FAP_REVIEW_ENDED', - INVITE_ACCEPTED = 'INVITE_ACCEPTED', USER_UPDATED = 'USER_UPDATED', USER_ROLE_UPDATED = 'USER_ROLE_UPDATED', USER_DELETED = 'USER_DELETED', @@ -65,8 +64,11 @@ export enum Event { PROPOSAL_BOOKING_TIME_UPDATED = 'PROPOSAL_BOOKING_TIME_UPDATED', PROPOSAL_BOOKING_TIME_REOPENED = 'PROPOSAL_BOOKING_TIME_REOPENED', PROPOSAL_CO_PROPOSER_INVITES_UPDATED = 'PROPOSAL_CO_PROPOSER_INVITES_UPDATED', - PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT = 'PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT', + PROPOSAL_CO_PROPOSER_INVITE_SENT = 'PROPOSAL_CO_PROPOSER_INVITE_SENT', PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED = 'PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED', + PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED = 'PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED', + PROPOSAL_VISIT_REGISTRATION_INVITE_SENT = 'PROPOSAL_VISIT_REGISTRATION_INVITE_SENT', + PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED = 'PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED', INSTRUMENT_CREATED = 'INSTRUMENT_CREATED', INSTRUMENT_UPDATED = 'INSTRUMENT_UPDATED', INSTRUMENT_DELETED = 'INSTRUMENT_DELETED', @@ -90,10 +92,12 @@ export enum Event { EXPERIMENT_ESF_REJECTED_BY_IS = 'EXPERIMENT_ESF_REJECTED_BY_IS', EXPERIMENT_ESF_APPROVED_BY_ESR = 'EXPERIMENT_ESF_APPROVED_BY_ESR', EXPERIMENT_ESF_REJECTED_BY_ESR = 'EXPERIMENT_ESF_REJECTED_BY_ESR', + DATA_ACCESS_USERS_UPDATED = 'DATA_ACCESS_USERS_UPDATED', EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_IS = 'EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_IS', EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_ESR = 'EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_ESR', EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER = 'EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER', EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW = 'EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW', + VISIT_CREATED = 'VISIT_CREATED', } export const EventLabel = new Map([ @@ -273,9 +277,25 @@ export const EventLabel = new Map([ 'Event occurs when co-proposer invites are updated for a proposal', ], [ - Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT, + Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, 'Event occurs when co-proposer invite is sent to a user', ], + [ + Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, + 'Event occurs when user accepts the co-proposer claim for a proposal', + ], + [ + Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, + 'Event occurs when visit registration invites are updated for a proposal', + ], + [ + Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, + 'Event occurs when visit registration invite is sent to a user', + ], + [ + Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED, + 'Event occurs when user accepts the visit registration claim for a proposal', + ], [ Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, 'Event occurs when the proposal status was changed by the workflow engine', @@ -352,6 +372,7 @@ export const EventLabel = new Map([ Event.PROPOSAL_ASSIGNED_TO_TECHNIQUES, 'Event occurs when a proposal is assigned to techniques', ], + [Event.VISIT_CREATED, 'Event occurs when visit is created'], [ Event.VISIT_REGISTRATION_APPROVED, 'Event occurs when visit registration is approved', @@ -380,6 +401,10 @@ export const EventLabel = new Map([ Event.EXPERIMENT_ESF_REJECTED_BY_ESR, 'Event occurs when experiment ESF is rejected by ESR', ], + [ + Event.DATA_ACCESS_USERS_UPDATED, + 'Event occurs when data access users are updated', + ], [ Event.EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_IS, 'Event occurs when experiment safety management decision is submitted by IS', diff --git a/apps/backend/src/factory/pdf/proposal.ts b/apps/backend/src/factory/pdf/proposal.ts index f0bd577c88..461fd9c225 100644 --- a/apps/backend/src/factory/pdf/proposal.ts +++ b/apps/backend/src/factory/pdf/proposal.ts @@ -21,6 +21,7 @@ import { getTopicActiveAnswers } from '../../models/ProposalModelFunctions'; import { Answer, QuestionaryStep } from '../../models/Questionary'; import { isRejection } from '../../models/Rejection'; import { Review } from '../../models/Review'; +import { Roles } from '../../models/Role'; import { Sample } from '../../models/Sample'; import { TechnicalReview, @@ -259,6 +260,9 @@ export const collectProposalPDFData = async ( if (proposal === null) { throw new Error('Proposal not found'); } + if (!proposal.submitted && user.currentRole?.shortCode == Roles.USER) { + throw new Error('PDF cannot be downloaded pre submission '); + } const call = await baseContext.queries.call.get(user, proposal.callId); diff --git a/apps/backend/src/middlewares/readinessCheck.ts b/apps/backend/src/middlewares/readinessCheck.ts index b8e39e296e..037aeae3bc 100644 --- a/apps/backend/src/middlewares/readinessCheck.ts +++ b/apps/backend/src/middlewares/readinessCheck.ts @@ -5,21 +5,30 @@ import baseContext from '../buildContext'; const router = express.Router(); -router.get('/readiness', (req: Request, res: Response) => { +router.get('/readiness', async (req: Request, res: Response) => { try { - baseContext.queries.system - .connectivityCheck() - .then((success) => { - success ? res.status(200) : res.status(500); - res.end(); - }) - .catch((e) => { - logger.logException('Readiness check failed', e); - res.status(500).end(); - }); + const success = await baseContext.queries.system.connectivityCheck(); + const responseStatus = success ? 200 : 503; + res.status(responseStatus).json({ + application: { + status: 'UP', + database: { + status: success ? 'UP' : 'DOWN', + message: success ? 'Connected' : 'Not connected', + }, + }, + }); } catch (e) { logger.logException('Readiness check failed', e); - res.status(500).end(); + res.status(500).json({ + application: { + status: 'DOWN', + database: { + status: 'DOWN', + message: 'Could not perform readiness check', + }, + }, + }); } }); diff --git a/apps/backend/src/models/Review.ts b/apps/backend/src/models/Review.ts index 0fdc250569..d06e827e4e 100644 --- a/apps/backend/src/models/Review.ts +++ b/apps/backend/src/models/Review.ts @@ -4,7 +4,7 @@ export class Review { public proposalPk: number, public userID: number, public comment: string, - public grade: number, + public grade: string, public status: number, public fapID: number, public questionaryID: number, diff --git a/apps/backend/src/models/Settings.ts b/apps/backend/src/models/Settings.ts index e6b92ef744..d4b8e2412a 100644 --- a/apps/backend/src/models/Settings.ts +++ b/apps/backend/src/models/Settings.ts @@ -34,7 +34,6 @@ export enum SettingsId { DEFAULT_INST_SCI_STATUS_FILTER = 'DEFAULT_INST_SCI_STATUS_FILTER', DEFAULT_INST_SCI_REVIEWER_FILTER = 'DEFAULT_INST_SCI_REVIEWER_FILTER', IDLE_TIMEOUT = 'IDLE_TIMEOUT', - GRADE_PRECISION = 'GRADE_PRECISION', USER_OFFICE_EMAIL = 'USER_OFFICE_EMAIL', SMTP_BCC_EMAIL = 'SMTP_BCC_EMAIL', TECH_REVIEW_OPTIONAL_WORKFLOW_STATUS = 'TECH_REVIEW_OPTIONAL_WORKFLOW_STATUS', diff --git a/apps/backend/src/models/VisitRegistrationClaim.ts b/apps/backend/src/models/VisitRegistrationClaim.ts new file mode 100644 index 0000000000..5916f3c1a9 --- /dev/null +++ b/apps/backend/src/models/VisitRegistrationClaim.ts @@ -0,0 +1,6 @@ +export class VisitRegistrationClaim { + constructor( + public inviteId: number, + public visitId: number + ) {} +} diff --git a/apps/backend/src/models/Workflow.ts b/apps/backend/src/models/Workflow.ts index 295ac83055..8827b0e244 100644 --- a/apps/backend/src/models/Workflow.ts +++ b/apps/backend/src/models/Workflow.ts @@ -8,6 +8,7 @@ export class Workflow { public id: number, public name: string, public description: string, - public entityType: WorkflowType + public entityType: WorkflowType, + public connectionLineType: string ) {} } diff --git a/apps/backend/src/models/WorkflowConnections.ts b/apps/backend/src/models/WorkflowConnections.ts index 150d946e62..450a2efebb 100644 --- a/apps/backend/src/models/WorkflowConnections.ts +++ b/apps/backend/src/models/WorkflowConnections.ts @@ -14,8 +14,9 @@ export class WorkflowConnection { public statusId: number, public nextStatusId: number | null, public prevStatusId: number | null, - public droppableGroupId: string, - public parentDroppableGroupId: string | null + public posX: number, + public posY: number, + public prevConnectionId: number | null ) {} } @@ -28,7 +29,8 @@ export class WorkflowConnectionWithStatus { public status: Status, public nextStatusId: number | null, public prevStatusId: number | null, - public droppableGroupId: string, - public parentDroppableGroupId: string | null + public posX: number, + public posY: number, + public prevConnectionId: number | null ) {} } diff --git a/apps/backend/src/models/questionTypes/FapReviewBasis.ts b/apps/backend/src/models/questionTypes/FapReviewBasis.ts index e67cea2b73..f3ef0fdbcd 100644 --- a/apps/backend/src/models/questionTypes/FapReviewBasis.ts +++ b/apps/backend/src/models/questionTypes/FapReviewBasis.ts @@ -5,7 +5,13 @@ import { Question } from './QuestionRegistry'; export const fapReviewBasisDefinition: Question = { dataType: DataType.FAP_REVIEW_BASIS, createBlankConfig: (): FapReviewBasisConfig => { - return new FapReviewBasisConfig(); + const config = new FapReviewBasisConfig(); + config.minGrade = 1; + config.maxGrade = 10; + config.decimalPoints = 0; + config.nonNumericOptions = []; + + return config; }, getDefaultAnswer: () => null, }; diff --git a/apps/backend/src/models/questionTypes/Interval.ts b/apps/backend/src/models/questionTypes/Interval.ts index 9645c78309..0ac4dd8ac1 100644 --- a/apps/backend/src/models/questionTypes/Interval.ts +++ b/apps/backend/src/models/questionTypes/Interval.ts @@ -4,7 +4,10 @@ import { logger } from '@user-office-software/duo-logger'; import { intervalQuestionValidationSchema } from '@user-office-software/duo-validation'; import { GraphQLError } from 'graphql'; -import { IntervalConfig } from '../../resolvers/types/FieldConfig'; +import { + IntervalConfig, + NumberValueConstraint, +} from '../../resolvers/types/FieldConfig'; import { isSiConversionFormulaValid } from '../../utils/isSiConversionFormulaValid'; import { QuestionFilterCompareOperator } from '../Questionary'; import { DataType, QuestionTemplateRelation } from '../Template'; @@ -28,7 +31,10 @@ export const intervalDefinition: Question = { throw new GraphQLError('DataType should be INTERVAL'); } - return intervalQuestionValidationSchema(field).isValid(value); + return intervalQuestionValidationSchema( + field, + NumberValueConstraint + ).isValid(value); }, createBlankConfig: (): IntervalConfig => { const config = new IntervalConfig(); diff --git a/apps/backend/src/mutations/DataAccessUsersMutations.spec.ts b/apps/backend/src/mutations/DataAccessUsersMutations.spec.ts new file mode 100644 index 0000000000..a429b0c92a --- /dev/null +++ b/apps/backend/src/mutations/DataAccessUsersMutations.spec.ts @@ -0,0 +1,100 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../config/Tokens'; +import { DataAccessUsersDataSource } from '../datasources/DataAccessUsersDataSource'; +import { dummyProposalSubmitted } from '../datasources/mockups/ProposalDataSource'; +import { + basicDummyUserNotOnProposal, + dummyPrincipalInvestigatorWithRole, + dummyUser, + dummyUserNotOnProposalWithRole, +} from '../datasources/mockups/UserDataSource'; +import { Rejection } from '../models/Rejection'; +import DataAccessUsersMutations from './DataAccessUsersMutations'; + +const dataAccessUsersMutations = container.resolve(DataAccessUsersMutations); + +let mockDataSource: DataAccessUsersDataSource; + +beforeEach(() => { + mockDataSource = container.resolve( + Tokens.DataAccessUsersDataSource + ); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('DataAccessUsersMutations', () => { + describe('updateDataAccessUsers', () => { + it('PI should be able to update data access users successfully', async () => { + const proposalPk = dummyProposalSubmitted.primaryKey; + const dataAccessUserIds = [basicDummyUserNotOnProposal.id]; + const expectedDataAccessUsers = [basicDummyUserNotOnProposal]; + + jest.spyOn(mockDataSource, 'updateDataAccessUsers'); + + const result = await dataAccessUsersMutations.updateDataAccessUsers( + dummyPrincipalInvestigatorWithRole, + { proposalPk, userIds: dataAccessUserIds } + ); + + expect(mockDataSource.updateDataAccessUsers).toHaveBeenCalledWith( + proposalPk, + dataAccessUserIds + ); + + expect(result).not.toBeInstanceOf(Rejection); + expect(result).toEqual(expectedDataAccessUsers); + }); + + it('User not on proposal should NOT be able to update data access users', async () => { + jest.spyOn(mockDataSource, 'updateDataAccessUsers'); + + const result = await dataAccessUsersMutations.updateDataAccessUsers( + dummyUserNotOnProposalWithRole, + { + proposalPk: dummyProposalSubmitted.primaryKey, + userIds: [basicDummyUserNotOnProposal.id], + } + ); + + expect(mockDataSource.updateDataAccessUsers).toHaveBeenCalledTimes(0); + expect(result).toBeInstanceOf(Rejection); + }); + + it('Should handle data source errors', async () => { + const updateSpy = jest.spyOn(mockDataSource, 'updateDataAccessUsers'); + updateSpy.mockRejectedValue(new Error('Database error')); + + const result = await dataAccessUsersMutations.updateDataAccessUsers( + dummyPrincipalInvestigatorWithRole, + { proposalPk: dummyProposalSubmitted.primaryKey, userIds: [] } + ); + + expect(result).toBeInstanceOf(Rejection); + expect((result as Rejection).reason).toBe( + 'Failed to update data access users' + ); + }); + + it('Should not be able to add data access users if already a member of the proposal', async () => { + const userOnProposal = dummyUser; + + const result = await dataAccessUsersMutations.updateDataAccessUsers( + dummyPrincipalInvestigatorWithRole, + { + proposalPk: dummyProposalSubmitted.primaryKey, + userIds: [userOnProposal.id], + } + ); + + expect(result).toBeInstanceOf(Rejection); + expect((result as Rejection).reason).toBe( + 'User can not be symultaneously a data access user and a member of the proposal' + ); + }); + }); +}); diff --git a/apps/backend/src/mutations/DataAccessUsersMutations.ts b/apps/backend/src/mutations/DataAccessUsersMutations.ts new file mode 100644 index 0000000000..147355e382 --- /dev/null +++ b/apps/backend/src/mutations/DataAccessUsersMutations.ts @@ -0,0 +1,83 @@ +import { updateDataAccessUsersValidationSchema } from '@user-office-software/duo-validation'; +import { inject, injectable } from 'tsyringe'; + +import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; +import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { UserAuthorization } from '../auth/UserAuthorization'; +import { Tokens } from '../config/Tokens'; +import { DataAccessUsersDataSource } from '../datasources/DataAccessUsersDataSource'; +import { ValidateArgs } from '../decorators'; +import { resolveApplicationEventBus } from '../events'; +import { Event } from '../events/event.enum'; +import { isRejection, rejection, Rejection } from '../models/Rejection'; +import { BasicUserDetails, UserWithRole } from '../models/User'; + +export interface UpdateDataAccessUsersArgs { + proposalPk: number; + userIds: number[]; +} + +@injectable() +export default class DataAccessUsersMutations { + constructor( + @inject(Tokens.DataAccessUsersDataSource) + private dataSource: DataAccessUsersDataSource, + @inject(Tokens.DataAccessUsersAuthorization) + private dataAccessUsersAuth: DataAccessUsersAuthorization, + @inject(Tokens.UserAuthorization) + private userAuth: UserAuthorization, + @inject(Tokens.ProposalAuthorization) + private proposalAuth: ProposalAuthorization + ) {} + + @ValidateArgs(updateDataAccessUsersValidationSchema) + async updateDataAccessUsers( + agent: UserWithRole | null, + args: UpdateDataAccessUsersArgs + ): Promise { + try { + const { proposalPk, userIds } = args; + + const hasWriteRights = + this.userAuth.isApiToken(agent) || + (await this.dataAccessUsersAuth.hasWriteRights(agent, proposalPk)); + if (!hasWriteRights) { + return rejection('You do not have write rights for this proposal'); + } + + const alreadyMemberIds: number[] = []; + for (const userId of args.userIds) { + if (await this.proposalAuth.isMemberOfProposal(userId, proposalPk)) { + alreadyMemberIds.push(userId); + } + } + if (alreadyMemberIds.length > 0) { + return rejection( + 'User can not be symultaneously a data access user and a member of the proposal', + { alreadyMemberIds } + ); + } + + const result = await this.dataSource.updateDataAccessUsers( + proposalPk, + userIds + ); + + if (!isRejection(result)) { + const eventBus = resolveApplicationEventBus(); + + await eventBus.publish({ + type: Event.DATA_ACCESS_USERS_UPDATED, + proposalPKey: proposalPk, + key: 'proposalPk', + loggedInUserId: agent ? agent.id : null, + isRejection: false, + }); + } + + return result; + } catch (error) { + return rejection('Failed to update data access users', { args }, error); + } + } +} diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index bb66b5c6d0..e1f8a1e907 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -15,6 +15,7 @@ import { dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; import { VisitDataSourceMock } from '../datasources/mockups/VisitDataSource'; +import { VisitDataSource } from '../datasources/VisitDataSource'; import { EmailTemplateId } from '../eventHandlers/email/essEmailHandler'; import { MailService } from '../eventHandlers/MailService/MailService'; import { Event } from '../events/event.enum'; @@ -23,6 +24,9 @@ import { Rejection } from '../models/Rejection'; import InviteMutations from './InviteMutations'; const inviteMutations = container.resolve(InviteMutations); +const visitDataSource = container.resolve( + Tokens.VisitDataSource +); describe('Test Invite Mutations', () => { beforeEach(() => { @@ -263,10 +267,86 @@ describe('Test Invite Mutations', () => { expect(setEventInDataSourceSpy).toHaveBeenCalledTimes(1); expect(setEventInDataSourceSpy).toHaveBeenCalledWith( dummyUserWithRole.id, // changedBy (userId) - expect.stringMatching(Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT), // eventType + expect.stringMatching(Event.PROPOSAL_CO_PROPOSER_INVITE_SENT), // eventType expect.stringContaining(email), // rowData (JSON string containing the email) expect.any(String), // changedObjectId (should be the invite ID) - expect.stringContaining(`Co-proposer invite sent to: ${email}`), // description + expect.stringContaining( + `Co-proposer invite issued to ${email} by userId ${dummyUserWithRole.id}` + ), // description + undefined + ); + }); + + test('A visitor should receive an invite email when visitor invite is created', async () => { + const email = faker.internet.email(); + + const mailService = container.resolve(Tokens.MailService); + const sendMailSpy = jest.spyOn(mailService, 'sendMail'); + + const newVisit = await visitDataSource.createVisit( + { + teamLeadUserId: dummyUserWithRole.id, + experimentPk: 1, + team: [dummyUserWithRole.id], + }, + dummyUserWithRole.id, + 1 + ); + const response = await inviteMutations.setVisitRegistrationInvites( + dummyUserWithRole, + { + visitId: newVisit.id, + emails: [email], + } + ); + + expect(response).not.toBeInstanceOf(Rejection); + + // Wait for async handlers to complete + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + recipients: [{ address: email }], + content: { + template_id: + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, + }, + }) + ); + }); + + test('A log should be added when visitor invite is sent', async () => { + const email = faker.internet.email(); + const visitId = 1; + + // Get the exact same instance that the logging handler will use + const eventLogDataSource = container.resolve( + Tokens.EventLogsDataSource + ); + const setEventInDataSourceSpy = jest.spyOn(eventLogDataSource, 'set'); + + const response = await inviteMutations.setVisitRegistrationInvites( + dummyUserWithRole, + { + visitId: visitId, + emails: [email], + } + ); + expect(response).not.toBeInstanceOf(Rejection); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(setEventInDataSourceSpy).toHaveBeenCalledTimes(1); + expect(setEventInDataSourceSpy).toHaveBeenCalledWith( + dummyUserWithRole.id, // changedBy (userId) + expect.stringMatching(Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT), // eventType + expect.stringContaining(email), // rowData (JSON string containing the email) + expect.any(String), // changedObjectId (should be the invite ID) + expect.stringContaining( + `Visit invite issued to ${email} by userId ${dummyUserWithRole.id}` + ), // description undefined ); }); @@ -292,7 +372,7 @@ describe('Test Invite Mutations', () => { .findById((response as Invite[])[0].id)) as Invite; expect(invite.templateId).toBe( - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER + 'user-office-registration-invitation-co-proposer' ); }); }); diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 183957ac49..65fb8385e2 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -3,14 +3,17 @@ import { inject, injectable } from 'tsyringe'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; import { UserAuthorization } from '../auth/UserAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { Tokens } from '../config/Tokens'; import { AdminDataSource } from '../datasources/AdminDataSource'; import { CoProposerClaimDataSource } from '../datasources/CoProposerClaimDataSource'; import { InviteDataSource } from '../datasources/InviteDataSource'; +import database from '../datasources/postgres/database'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { RoleClaimDataSource } from '../datasources/RoleClaimDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; +import { VisitRegistrationClaimDataSource } from '../datasources/VisitRegistrationClaimDataSource'; import { Authorized } from '../decorators'; import { ApplicationEventBus } from '../events'; import { ApplicationEvent } from '../events/applicationEvents'; @@ -35,10 +38,14 @@ export default class InviteMutations { private roleClaimDataSource: RoleClaimDataSource, @inject(Tokens.CoProposerClaimDataSource) private coProposerClaimDataSource: CoProposerClaimDataSource, + @inject(Tokens.VisitRegistrationClaimDataSource) + private visitRegistrationClaimDataSource: VisitRegistrationClaimDataSource, @inject(Tokens.VisitDataSource) private visitDataSource: VisitDataSource, @inject(Tokens.ProposalAuthorization) private proposalAuth: ProposalAuthorization, + @inject(Tokens.VisitAuthorization) + private visitAuthorization: VisitAuthorization, @inject(Tokens.AdminDataSource) private adminDataSource: AdminDataSource, @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, @@ -62,8 +69,9 @@ export default class InviteMutations { return rejection('Invite code has expired', { invite: code }); } - await this.processRoleClaims(agent!.id, invite); - await this.processCoProposerClaims(agent!.id, invite); + await this.processAcceptedRoleClaims(agent!.id, invite); + await this.processAcceptedCoProposerClaims(agent!.id, invite); + await this.processAcceptedVisitRegistrationClaims(agent!.id, invite); const updatedInvite = await this.inviteDataSource.update({ id: invite.id, @@ -143,8 +151,86 @@ export default class InviteMutations { ...newInvites, ]; + if (invites.length > 0) { + await this.eventBus.publish({ + type: Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED, + array: invites, + key: 'array', + loggedInUserId: agent?.id, + inputArgs: JSON.stringify(args), + impersonatingUserId: agent ? agent.impersonatingUserId : null, + proposalPKey: proposalPk, + } as ApplicationEvent); + } + + return invites; + } + + @Authorized() + public async setVisitRegistrationInvites( + agent: UserWithRole | null, + args: { visitId: number; emails: string[] } + ): Promise { + const { visitId, emails } = args; + + const hasWriteRights = + this.userAuth.isApiToken(agent) || + (await this.visitAuthorization.hasWriteRights(agent, visitId)); + + if (!hasWriteRights) { + return rejection( + 'User is not authorized to create invites for this visit' + ); + } + + const existingInvites = + await this.inviteDataSource.findVisitRegistrationInvites(visitId, false); + + const existingEmails = existingInvites.map((invite) => invite.email); + const deletedEmails = existingEmails.filter( + (email) => !emails.includes(email) + ); + const newEmails = emails.filter((email) => !existingEmails.includes(email)); + + const deletedInvites = existingInvites.filter((invite) => + deletedEmails.includes(invite.email) + ); + + await Promise.all( + deletedInvites.map((invite) => this.inviteDataSource.delete(invite.id)) + ); + + const expirationDate = await this.getInviteExpirationDate(); + + const newInvites = await Promise.all( + newEmails.map(async (email) => + this.inviteDataSource.create({ + createdByUserId: agent!.id, + code: await this.generateInviteCode(), + email: email, + expiresAt: expirationDate, + }) + ) + ); + await Promise.all( + newInvites.map(async (newInvite) => { + await this.visitRegistrationClaimDataSource.create( + newInvite.id, + visitId + ); + }) + ); + + const invites = [ + ...existingInvites.filter((invite) => !deletedInvites.includes(invite)), + ...newInvites, + ]; + + const { primaryKey: proposalPk } = + await this.proposalDataSource.getProposalByVisitId(visitId); + await this.eventBus.publish({ - type: Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED, + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, array: invites, key: 'array', loggedInUserId: agent?.id, @@ -155,8 +241,10 @@ export default class InviteMutations { return invites; } - - private async processRoleClaims(claimerUserId: number, invite: Invite) { + private async processAcceptedRoleClaims( + claimerUserId: number, + invite: Invite + ) { const inviteId = invite.id; const roleClaims = await this.roleClaimDataSource.findByInviteId(inviteId); @@ -177,7 +265,10 @@ export default class InviteMutations { } } - private async processCoProposerClaims(claimerUserId: number, invite: Invite) { + private async processAcceptedCoProposerClaims( + claimerUserId: number, + invite: Invite + ) { const inviteId = invite.id; const coProposerClaim = await this.coProposerClaimDataSource.findByInviteId(inviteId); @@ -208,6 +299,52 @@ export default class InviteMutations { }); } } + private async processAcceptedVisitRegistrationClaims( + claimerUserId: number, + invite: Invite + ) { + const inviteId = invite.id; + const claims = + await this.visitRegistrationClaimDataSource.findByInviteId(inviteId); + + for (const claim of claims) { + const existingRegistration = await this.visitDataSource.getRegistration( + claimerUserId, + claim.visitId + ); + + if (existingRegistration) { + return; + } + + // Insert the user into the visits_has_users table to create a new visit registration + await database('visits_has_users') + .insert({ + visit_id: claim.visitId, + user_id: claimerUserId, + registration_questionary_id: null, + starts_at: null, + ends_at: null, + // status will default to 'DRAFTED' as defined in the database schema + }) + .onConflict(['user_id', 'visit_id']) + .ignore(); + + const proposal = await this.proposalDataSource.getProposalByVisitId( + claim.visitId + ); + + this.eventBus.publish({ + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite, + description: `User with ID ${claimerUserId} accepted visit invite`, + proposalPKey: proposal.primaryKey, + }); + } + } private async proposalHasUser(proposalPk: number, userId: number) { const proposalUsers = diff --git a/apps/backend/src/mutations/ProposalMutations.ts b/apps/backend/src/mutations/ProposalMutations.ts index 66203d7c6a..cbf9467167 100644 --- a/apps/backend/src/mutations/ProposalMutations.ts +++ b/apps/backend/src/mutations/ProposalMutations.ts @@ -38,6 +38,7 @@ import { ChangeProposalsStatusInput } from '../resolvers/mutations/ChangeProposa import { CloneProposalsInput } from '../resolvers/mutations/CloneProposalMutation'; import { CreateProposalScientistCommentArgs } from '../resolvers/mutations/CreateProposalScientistCommentMutation'; import { ImportProposalArgs } from '../resolvers/mutations/ImportProposalMutation'; +import { NotifyProposalArgs } from '../resolvers/mutations/NotifyProposalMutation'; import { UpdateProposalArgs } from '../resolvers/mutations/UpdateProposalMutation'; import { UpdateProposalScientistCommentArgs } from '../resolvers/mutations/UpdateProposalScientistCommentMutation'; import { ProposalScientistComment } from '../resolvers/types/ProposalView'; @@ -381,13 +382,18 @@ export default class ProposalMutations { @Authorized([Roles.USER_OFFICER]) async notify( user: UserWithRole | null, - { proposalPk }: { proposalPk: number } + notifyArgs: NotifyProposalArgs ): Promise { - const proposal = await this.proposalDataSource.get(proposalPk); + const proposal = await this.proposalDataSource.get(notifyArgs.proposalPk); - if (!proposal || proposal.notified || !proposal.finalStatus) { + if ( + !proposal || + (proposal.notified && !notifyArgs.ignoreNotifiedFlag) || + !proposal.finalStatus + ) { return rejection('Can not notify proposal', { proposal }); } + proposal.notified = true; const result = await this.proposalDataSource.update(proposal); @@ -917,9 +923,17 @@ export default class ProposalMutations { const proposalUsers = await this.userDataSource.getProposalUsers( sourceProposal.primaryKey ); + const proposalUserIds = proposalUsers.map((user) => user.id); + + const hasWriteRightsOnClonedProposal = + await this.proposalAuth.hasWriteRights(agent, clonedProposal); + if (!hasWriteRightsOnClonedProposal) { + proposalUserIds.push(agent!.id); + } + await this.proposalDataSource.setProposalUsers( clonedProposal.primaryKey, - proposalUsers.map((user) => user.id) + proposalUserIds ); const proposalSamples = await this.sampleDataSource.getSamples({ diff --git a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts index 68f933e5a1..79aaea6d52 100644 --- a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts @@ -193,4 +193,13 @@ describe('Test Proposal settings mutations', () => { }) ).resolves.toStrictEqual(true); }); + + test('A userofficer can delete workflow connection', () => { + return expect( + workflowMutationsInstance.deleteWorkflowConnection( + dummyUserOfficerWithRole, + 1 + ) + ).resolves.toStrictEqual(dummyWorkflowConnection); + }); }); diff --git a/apps/backend/src/mutations/ReviewMutations.spec.ts b/apps/backend/src/mutations/ReviewMutations.spec.ts index c43f620578..f66ee0eb94 100644 --- a/apps/backend/src/mutations/ReviewMutations.spec.ts +++ b/apps/backend/src/mutations/ReviewMutations.spec.ts @@ -21,7 +21,7 @@ test('A reviewer can submit a review on a proposal he is on', () => { reviewMutations.updateReview(dummyUserWithRole, { reviewID: 10, comment: 'Good proposal', - grade: 9, + grade: '9', status: ReviewStatus.DRAFT, fapID: 1, questionaryID: 1, @@ -34,7 +34,7 @@ test('A user can not submit a review on a proposal', () => { reviewMutations.updateReview(dummyUserNotOnProposalWithRole, { reviewID: 1, comment: 'Good proposal', - grade: 9, + grade: '9', status: ReviewStatus.DRAFT, fapID: 1, questionaryID: 1, @@ -50,7 +50,7 @@ test('A Fap chair can not modify Fap review if it is submitted', () => { reviewMutations.updateReview(dummyFapChairWithRole, { reviewID: 5, comment: 'Good proposal test', - grade: 9, + grade: '9', status: ReviewStatus.SUBMITTED, fapID: 1, questionaryID: 1, @@ -66,7 +66,7 @@ test('A Fap secretary can not modify Fap review if it is submitted', () => { reviewMutations.updateReview(dummyFapSecretaryWithRole, { reviewID: 5, comment: 'Good proposal test', - grade: 9, + grade: '9', status: ReviewStatus.SUBMITTED, fapID: 1, questionaryID: 1, diff --git a/apps/backend/src/mutations/SampleMutations.ts b/apps/backend/src/mutations/SampleMutations.ts index 4edccf4e8f..c06ba2f6cc 100644 --- a/apps/backend/src/mutations/SampleMutations.ts +++ b/apps/backend/src/mutations/SampleMutations.ts @@ -72,7 +72,11 @@ export default class SampleMutations { ); } - if (proposal.submitted && args.isPostProposalSubmission !== true) { + if ( + proposal.submitted && + args.isPostProposalSubmission !== true && + !this.userAuth.isUserOfficer(agent) + ) { return rejection( 'Can not create sample because proposal is already submitted', { agent, args } diff --git a/apps/backend/src/mutations/ShipmentMutations.ts b/apps/backend/src/mutations/ShipmentMutations.ts index 74a54d92e2..9e1139d39c 100644 --- a/apps/backend/src/mutations/ShipmentMutations.ts +++ b/apps/backend/src/mutations/ShipmentMutations.ts @@ -9,8 +9,7 @@ import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { QuestionaryDataSource } from '../datasources/QuestionaryDataSource'; import { ShipmentDataSource } from '../datasources/ShipmentDataSource'; import { TemplateDataSource } from '../datasources/TemplateDataSource'; -import { Authorized, EventBus } from '../decorators'; -import { Event } from '../events/event.enum'; +import { Authorized } from '../decorators'; import { ProposalEndStatus } from '../models/Proposal'; import { rejection } from '../models/Rejection'; import { ShipmentStatus } from '../models/Shipment'; @@ -159,7 +158,6 @@ export default class ShipmentMutations { } } - @EventBus(Event.PROPOSAL_SAMPLE_REVIEW_SUBMITTED) async updateShipment(agent: UserWithRole | null, args: UpdateShipmentArgs) { const hasWriteRights = this.userAuth.isApiToken(agent) || diff --git a/apps/backend/src/mutations/VisitMutations.ts b/apps/backend/src/mutations/VisitMutations.ts index ee8e3896be..39a5db8b96 100644 --- a/apps/backend/src/mutations/VisitMutations.ts +++ b/apps/backend/src/mutations/VisitMutations.ts @@ -52,6 +52,7 @@ export default class VisitMutations { ) {} @Authorized() + @EventBus(Event.VISIT_CREATED) async createVisit( agent: UserWithRole | null, args: CreateVisitArgs diff --git a/apps/backend/src/mutations/WorkflowMutations.ts b/apps/backend/src/mutations/WorkflowMutations.ts index 7da52523a9..6a6ca8e525 100644 --- a/apps/backend/src/mutations/WorkflowMutations.ts +++ b/apps/backend/src/mutations/WorkflowMutations.ts @@ -1,10 +1,8 @@ import { addStatusActionsToConnectionValidationSchema, - addWorkflowStatusValidationSchema, createWorkflowValidationSchema, deleteWorkflowStatusValidationSchema, deleteWorkflowValidationSchema, - moveWorkflowStatusValidationSchema, updateWorkflowValidationSchema, } from '@user-office-software/duo-validation'; import { inject, injectable } from 'tsyringe'; @@ -22,19 +20,15 @@ import { import { StatusChangingEvent } from '../models/StatusChangingEvent'; import { UserWithRole } from '../models/User'; import { Workflow } from '../models/Workflow'; -import { - WorkflowConnection, - WorkflowConnectionWithStatus, -} from '../models/WorkflowConnections'; +import { WorkflowConnection } from '../models/WorkflowConnections'; import { AddConnectionStatusActionsInput } from '../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; import { AddStatusChangingEventsToConnectionInput } from '../resolvers/mutations/settings/AddStatusChangingEventsToConnectionMutation'; import { AddWorkflowStatusInput } from '../resolvers/mutations/settings/AddWorkflowStatusMutation'; import { CreateWorkflowInput } from '../resolvers/mutations/settings/CreateWorkflowMutation'; import { DeleteWorkflowStatusInput } from '../resolvers/mutations/settings/DeleteWorkflowStatusMutation'; -import { MoveWorkflowStatusInput } from '../resolvers/mutations/settings/MoveWorkflowStatusMutation'; import { UpdateWorkflowInput } from '../resolvers/mutations/settings/UpdateWorkflowMutation'; +import { UpdateWorkflowStatusInput } from '../resolvers/mutations/settings/UpdateWorkflowStatusMutation'; import { EmailStatusActionRecipients } from '../resolvers/types/StatusActionConfig'; -import { omit } from '../utils/helperFunctions'; @injectable() export default class WorkflowMutations { @@ -79,233 +73,68 @@ export default class WorkflowMutations { }); } - // TODO: It is messy code but it works! Clean it from here to the bottom! - orderAndConnectAllWorkflowStatusesInSameDroppableGroup( - workflowConnections: WorkflowConnection[], - isInTheMiddleOfAGroup: boolean - ) { - return workflowConnections.map((workflowConnection, index) => { - const nextShouldNotBeTouched = - isInTheMiddleOfAGroup && index === workflowConnections.length - 1; - - return { - ...workflowConnection, - sortOrder: index, - prevStatusId: workflowConnections[index - 1] - ? workflowConnections[index - 1].statusId - : null, - nextStatusId: nextShouldNotBeTouched - ? workflowConnection.nextStatusId - : workflowConnections[index + 1] - ? workflowConnections[index + 1].statusId - : null, - }; - }); - } - - async deleteParentGroupLastConnection( - lastConnectionInParentDroppableGroup: WorkflowConnection - ) { - return await this.dataSource.deleteWorkflowStatus( - lastConnectionInParentDroppableGroup.statusId, - lastConnectionInParentDroppableGroup.workflowId, - lastConnectionInParentDroppableGroup.sortOrder - ); - } - - getNewOrUpdatedLastConnectionInParentGroup( - lastConnectionInParentDroppableGroup: WorkflowConnection, - firstConnectionStatusId: number - ) { - const lastConnectionInParentGroupHasNext = - !!lastConnectionInParentDroppableGroup.nextStatusId; - - lastConnectionInParentDroppableGroup.nextStatusId = firstConnectionStatusId; - - if (lastConnectionInParentGroupHasNext) { - const newConnection = omit(lastConnectionInParentDroppableGroup, 'id'); - - return newConnection as WorkflowConnection; - } else { - return lastConnectionInParentDroppableGroup; - } - } - - async updateWorkflowConnectionStatuses( - workflowConnections: WorkflowConnection[], - { - isInTheMiddleOfAGroup = false, - isLastConnectionInParentGroup = false, - isFirstConnectionInChildGroup = false, - } - ) { - const updatedWorkflowConnections = - this.orderAndConnectAllWorkflowStatusesInSameDroppableGroup( - workflowConnections, - isInTheMiddleOfAGroup - ); - - const [firstConnection, secondConnection] = updatedWorkflowConnections; - const parentDroppableGroupId = firstConnection.parentDroppableGroupId; - - if (parentDroppableGroupId) { - const [lastConnectionInParentDroppableGroup] = - await this.dataSource.getWorkflowConnectionsById( - workflowConnections[0].workflowId, - workflowConnections[0].prevStatusId as number, - { - nextStatusId: - secondConnection && isFirstConnectionInChildGroup - ? workflowConnections[1]?.statusId - : null, - } - ); - - if (secondConnection && isFirstConnectionInChildGroup) { - updatedWorkflowConnections[0].prevStatusId = - workflowConnections[1].prevStatusId; - } else { - updatedWorkflowConnections[0].prevStatusId = - lastConnectionInParentDroppableGroup?.statusId; - } - - updatedWorkflowConnections[0].prevStatusId = - lastConnectionInParentDroppableGroup?.statusId; - - if (isFirstConnectionInChildGroup) { - if (secondConnection) { - // Remove connection if there was one between parent group last status and this group second status because now we have new status in between. - await this.deleteParentGroupLastConnection( - lastConnectionInParentDroppableGroup + @Authorized([Roles.USER_OFFICER]) + async addWorkflowStatus( + agent: UserWithRole | null, + args: AddWorkflowStatusInput + ): Promise { + try { + if (args.prevStatusId) { + const previousWorkflowConnection = + await this.dataSource.getWorkflowConnectionsById( + args.workflowId, + args.prevStatusId, + {} ); + if (previousWorkflowConnection.length > 0) { + // If there is a previous connection, we need to update its nextStatusId + const updatedConnection = previousWorkflowConnection[0]; + updatedConnection.nextStatusId = args.statusId; + await this.dataSource.updateWorkflowStatus(updatedConnection); } - - // If last connection already has next we add new connection. If not we update the existing one with next of the current group first connection. - const lastUpdatedOrNewInsertedConnection = - this.getNewOrUpdatedLastConnectionInParentGroup( - lastConnectionInParentDroppableGroup, - firstConnection.statusId - ); - - updatedWorkflowConnections.push(lastUpdatedOrNewInsertedConnection); } - } - - if (isLastConnectionInParentGroup) { - const lastConnection = - updatedWorkflowConnections.pop() as WorkflowConnection; - const secondLastConnection = updatedWorkflowConnections.slice(-1)[0]; - const findAllConnectionsByParentGroup = true; - - // Delete connection between second last and last connection if there is one. - if (secondLastConnection) { - const secondLastConnectionInitialSortOrder = workflowConnections.find( - (workflowConnection) => - workflowConnection.statusId === secondLastConnection.statusId && - workflowConnection.prevStatusId === - secondLastConnection.prevStatusId - )?.sortOrder; - - await this.dataSource.deleteWorkflowStatus( - secondLastConnection.statusId, - secondLastConnection.workflowId, - secondLastConnectionInitialSortOrder || secondLastConnection.sortOrder - ); - } - - // Find all potential first child connections and connect them with new parent - const allFirstChildrenGroupConnections = ( - await this.dataSource.getWorkflowConnections( - lastConnection.workflowId, - lastConnection.droppableGroupId, - findAllConnectionsByParentGroup - ) - ) - .filter((childConnection) => childConnection.sortOrder === 0) - .map((firstChildConnection) => ({ - ...firstChildConnection, - prevStatusId: lastConnection.statusId, - })); - - if ( - allFirstChildrenGroupConnections && - allFirstChildrenGroupConnections.length > 0 - ) { - updatedWorkflowConnections.push(...allFirstChildrenGroupConnections); - allFirstChildrenGroupConnections.forEach((firstChildConnection) => { - updatedWorkflowConnections.push({ - ...lastConnection, - nextStatusId: firstChildConnection.statusId, - }); - }); - } + return await this.dataSource.addWorkflowStatus(args); + } catch (error) { + return rejection('Could not add workflow status', { agent, args }, error); } - - return await this.dataSource.updateWorkflowStatuses( - updatedWorkflowConnections - ); - } - - async insertWorkflowStatus(args: AddWorkflowStatusInput) { - return this.dataSource.addWorkflowStatus(args); - } - - async insertNewAndUpdateExistingWorkflowStatuses( - args: AddWorkflowStatusInput - ) { - const newWorkflowConnection = args as WorkflowConnectionWithStatus; - const allWorkflowGroupConnections = - await this.dataSource.getWorkflowConnections( - args.workflowId, - args.droppableGroupId, - false - ); - - const isFirstConnectionInChildGroup = - newWorkflowConnection.sortOrder === 0 && - !!newWorkflowConnection.prevStatusId; - - const isLastConnectionInParentGroup = - newWorkflowConnection.sortOrder === allWorkflowGroupConnections.length && - !!newWorkflowConnection.nextStatusId; - - // Insert new connection in the correct place inside its group - allWorkflowGroupConnections.splice( - newWorkflowConnection.sortOrder, - 0, - newWorkflowConnection - ); - - const isInTheMiddleOfAGroup = - newWorkflowConnection.sortOrder > 0 && - newWorkflowConnection.sortOrder < allWorkflowGroupConnections.length - 1; - const insertedWorkflowConnection = ( - await this.updateWorkflowConnectionStatuses(allWorkflowGroupConnections, { - isInTheMiddleOfAGroup, - isFirstConnectionInChildGroup, - isLastConnectionInParentGroup, - }) - )[newWorkflowConnection.sortOrder]; - - return insertedWorkflowConnection; } - @ValidateArgs(addWorkflowStatusValidationSchema) @Authorized([Roles.USER_OFFICER]) - async addWorkflowStatus( + async updateWorkflowStatus( agent: UserWithRole | null, - args: AddWorkflowStatusInput + args: UpdateWorkflowStatusInput ): Promise { - const isVeryFirstConnection = !args.nextStatusId && !args.prevStatusId; try { - if (isVeryFirstConnection) { - return this.insertWorkflowStatus(args); - } else { - return this.insertNewAndUpdateExistingWorkflowStatuses(args); + const connection = await this.dataSource.getWorkflowConnection(args.id); + + if (!connection) { + return rejection( + 'Workflow connection not found', + { agent, args }, + new Error('Connection not found') + ); } + + const updatedConnection = new WorkflowConnection( + connection.id, + connection.sortOrder, + connection.workflowId, + connection.statusId, + args.nextStatusId ?? connection.nextStatusId, + args.prevStatusId ?? connection.prevStatusId, + args.posX ?? connection.posX, + args.posY ?? connection.posY, + args.prevConnectionId ?? connection.prevConnectionId + ); + + return await this.dataSource.updateWorkflowStatus(updatedConnection); } catch (error) { - return rejection('Could not add workflow status', { agent, args }, error); + return rejection( + 'Could not update workflow status', + { agent, args }, + error + ); } } @@ -329,227 +158,51 @@ export default class WorkflowMutations { }); } - // NOTE: This is not in use currently. - moveArrayElement( - workflowConnections: WorkflowConnection[], - fromIndex: number, - toIndex: number - ) { - const workflowConnectionToMove = workflowConnections[fromIndex]; - - workflowConnections.splice( - workflowConnections.indexOf(workflowConnectionToMove), - 1 - ); - - workflowConnections.splice(toIndex, 0, workflowConnectionToMove); - - return workflowConnections; - } - - // NOTE: Moving statuses inside workflow is not enabled at the moment so this is not used at all. I keep it if we deceide to use this feature later. - @ValidateArgs(moveWorkflowStatusValidationSchema) + @ValidateArgs(deleteWorkflowStatusValidationSchema) @Authorized([Roles.USER_OFFICER]) - async moveWorkflowStatus( + async deleteWorkflowStatus( agent: UserWithRole | null, - args: MoveWorkflowStatusInput - ): Promise { + args: DeleteWorkflowStatusInput + ): Promise { try { - const allSourceGroupWorkflowConnections = - await this.dataSource.getWorkflowConnections( - args.workflowId, - args.from.droppableId - ); - - let reorderedWorkflowConnections: WorkflowConnection[] = []; - - if (args.from.droppableId === args.to.droppableId) { - reorderedWorkflowConnections = this.moveArrayElement( - allSourceGroupWorkflowConnections, - args.from.index, - args.to.index - ); - } - - const isFirstConnectionInChildGroup = args.to.index === 0; - - await this.updateWorkflowConnectionStatuses( - reorderedWorkflowConnections, - { isFirstConnectionInChildGroup } + await this.dataSource.deleteWorkflowStatus( + args.statusId, + args.workflowId, + args.sortOrder ); - return reorderedWorkflowConnections[args.to.index]; + return true; } catch (error) { return rejection( - 'Could not move workflow status', + 'Could not delete workflow status', { agent, args }, error ); } } - async findPreviousAndNextConnections( - workflowId: number, - { - currentStatusId, - previousStatusId, - nextStatusId, - }: { - currentStatusId: number; - previousStatusId: number | null; - nextStatusId: number | null; - } - ) { - const [previousConnection] = previousStatusId - ? await this.dataSource.getWorkflowConnectionsById( - workflowId, - previousStatusId, - { - nextStatusId: currentStatusId, - } - ) - : []; - - const [nextConnection] = nextStatusId - ? await this.dataSource.getWorkflowConnectionsById( - workflowId, - nextStatusId, - { - prevStatusId: currentStatusId, - } - ) - : []; - - return [previousConnection, nextConnection]; - } - - @ValidateArgs(deleteWorkflowStatusValidationSchema) @Authorized([Roles.USER_OFFICER]) - async deleteWorkflowStatus( + async deleteWorkflowConnection( agent: UserWithRole | null, - args: DeleteWorkflowStatusInput - ): Promise { + connectionId: number + ): Promise { try { - // NOTE: We can have more than one connection if it is multi-column workflow - const workflowConnectionsToRemove = - await this.dataSource.getWorkflowConnectionsById( - args.workflowId, - args.statusId, - { sortOrder: args.sortOrder } - ); - - const [firstWorkflowConnectionToRemove] = workflowConnectionsToRemove; + const deletedConnection = + await this.dataSource.deleteWorkflowConnection(connectionId); - if (!firstWorkflowConnectionToRemove) { + if (!deletedConnection) { return rejection( - 'Can not delete workflow connection because first connection not found', - { workflowConnectionsToRemove } - ); - } - - const allGroupWorkflowConnections = - await this.dataSource.getWorkflowConnections( - args.workflowId, - firstWorkflowConnectionToRemove.droppableGroupId - ); - - const isLastConnectionInGroupRemoved = - firstWorkflowConnectionToRemove.sortOrder + 1 === - allGroupWorkflowConnections.length; - const lastConnectionInParentGroupRemoved = - isLastConnectionInGroupRemoved && - firstWorkflowConnectionToRemove.nextStatusId && - firstWorkflowConnectionToRemove.prevStatusId; - - if (lastConnectionInParentGroupRemoved) { - const [workflowConnectionToReplaceRemoved] = - await this.dataSource.getWorkflowConnectionsById( - firstWorkflowConnectionToRemove.workflowId, - firstWorkflowConnectionToRemove.prevStatusId as number, - {} - ); - - if (!workflowConnectionToReplaceRemoved) { - return rejection( - 'Can not delete workflow connection because connection to replace is not found', - { firstWorkflowConnectionToRemove } - ); - } - - workflowConnectionsToRemove.forEach(async (connection) => { - const updatedWorkflowConnections: WorkflowConnection[] = []; - - if (connection.nextStatusId) { - const [nextConnection] = - await this.dataSource.getWorkflowConnectionsById( - connection.workflowId, - connection.nextStatusId as number, - {} - ); - - if (nextConnection) { - updatedWorkflowConnections.push({ - ...nextConnection, - prevStatusId: connection.prevStatusId, - }); - } - } - - updatedWorkflowConnections.push({ - ...connection, - prevStatusId: workflowConnectionToReplaceRemoved.prevStatusId, - statusId: workflowConnectionToReplaceRemoved.statusId, - sortOrder: workflowConnectionToReplaceRemoved.sortOrder, - }); - - await this.dataSource.updateWorkflowStatuses( - updatedWorkflowConnections - ); - }); - - await this.dataSource.deleteWorkflowStatus( - workflowConnectionToReplaceRemoved.statusId, - workflowConnectionToReplaceRemoved.workflowId, - workflowConnectionToReplaceRemoved.sortOrder + 'Workflow connection not found', + { agent, connectionId }, + new Error('Connection not found') ); - } else { - const result = await this.dataSource.deleteWorkflowStatus( - args.statusId, - args.workflowId, - args.sortOrder - ); - - const [previousConnection, nextConnection] = - await this.findPreviousAndNextConnections(args.workflowId, { - currentStatusId: result.statusId, - previousStatusId: result.prevStatusId, - nextStatusId: result.nextStatusId, - }); - - const connectionsToUpdate = []; - - if (previousConnection) { - connectionsToUpdate.push({ - ...previousConnection, - nextStatusId: result.nextStatusId, - }); - } - - if (nextConnection) { - connectionsToUpdate.push({ - ...nextConnection, - prevStatusId: result.prevStatusId, - }); - } - - await this.dataSource.updateWorkflowStatuses(connectionsToUpdate); } - return true; + return deletedConnection; } catch (error) { return rejection( - 'Could not delete proposal workflow status', - { agent, args }, + 'Could not delete workflow connection', + { agent, connectionId }, error ); } diff --git a/apps/backend/src/queries/DataAccessUsersQueries.ts b/apps/backend/src/queries/DataAccessUsersQueries.ts new file mode 100644 index 0000000000..0dc0948941 --- /dev/null +++ b/apps/backend/src/queries/DataAccessUsersQueries.ts @@ -0,0 +1,20 @@ +import { inject, injectable } from 'tsyringe'; + +import { Tokens } from '../config/Tokens'; +import { DataAccessUsersDataSource } from '../datasources/DataAccessUsersDataSource'; +import { BasicUserDetails, UserWithRole } from '../models/User'; + +@injectable() +export default class DataAccessUsersQueries { + constructor( + @inject(Tokens.DataAccessUsersDataSource) + private dataSource: DataAccessUsersDataSource + ) {} + + async findByProposalPk( + agent: UserWithRole | null, + proposalPk: number + ): Promise { + return this.dataSource.findByProposalPk(proposalPk); + } +} diff --git a/apps/backend/src/queries/ExperimentQueries.spec.ts b/apps/backend/src/queries/ExperimentQueries.spec.ts index cf48666656..f9217367eb 100644 --- a/apps/backend/src/queries/ExperimentQueries.spec.ts +++ b/apps/backend/src/queries/ExperimentQueries.spec.ts @@ -7,6 +7,7 @@ import { ExperimentDataSourceMock, UpcomingExperimentWithExperiment, } from '../datasources/mockups/ExperimentDataSource'; +import { dummyUserOfficerWithRole } from '../datasources/mockups/UserDataSource'; import { ExperimentSafety } from '../models/Experiment'; import ExperimentQueries from './ExperimentQueries'; @@ -31,14 +32,17 @@ describe('ExperimentQueries', () => { 1 ); const result = await queries.getExperimentSafetyByExperimentPk( - null, + dummyUserOfficerWithRole, UpcomingExperimentWithExperiment.experimentPk ); expect(result).toEqual(createdSafety); }); test('returns null when safety does not exist', async () => { - const result = await queries.getExperimentSafetyByExperimentPk(null, 999); + const result = await queries.getExperimentSafetyByExperimentPk( + dummyUserOfficerWithRole, + 999 + ); expect(result).toBeNull(); }); }); @@ -51,14 +55,17 @@ describe('ExperimentQueries', () => { 2 )) as ExperimentSafety; const result = await queries.getExperimentSafety( - null, + dummyUserOfficerWithRole, createdSafety.experimentSafetyPk ); expect(result).toEqual(createdSafety); }); test('returns null when safety pk does not exist', async () => { - const result = await queries.getExperimentSafety(null, 999); + const result = await queries.getExperimentSafety( + dummyUserOfficerWithRole, + 999 + ); expect(result).toBeNull(); }); }); @@ -66,19 +73,25 @@ describe('ExperimentQueries', () => { describe('getExperimentSample', () => { test('returns experiment sample when available', async () => { // Override the method to return a dummy sample - const result = await queries.getExperimentSample(null, { - experimentPk: 1, - sampleId: 1, - }); + const result = await queries.getExperimentSample( + dummyUserOfficerWithRole, + { + experimentPk: 1, + sampleId: 1, + } + ); expect(result).toEqual(DummyExperimentSample1); }); test('returns null when sample is not found', async () => { dataSource.getExperimentSample = () => Promise.resolve(null); - const result = await queries.getExperimentSample(null, { - experimentPk: 1, - sampleId: 999, - }); + const result = await queries.getExperimentSample( + dummyUserOfficerWithRole, + { + experimentPk: 1, + sampleId: 999, + } + ); expect(result).toBeNull(); }); }); @@ -86,7 +99,10 @@ describe('ExperimentQueries', () => { describe('getExperimentSamples', () => { test('returns an array of experiment samples', async () => { const dummySamples = [DummyExperimentSample1]; - const result = await queries.getExperimentSamples(null, 1); + const result = await queries.getExperimentSamples( + dummyUserOfficerWithRole, + 1 + ); expect(result).toEqual(dummySamples); }); }); @@ -96,7 +112,7 @@ describe('ExperimentQueries', () => { // Use the experiments generated in init() const dummyExperiments = (dataSource as any).experiments; dataSource.getExperiments = () => Promise.resolve(dummyExperiments); - const result = await queries.getExperiments(null, { filter: {} }); + const result = await queries.getExperiments(dummyUserOfficerWithRole, {}); expect(result).toEqual(dummyExperiments); }); }); @@ -107,12 +123,15 @@ describe('ExperimentQueries', () => { const expected = (dataSource as any).experiments.find( (exp: any) => exp.experimentPk === experimentPk ); - const result = await queries.getExperiment(null, experimentPk); + const result = await queries.getExperiment( + dummyUserOfficerWithRole, + experimentPk + ); expect(result).toEqual(expected); }); test('returns null when experiment not found', async () => { - const result = await queries.getExperiment(null, 999); + const result = await queries.getExperiment(dummyUserOfficerWithRole, 999); expect(result).toBeNull(); }); }); diff --git a/apps/backend/src/queries/ExperimentQueries.ts b/apps/backend/src/queries/ExperimentQueries.ts index 991c565f6e..c4b7269add 100644 --- a/apps/backend/src/queries/ExperimentQueries.ts +++ b/apps/backend/src/queries/ExperimentQueries.ts @@ -1,15 +1,13 @@ import { inject, injectable } from 'tsyringe'; -import { Authorized } from 'type-graphql'; +import { UserAuthorization } from '../auth/UserAuthorization'; import { Tokens } from '../config/Tokens'; import { ExperimentDataSource } from '../datasources/ExperimentDataSource'; +import { Authorized } from '../decorators'; import { Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; import { ExperimentSampleArgs } from '../resolvers/queries/ExperimentSampleQuery'; -import { - ExperimentsArgs, - ExperimentsFilter, -} from '../resolvers/queries/ExperimentsQuery'; +import { ExperimentsFilter } from '../resolvers/queries/ExperimentsQuery'; import { Experiment } from '../resolvers/types/Experiment'; import { ExperimentHasSample } from '../resolvers/types/ExperimentHasSample'; import { ExperimentSafety } from '../resolvers/types/ExperimentSafety'; @@ -17,12 +15,19 @@ import { ExperimentSafety } from '../resolvers/types/ExperimentSafety'; @injectable() export default class ExperimentQueries { constructor( - @inject(Tokens.ExperimentDataSource) public dataSource: ExperimentDataSource + @inject(Tokens.ExperimentDataSource) + public dataSource: ExperimentDataSource, + @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization ) {} - @Authorized(Roles.USER_OFFICER, Roles.USER) + @Authorized([ + Roles.USER_OFFICER, + Roles.USER, + Roles.INSTRUMENT_SCIENTIST, + Roles.EXPERIMENT_SAFETY_REVIEWER, + ]) async getExperimentSafetyByExperimentPk( - user: UserWithRole | null, + agent: UserWithRole | null, experimentPk: number ): Promise { const experimentSafety = @@ -31,9 +36,14 @@ export default class ExperimentQueries { return experimentSafety; } - @Authorized(Roles.USER_OFFICER, Roles.USER) + @Authorized([ + Roles.USER_OFFICER, + Roles.USER, + Roles.INSTRUMENT_SCIENTIST, + Roles.EXPERIMENT_SAFETY_REVIEWER, + ]) async getExperimentSafety( - user: UserWithRole | null, + agent: UserWithRole | null, experimentSafetyPk: number ): Promise { const experimentSafety = @@ -42,9 +52,14 @@ export default class ExperimentQueries { return experimentSafety; } - @Authorized(Roles.USER_OFFICER, Roles.USER) + @Authorized([ + Roles.USER_OFFICER, + Roles.USER, + Roles.INSTRUMENT_SCIENTIST, + Roles.EXPERIMENT_SAFETY_REVIEWER, + ]) async getExperimentSample( - user: UserWithRole | null, + agent: UserWithRole | null, args: ExperimentSampleArgs ): Promise { const experimentSample = await this.dataSource.getExperimentSample( @@ -55,9 +70,14 @@ export default class ExperimentQueries { return experimentSample; } - @Authorized(Roles.USER_OFFICER, Roles.USER) + @Authorized([ + Roles.USER_OFFICER, + Roles.USER, + Roles.INSTRUMENT_SCIENTIST, + Roles.EXPERIMENT_SAFETY_REVIEWER, + ]) async getExperimentSamples( - user: UserWithRole | null, + agent: UserWithRole | null, experimentPk: number ): Promise { const experimentSamples = @@ -66,25 +86,28 @@ export default class ExperimentQueries { return experimentSamples; } - @Authorized(Roles.USER_OFFICER) + @Authorized([ + Roles.USER_OFFICER, + Roles.INSTRUMENT_SCIENTIST, + Roles.EXPERIMENT_SAFETY_REVIEWER, + ]) async getExperiments( - user: UserWithRole | null, - args: ExperimentsArgs - ): Promise { - return this.dataSource.getExperiments(args); - } - - @Authorized(Roles.USER_OFFICER) - async getAllExperiments( - user: UserWithRole | null, - filter?: ExperimentsFilter, + agent: UserWithRole | null, + filter: ExperimentsFilter = {}, first?: number, offset?: number, sortField?: string, sortDirection?: string, searchText?: string ) { - return this.dataSource.getAllExperiments( + let instrumentScientistUserId: number | undefined; + if (this.userAuth.isInstrumentScientist(agent)) { + instrumentScientistUserId = agent!.id; + } + + filter['instrumentScientistUserId'] = instrumentScientistUserId; + + return this.dataSource.getExperiments( filter, first, offset, @@ -94,9 +117,14 @@ export default class ExperimentQueries { ); } - @Authorized(Roles.USER_OFFICER) + @Authorized([ + Roles.USER_OFFICER, + Roles.INSTRUMENT_SCIENTIST, + Roles.EXPERIMENT_SAFETY_REVIEWER, + Roles.USER, + ]) async getExperiment( - user: UserWithRole | null, + agent: UserWithRole | null, experimentPk: number ): Promise { return this.dataSource.getExperiment(experimentPk); diff --git a/apps/backend/src/queries/FapQueries.spec.ts b/apps/backend/src/queries/FapQueries.spec.ts index b326e19cdd..fb04c0e117 100644 --- a/apps/backend/src/queries/FapQueries.spec.ts +++ b/apps/backend/src/queries/FapQueries.spec.ts @@ -73,6 +73,7 @@ describe('Test FapQueries', () => { fapId: 1, callId: 1, instrumentId: 1, + legacy: false, }) ).resolves.toStrictEqual([dummyFapProposal]); }); diff --git a/apps/backend/src/queries/FapQueries.ts b/apps/backend/src/queries/FapQueries.ts index b6ffb2ba0a..66bfbbe916 100644 --- a/apps/backend/src/queries/FapQueries.ts +++ b/apps/backend/src/queries/FapQueries.ts @@ -69,14 +69,45 @@ export default class FapQueries { fapId, callId, instrumentId, - }: { fapId: number; callId: number | null; instrumentId: number | null } + legacy, + }: { + fapId: number; + callId: number | null; + instrumentId: number | null; + legacy: boolean; + } ) { if ( this.userAuth.isApiToken(agent) || this.userAuth.isUserOfficer(agent) || (await this.userAuth.isMemberOfFap(agent, fapId)) ) { - return this.dataSource.getFapProposals({ fapId, callId, instrumentId }); + const fapProposals = legacy + ? await this.dataSource.getLegacyFapProposals({ + fapId, + callId, + instrumentId, + }) + : await this.dataSource.getFapProposals({ + fapId, + callId, + instrumentId, + }); + + // Remove users proposals from the list if the user is a FAP Reviewer + // This ensures that Reviewers cannot see reviews of there own proposals + // which might contain sensitive information + if (agent?.currentRole?.shortCode === Roles.FAP_REVIEWER) { + const usersProposals = ( + await this.proposalDataSource.getUserProposals(agent.id) + ).map((p) => p.primaryKey); + + return fapProposals.filter( + (fp) => !usersProposals.includes(fp.proposalPk) + ); + } + + return fapProposals; } else { return null; } diff --git a/apps/backend/src/queries/InviteQueries.ts b/apps/backend/src/queries/InviteQueries.ts index 8bb498d4d3..531b1f0a05 100644 --- a/apps/backend/src/queries/InviteQueries.ts +++ b/apps/backend/src/queries/InviteQueries.ts @@ -2,6 +2,7 @@ import { inject, injectable } from 'tsyringe'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; import { UserAuthorization } from '../auth/UserAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { Tokens } from '../config/Tokens'; import { InviteDataSource } from '../datasources/InviteDataSource'; import { Authorized } from '../decorators'; @@ -14,7 +15,8 @@ export default class InviteQueries { public dataSource: InviteDataSource, @inject(Tokens.ProposalAuthorization) private proposalAuth: ProposalAuthorization, - @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization + @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, + @inject(Tokens.VisitAuthorization) private visitAuth: VisitAuthorization ) {} @Authorized() @@ -34,4 +36,25 @@ export default class InviteQueries { return invites; } + + @Authorized() + async getVisitRegistrationInvites( + agent: UserWithRole | null, + visitId: number + ) { + const hasReadRights = + this.userAuth.isApiToken(agent) || + this.visitAuth.hasReadRights(agent, visitId); + + if (!hasReadRights) { + return []; + } + + const invites = await this.dataSource.findVisitRegistrationInvites( + visitId, + false + ); + + return invites; + } } diff --git a/apps/backend/src/queries/WorkflowQueries.ts b/apps/backend/src/queries/WorkflowQueries.ts index f7efef20e5..f867f28e39 100644 --- a/apps/backend/src/queries/WorkflowQueries.ts +++ b/apps/backend/src/queries/WorkflowQueries.ts @@ -7,10 +7,6 @@ import { MailService } from '../eventHandlers/MailService/MailService'; import { Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; import { WorkflowType } from '../models/Workflow'; -import { - WorkflowConnection, - WorkflowConnectionWithStatus, -} from '../models/WorkflowConnections'; @injectable() export default class WorkflowQueries { @@ -22,9 +18,17 @@ export default class WorkflowQueries { ) {} @Authorized([Roles.USER_OFFICER]) - async getWorkflow(agent: UserWithRole | null, id: number) { + async getWorkflow( + agent: UserWithRole | null, + id: number, + entityType: WorkflowType + ) { const workflow = await this.dataSource.getWorkflow(id); + if (workflow?.entityType !== entityType) { + return null; + } + return workflow; } @@ -35,58 +39,9 @@ export default class WorkflowQueries { return workflows; } - getUniqueDroppableGroupIds(list: WorkflowConnection[]) { - const flags = new Set(); - - return ( - list - .map((item) => ({ - droppableGroupId: item.droppableGroupId, - prevStatusId: item.prevStatusId, - })) - // remove duplicates - .filter((item) => { - if (flags.has(item.droppableGroupId)) { - return false; - } - flags.add(item.droppableGroupId); - - return true; - }) - ); - } - - groupWorkflowConnectionsByDroppableArea( - workflowConnections: WorkflowConnectionWithStatus[] - ) { - const groupedWorkflowConnections = this.getUniqueDroppableGroupIds( - workflowConnections - ).map((item) => ({ - groupId: item.droppableGroupId, - parentGroupId: - workflowConnections.find( - (element) => element.statusId === item.prevStatusId - )?.droppableGroupId || null, - connections: workflowConnections.filter( - (workflowConnection) => - workflowConnection.droppableGroupId === item.droppableGroupId - ), - })); - - return groupedWorkflowConnections; - } - @Authorized() - async workflowConnectionGroups( - agent: UserWithRole | null, - workflowId: number - ) { - const workflowConnections = - await this.dataSource.getWorkflowConnections(workflowId); - const groupedWorkflowConnections = - this.groupWorkflowConnectionsByDroppableArea(workflowConnections); - - return groupedWorkflowConnections; + async getWorkflowConnections(agent: UserWithRole | null, workflowId: number) { + return this.dataSource.getWorkflowConnections(workflowId); } @Authorized([Roles.USER_OFFICER]) diff --git a/apps/backend/src/resolvers/mutations/AssignMembersToFapMutation.ts b/apps/backend/src/resolvers/mutations/AssignMembersToFapMutation.ts index 2cebaa7aef..9bc2bda6d2 100644 --- a/apps/backend/src/resolvers/mutations/AssignMembersToFapMutation.ts +++ b/apps/backend/src/resolvers/mutations/AssignMembersToFapMutation.ts @@ -20,6 +20,9 @@ export class FapReviewAssignmentInput { @Field(() => Int) public proposalPk: number; + + @Field(() => Int, { nullable: true }) + public rank?: number; } @ArgsType() diff --git a/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts b/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts index 9e569d6824..2db70eabdf 100644 --- a/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts +++ b/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts @@ -9,24 +9,41 @@ import { } from 'type-graphql'; import { ResolverContext } from '../../context'; +import { isRejection, rejection } from '../../models/Rejection'; import { Visit } from '../types/Visit'; @ArgsType() export class CreateVisitArgs { - @Field(() => Int) + @Field(() => Int!) experimentPk: number; - @Field(() => [Int]) + @Field(() => [Int!]) team: number[]; @Field(() => Int) teamLeadUserId: number; + + @Field(() => [String!], { nullable: true }) + inviteEmails?: string[]; } @Resolver() export class CreateVisitMutation { @Mutation(() => Visit) - createVisit(@Args() args: CreateVisitArgs, @Ctx() context: ResolverContext) { - return context.mutations.visit.createVisit(context.user, args); + async createVisit( + @Args() args: CreateVisitArgs, + @Ctx() context: ResolverContext + ) { + const visit = await context.mutations.visit.createVisit(context.user, args); + if (isRejection(visit)) { + return rejection('CREATE_VISIT_FAILED'); + } + + await context.mutations.invite.setVisitRegistrationInvites(context.user, { + visitId: visit.id, + emails: args.inviteEmails ?? [], + }); + + return visit; } } diff --git a/apps/backend/src/resolvers/mutations/NotifyProposalMutation.ts b/apps/backend/src/resolvers/mutations/NotifyProposalMutation.ts index 6af10fb03e..4f0f9120a7 100644 --- a/apps/backend/src/resolvers/mutations/NotifyProposalMutation.ts +++ b/apps/backend/src/resolvers/mutations/NotifyProposalMutation.ts @@ -15,6 +15,8 @@ import { Proposal } from '../types/Proposal'; export class NotifyProposalArgs { @Field(() => Int) public proposalPk: number; + @Field(() => Boolean) + public ignoreNotifiedFlag: boolean; } @Resolver() @@ -25,8 +27,6 @@ export class NotifyProposalMutation { args: NotifyProposalArgs, @Ctx() context: ResolverContext ) { - return context.mutations.proposal.notify(context.user, { - proposalPk: args.proposalPk, - }); + return context.mutations.proposal.notify(context.user, args); } } diff --git a/apps/backend/src/resolvers/mutations/UpdateDataAccessUsersMutation.ts b/apps/backend/src/resolvers/mutations/UpdateDataAccessUsersMutation.ts new file mode 100644 index 0000000000..4dc5a298f3 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/UpdateDataAccessUsersMutation.ts @@ -0,0 +1,38 @@ +import { + Args, + ArgsType, + Ctx, + Field, + Int, + Mutation, + Resolver, +} from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { UpdateDataAccessUsersArgs as UpdateDataAccessUsersArgsType } from '../../mutations/DataAccessUsersMutations'; +import { BasicUserDetails } from '../types/BasicUserDetails'; + +@ArgsType() +export class UpdateDataAccessUsersArgs + implements UpdateDataAccessUsersArgsType +{ + @Field(() => Int) + public proposalPk: number; + + @Field(() => [Int]) + public userIds: number[]; +} + +@Resolver() +export class UpdateDataAccessUsersMutation { + @Mutation(() => [BasicUserDetails]) + updateDataAccessUsers( + @Args() updateDataAccessUsersArgs: UpdateDataAccessUsersArgs, + @Ctx() context: ResolverContext + ) { + return context.mutations.dataAccessUsers.updateDataAccessUsers( + context.user, + updateDataAccessUsersArgs + ); + } +} diff --git a/apps/backend/src/resolvers/mutations/UpdateReviewMutation.ts b/apps/backend/src/resolvers/mutations/UpdateReviewMutation.ts index 396462073e..13fa2f6e5c 100644 --- a/apps/backend/src/resolvers/mutations/UpdateReviewMutation.ts +++ b/apps/backend/src/resolvers/mutations/UpdateReviewMutation.ts @@ -3,7 +3,6 @@ import { ArgsType, Ctx, Field, - Float, Int, Mutation, Resolver, @@ -21,11 +20,11 @@ export class UpdateReviewArgs { @Field() public comment: string; - @Field(() => Float) - public grade: number; + @Field(() => String) + public grade: string; @Field(() => ReviewStatus) - status: ReviewStatus; + public status: ReviewStatus; @Field(() => Int) public fapID: number; diff --git a/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts b/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts index cb805e70ec..304ec46859 100644 --- a/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts +++ b/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts @@ -21,12 +21,28 @@ export class UpdateVisitArgs { @Field(() => Int, { nullable: true }) teamLeadUserId?: number; + + @Field(() => [String!], { nullable: true }) + inviteEmails?: string[]; } @Resolver() export class UpdateVisitMutation { @Mutation(() => Visit) - updateVisit(@Args() args: UpdateVisitArgs, @Ctx() context: ResolverContext) { - return context.mutations.visit.updateVisit(context.user, args); + async updateVisit( + @Args() args: UpdateVisitArgs, + @Ctx() context: ResolverContext + ) { + const result = await context.mutations.visit.updateVisit( + context.user, + args + ); + + await context.mutations.invite.setVisitRegistrationInvites(context.user, { + visitId: args.visitId, + emails: args.inviteEmails ?? [], + }); + + return result; } } diff --git a/apps/backend/src/resolvers/mutations/settings/AddWorkflowStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/AddWorkflowStatusMutation.ts index f27cd46cf4..21c24a4972 100644 --- a/apps/backend/src/resolvers/mutations/settings/AddWorkflowStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/AddWorkflowStatusMutation.ts @@ -19,12 +19,6 @@ export class AddWorkflowStatusInput implements Partial { @Field(() => Int) public sortOrder: number; - @Field(() => String) - public droppableGroupId: string; - - @Field(() => String, { nullable: true }) - public parentDroppableGroupId: string | null; - @Field(() => Int) public statusId: number; @@ -33,6 +27,15 @@ export class AddWorkflowStatusInput implements Partial { @Field(() => Int, { nullable: true }) public prevStatusId: number | null; + + @Field(() => Int) + public posX: number; + + @Field(() => Int) + public posY: number; + + @Field(() => Int, { nullable: true }) + public prevConnectionId: number | null; } @Resolver() diff --git a/apps/backend/src/resolvers/mutations/settings/DeleteWorkflowConnectionMutation.ts b/apps/backend/src/resolvers/mutations/settings/DeleteWorkflowConnectionMutation.ts new file mode 100644 index 0000000000..077e74172a --- /dev/null +++ b/apps/backend/src/resolvers/mutations/settings/DeleteWorkflowConnectionMutation.ts @@ -0,0 +1,18 @@ +import { Arg, Ctx, Int, Mutation, Resolver } from 'type-graphql'; + +import { ResolverContext } from '../../../context'; +import { WorkflowConnection } from '../../types/WorkflowConnection'; + +@Resolver() +export class DeleteWorkflowConnectionMutation { + @Mutation(() => WorkflowConnection, { nullable: true }) + async deleteWorkflowConnection( + @Arg('connectionId', () => Int) connectionId: number, + @Ctx() context: ResolverContext + ) { + return context.mutations.workflow.deleteWorkflowConnection( + context.user, + connectionId + ); + } +} diff --git a/apps/backend/src/resolvers/mutations/settings/MoveWorkflowStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/MoveWorkflowStatusMutation.ts deleted file mode 100644 index ed24f49cd3..0000000000 --- a/apps/backend/src/resolvers/mutations/settings/MoveWorkflowStatusMutation.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - Ctx, - Mutation, - Resolver, - Field, - InputType, - Arg, - Int, -} from 'type-graphql'; - -import { ResolverContext } from '../../../context'; -import { WorkflowConnection } from '../../types/WorkflowConnection'; - -@InputType() -export class IndexWithGroupId { - @Field(() => Int) - public index: number; - - @Field(() => String) - public droppableId: string; -} - -@InputType() -export class MoveWorkflowStatusInput { - @Field(() => IndexWithGroupId) - public from: IndexWithGroupId; - - @Field(() => IndexWithGroupId) - public to: IndexWithGroupId; - - @Field(() => Int) - public workflowId: number; -} - -@Resolver() -export class MoveWorkflowStatusMutation { - @Mutation(() => WorkflowConnection) - async moveWorkflowStatus( - @Ctx() context: ResolverContext, - @Arg('moveWorkflowStatusInput') - moveWorkflowStatusInput: MoveWorkflowStatusInput - ) { - return context.mutations.workflow.moveWorkflowStatus( - context.user, - moveWorkflowStatusInput - ); - } -} diff --git a/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts index c82dc5ff85..e907bbc495 100644 --- a/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts @@ -12,21 +12,21 @@ import { ResolverContext } from '../../../context'; import { Status } from '../../types/Status'; @InputType() -export class UpdateStatusInput implements Omit { +export class UpdateStatusInput { @Field(() => Int) public id: number; @Field(() => String, { nullable: true }) - public shortCode: string; + public shortCode?: string; - @Field(() => String) - public name: string; + @Field(() => String, { nullable: true }) + public name?: string; - @Field(() => String) - public description: string; + @Field(() => String, { nullable: true }) + public description?: string; @Field(() => Boolean, { nullable: true }) - public isDefault: boolean; + public isDefault?: boolean; } @Resolver() diff --git a/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowMutation.ts b/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowMutation.ts index 174f8674b9..f2d8a3c614 100644 --- a/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowMutation.ts @@ -12,15 +12,18 @@ import { ResolverContext } from '../../../context'; import { Workflow } from '../../types/Workflow'; @InputType() -export class UpdateWorkflowInput implements Omit { +export class UpdateWorkflowInput implements Partial { @Field(() => Int) public id: number; - @Field(() => String) - public name: string; + @Field(() => String, { nullable: true }) + public name?: string; - @Field(() => String) - public description: string; + @Field(() => String, { nullable: true }) + public description?: string; + + @Field(() => String, { nullable: true }) + public connectionLineType?: string; } @Resolver() diff --git a/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowStatusMutation.ts new file mode 100644 index 0000000000..5678048621 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowStatusMutation.ts @@ -0,0 +1,48 @@ +import { + Ctx, + Mutation, + Resolver, + Field, + InputType, + Arg, + Int, +} from 'type-graphql'; + +import { ResolverContext } from '../../../context'; +import { WorkflowConnection } from '../../types/WorkflowConnection'; + +@InputType() +export class UpdateWorkflowStatusInput { + @Field(() => Int) + public id: number; + + @Field(() => Int, { nullable: true }) + public posX?: number; + + @Field(() => Int, { nullable: true }) + public posY?: number; + + @Field(() => Int, { nullable: true }) + public prevConnectionId?: number; + + @Field(() => Int, { nullable: true }) + public prevStatusId?: number; + + @Field(() => Int, { nullable: true }) + public nextStatusId?: number; +} + +@Resolver() +export class UpdateWorkflowStatusMutation { + @Mutation(() => WorkflowConnection) + async updateWorkflowStatus( + @Ctx() context: ResolverContext, + @Arg('updateWorkflowStatusInput') + updateWorkflowStatusInput: UpdateWorkflowStatusInput + ) { + return context.mutations.workflow.updateWorkflowStatus( + context.user, + updateWorkflowStatusInput + ); + } +} diff --git a/apps/backend/src/resolvers/queries/DataAccessUsersQuery.ts b/apps/backend/src/resolvers/queries/DataAccessUsersQuery.ts index 83119b6d20..e29f79d053 100644 --- a/apps/backend/src/resolvers/queries/DataAccessUsersQuery.ts +++ b/apps/backend/src/resolvers/queries/DataAccessUsersQuery.ts @@ -10,7 +10,9 @@ export class DataAccessUsersQuery { @Ctx() context: ResolverContext, @Arg('proposalPk', () => Int) proposalPk: number ): Promise { - // TODO: This is a placeholder implementation. Replace with actual data fetching logic. - return []; + return context.queries.dataAccessUsers.findByProposalPk( + context.user, + proposalPk + ); } } diff --git a/apps/backend/src/resolvers/queries/ExperimentsQuery.ts b/apps/backend/src/resolvers/queries/ExperimentsQuery.ts index 07b19ef0a7..c0b4e4cffe 100644 --- a/apps/backend/src/resolvers/queries/ExperimentsQuery.ts +++ b/apps/backend/src/resolvers/queries/ExperimentsQuery.ts @@ -42,6 +42,9 @@ export class ExperimentsFilter { @Field(() => Int, { nullable: true }) public experimentSafetyStatusId?: number; + + @Field(() => Int, { nullable: true }) + public instrumentScientistUserId?: number; } @ArgsType() @@ -99,7 +102,7 @@ export class ExperimentsQuery { @Args() args: ExperimentsArgs, @Ctx() context: ResolverContext ): Promise { - return context.queries.experiment.getAllExperiments( + return context.queries.experiment.getExperiments( context.user, args.filter, args.first, diff --git a/apps/backend/src/resolvers/queries/FapQuery.ts b/apps/backend/src/resolvers/queries/FapQuery.ts index 6170ef482b..6e817b25ec 100644 --- a/apps/backend/src/resolvers/queries/FapQuery.ts +++ b/apps/backend/src/resolvers/queries/FapQuery.ts @@ -37,12 +37,14 @@ export class FapQuery { @Arg('callId', () => Int, { nullable: true }) callId: number | null, @Arg('instrumentId', () => Int, { nullable: true }) instrumentId: number | null, + @Arg('legacy', () => Boolean, { nullable: true }) legacy: boolean, @Ctx() context: ResolverContext ): Promise { return context.queries.fap.getFapProposals(context.user, { fapId, callId, instrumentId, + legacy, }); } diff --git a/apps/backend/src/resolvers/queries/WorkflowQuery.ts b/apps/backend/src/resolvers/queries/WorkflowQuery.ts index 7001616139..d6218b765c 100644 --- a/apps/backend/src/resolvers/queries/WorkflowQuery.ts +++ b/apps/backend/src/resolvers/queries/WorkflowQuery.ts @@ -8,6 +8,9 @@ import { Workflow, WorkflowEvent } from '../types/Workflow'; export class WorkflowArgs { @Field(() => Int) workflowId: number; + + @Field(() => WorkflowType) + entityType: WorkflowType; } @ArgsType() @@ -20,7 +23,11 @@ export class WorkflowsArgs { export class WorkflowQuery { @Query(() => Workflow, { nullable: true }) workflow(@Args() args: WorkflowArgs, @Ctx() context: ResolverContext) { - return context.queries.workflow.getWorkflow(context.user, args.workflowId); + return context.queries.workflow.getWorkflow( + context.user, + args.workflowId, + args.entityType + ); } @Query(() => [Workflow], { nullable: true }) diff --git a/apps/backend/src/resolvers/types/FieldConfig.ts b/apps/backend/src/resolvers/types/FieldConfig.ts index 0c2616fb4e..89085d7d14 100644 --- a/apps/backend/src/resolvers/types/FieldConfig.ts +++ b/apps/backend/src/resolvers/types/FieldConfig.ts @@ -58,7 +58,19 @@ export class VisitBasisConfig extends ConfigBase {} export class ExperimentSafetyReviewBasisConfig extends ConfigBase {} @ObjectType() -export class FapReviewBasisConfig extends ConfigBase {} +export class FapReviewBasisConfig extends ConfigBase { + @Field(() => [String]) + nonNumericOptions: string[]; + + @Field(() => Int) + minGrade: number; + + @Field(() => Int) + maxGrade: number; + + @Field(() => Int) + decimalPoints: number; +} @ObjectType() export class TechnicalReviewBasisConfig extends ConfigBase {} @@ -277,6 +289,9 @@ export class SampleDeclarationConfig extends SubTemplateBaseConfig { export class IntervalConfig extends ConfigBase { @Field(() => [Unit]) units: Unit[]; + + @Field(() => NumberValueConstraint, { nullable: true }) + numberValueConstraint: NumberValueConstraint | null; } export enum NumberValueConstraint { diff --git a/apps/backend/src/resolvers/types/Review.ts b/apps/backend/src/resolvers/types/Review.ts index 13aaf136fd..1ba2565022 100644 --- a/apps/backend/src/resolvers/types/Review.ts +++ b/apps/backend/src/resolvers/types/Review.ts @@ -2,7 +2,6 @@ import { Ctx, Field, FieldResolver, - Float, Int, ObjectType, Resolver, @@ -27,8 +26,8 @@ export class Review implements Partial { @Field({ nullable: true }) public comment?: string; - @Field(() => Float, { nullable: true }) - public grade?: number; + @Field(() => String, { nullable: true }) + public grade?: string; @Field(() => ReviewStatus) public status: ReviewStatus; diff --git a/apps/backend/src/resolvers/types/Visit.ts b/apps/backend/src/resolvers/types/Visit.ts index 5534f54007..b089e37ee3 100644 --- a/apps/backend/src/resolvers/types/Visit.ts +++ b/apps/backend/src/resolvers/types/Visit.ts @@ -11,6 +11,7 @@ import { import { ResolverContext } from '../../context'; import { Visit as VisitOrigin } from '../../models/Visit'; import { BasicUserDetails } from './BasicUserDetails'; +import { Invite } from './Invite'; import { Proposal } from './Proposal'; import { Sample } from './Sample'; import { VisitRegistration } from './VisitRegistration'; @@ -53,6 +54,17 @@ export class VisitResolver { }); } + @FieldResolver(() => [Invite]) + async registrationInvites( + @Root() visit: Visit, + @Ctx() context: ResolverContext + ): Promise { + return context.queries.invite.getVisitRegistrationInvites( + context.user, + visit.id + ); + } + @FieldResolver(() => BasicUserDetails) async teamLead( @Root() visit: Visit, diff --git a/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts b/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts new file mode 100644 index 0000000000..32c26d2809 --- /dev/null +++ b/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts @@ -0,0 +1,14 @@ +import { Field, Int, ObjectType } from 'type-graphql'; + +import { VisitRegistrationClaim as VisitRegistrationClaimOrig } from '../../models/VisitRegistrationClaim'; + +@ObjectType() +export class VisitRegistrationClaim + implements Partial +{ + @Field(() => Int) + public userId: number; + + @Field(() => Int) + public visitId: number; +} diff --git a/apps/backend/src/resolvers/types/Workflow.ts b/apps/backend/src/resolvers/types/Workflow.ts index 096c40af80..e77b67cbd6 100644 --- a/apps/backend/src/resolvers/types/Workflow.ts +++ b/apps/backend/src/resolvers/types/Workflow.ts @@ -15,7 +15,7 @@ import { Workflow as WorkflowOrigin, WorkflowType, } from '../../models/Workflow'; -import { WorkflowConnectionGroup } from './WorkflowConnection'; +import { WorkflowConnection } from './WorkflowConnection'; @ObjectType() export class Workflow implements Partial { @@ -30,16 +30,19 @@ export class Workflow implements Partial { @Field(() => WorkflowType) public entityType: WorkflowType; + + @Field(() => String) + public connectionLineType: string; } @Resolver(() => Workflow) export class WorkflowResolver { - @FieldResolver(() => [WorkflowConnectionGroup]) - async workflowConnectionGroups( + @FieldResolver(() => [WorkflowConnection]) + async workflowConnections( @Root() workflow: Workflow, @Ctx() context: ResolverContext - ): Promise { - const connections = await context.queries.workflow.workflowConnectionGroups( + ): Promise { + const connections = await context.queries.workflow.getWorkflowConnections( context.user, workflow.id ); diff --git a/apps/backend/src/resolvers/types/WorkflowConnection.ts b/apps/backend/src/resolvers/types/WorkflowConnection.ts index 1493d77cef..2cefe1597e 100644 --- a/apps/backend/src/resolvers/types/WorkflowConnection.ts +++ b/apps/backend/src/resolvers/types/WorkflowConnection.ts @@ -40,20 +40,14 @@ export class WorkflowConnection @Field(() => Int, { nullable: true }) public prevStatusId: number | null; - @Field() - public droppableGroupId: string; -} - -@ObjectType() -export class WorkflowConnectionGroup { - @Field(() => String) - public groupId: string; + @Field(() => Int) + public posX: number; - @Field(() => String, { nullable: true }) - public parentGroupId: string | null; + @Field(() => Int) + public posY: number; - @Field(() => [WorkflowConnection]) - public connections: WorkflowConnection[]; + @Field(() => Int, { nullable: true }) + public prevConnectionId: number | null; } @Resolver(() => WorkflowConnection) diff --git a/apps/backend/src/utils/mathFunctions.ts b/apps/backend/src/utils/mathFunctions.ts index 95942e1e0f..e50901fb96 100644 --- a/apps/backend/src/utils/mathFunctions.ts +++ b/apps/backend/src/utils/mathFunctions.ts @@ -14,7 +14,7 @@ export const average = (numbers: number[]) => { export const getGrades = (reviews: Review[] | null | undefined) => reviews ?.filter((review) => review.status === ReviewStatus.SUBMITTED) - .map((review) => review.grade as number) ?? []; + .map((review) => parseFloat(review.grade)) ?? []; export const sortByRankOrder = ( a: FapProposalWithReviewGradesAndRanking, diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 40164472f8..105607f331 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -23,7 +23,7 @@ const getProposalWorkflowByCallId = (callId: number) => { export const getProposalWorkflowConnectionByStatusId = ( workflowId: number, - statusId: number, + statusId?: number, prevStatusId?: number ) => { const workflowDataSource = container.resolve( @@ -181,81 +181,80 @@ export const workflowEngine = async ( * We store one record of FEASIBILITY_REVIEW with nextStatusId = FAP_SELECTION and another one with nextStatusId = NOT_FEASIBLE. * We go through each record and based on the currentEvent we move the proposal into the right direction */ - return Promise.all( - currentWorkflowConnections.map(async (currentWorkflowConnection) => { - if (!currentWorkflowConnection.nextStatusId) { - return; - } - - if (!proposalWithEvents.proposalEvents) { - return; - } + const response = await Promise.all( + currentWorkflowConnections.map(async (currentWorkflowConnection) => { const nextWorkflowConnections = await getProposalWorkflowConnectionByStatusId( proposalWorkflow.id, - currentWorkflowConnection.nextStatusId, + undefined, currentWorkflowConnection.statusId ); - if (!nextWorkflowConnections?.length) { - return; - } - - const workflowDataSource = container.resolve( - Tokens.WorkflowDataSource + return Promise.all( + nextWorkflowConnections.map(async (nextWorkflowConnection) => { + if (!proposalWithEvents.proposalEvents) { + return; + } + const workflowDataSource = + container.resolve( + Tokens.WorkflowDataSource + ); + + const statusChangingEvents = + await workflowDataSource.getStatusChangingEventsByConnectionIds( + [nextWorkflowConnection.id] + ); + + if (!statusChangingEvents) { + return; + } + + const eventThatTriggeredStatusChangeIsStatusChangingEvent = + statusChangingEvents.find( + (statusChangingEvent) => + proposalWithEvents.currentEvent === + statusChangingEvent.statusChangingEvent + ); + + if (!eventThatTriggeredStatusChangeIsStatusChangingEvent) { + return; + } + + if ( + shouldMoveToNextStatus( + statusChangingEvents, + proposalWithEvents.proposalEvents + ) + ) { + const updatedProposal = + await proposalDataSource.updateProposalStatus( + proposalWithEvents.proposalPk, + nextWorkflowConnection.statusId + ); + + if (updatedProposal) { + await checkIfConditionsForNextStatusAreMet({ + nextWorkflowConnections, + proposalWorkflow, + workflowDataSource, + proposalWithEvents, + }); + + return { + ...updatedProposal, + workflowId: proposalWorkflow.id, + prevStatusId: currentWorkflowConnection.statusId, + callShortCode: call.shortCode, + }; + } + } + }) ); - - const statusChangingEvents = - await workflowDataSource.getStatusChangingEventsByConnectionIds( - nextWorkflowConnections.map((connection) => connection.id) - ); - - if (!statusChangingEvents) { - return; - } - - const eventThatTriggeredStatusChangeIsStatusChangingEvent = - statusChangingEvents.find( - (statusChangingEvent) => - proposalWithEvents.currentEvent === - statusChangingEvent.statusChangingEvent - ); - - if (!eventThatTriggeredStatusChangeIsStatusChangingEvent) { - return; - } - - if ( - shouldMoveToNextStatus( - statusChangingEvents, - proposalWithEvents.proposalEvents - ) - ) { - const updatedProposal = - await proposalDataSource.updateProposalStatus( - proposalWithEvents.proposalPk, - currentWorkflowConnection.nextStatusId - ); - - if (updatedProposal) { - await checkIfConditionsForNextStatusAreMet({ - nextWorkflowConnections, - proposalWorkflow, - workflowDataSource, - proposalWithEvents, - }); - - return { - ...updatedProposal, - workflowId: proposalWorkflow.id, - prevStatusId: currentWorkflowConnection.statusId, - callShortCode: call.shortCode, - }; - } - } }) - ); + ).then((results) => results.flat()); + + return response; }) ) ).flat(); diff --git a/apps/e2e/cypress/e2e/FAPs.cy.ts b/apps/e2e/cypress/e2e/FAPs.cy.ts index fb72dd7ff2..abc851da0a 100644 --- a/apps/e2e/cypress/e2e/FAPs.cy.ts +++ b/apps/e2e/cypress/e2e/FAPs.cy.ts @@ -60,15 +60,8 @@ function readWriteReview( expect(content).to.have.string(commentContent) ); - if (settings.getEnabledSettings().get(SettingsId.GRADE_PRECISION) === '1') { - cy.get('@dialog').get('[data-cy="grade-proposal"]').click(); - - cy.get('[role="listbox"] > [role="option"]').first().click(); - - cy.get('[data-cy="grade-proposal"] input').should('have.value', '1'); - } else { - cy.get('@dialog').get('[data-cy="grade-proposal"]').click().type('1'); - } + cy.get('[data-cy="grade-proposal"]').click(); + cy.get('[data-cy="grade-proposal-options"] [role="option"]').first().click(); cy.get(`#comment_ifr`).first().focus().click(); @@ -195,6 +188,7 @@ let createdCallId: number; let firstCreatedProposalPk: number; let firstCreatedProposalId: string; let secondCreatedProposalPk: number; +let secondCreatedProposalId: string; let thirdCreatedProposalPk: number; let createdWorkflowId: number; let createdEsiTemplateId: number; @@ -219,11 +213,13 @@ function createWorkflowAndEsiTemplate() { 'FEASIBILITY' ) { cy.addWorkflowStatus({ - droppableGroupId: 'proposalWorkflowConnections_0', statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: createdWorkflowId, sortOrder: 1, prevStatusId: 1, + posX: 0, + posY: 200, + prevConnectionId: 1, }); } @@ -309,6 +305,7 @@ function initializationBeforeTests() { const createdProposal = result.createProposal; if (createdProposal) { secondCreatedProposalPk = createdProposal.primaryKey; + secondCreatedProposalId = createdProposal.proposalId; cy.updateProposal({ proposalPk: createdProposal.primaryKey, @@ -578,7 +575,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); cy.contains('1 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -681,6 +678,77 @@ context('Fap reviews tests', () => { cy.get('[index="0"]').children().contains(fapMembers.reviewer.lastName); }); + it('Officer should be able to assign ranks to reviewers during mass assignment', () => { + cy.assignProposalsToFaps({ + fapInstruments: [ + { instrumentId: newlyCreatedInstrumentId, fapId: createdFapId }, + ], + proposalPks: [firstCreatedProposalPk, secondCreatedProposalPk], + }); + + cy.assignReviewersToFap({ + fapId: createdFapId, + memberIds: [fapMembers.reviewer.id, fapMembers.reviewer2.id], + }); + + cy.login('officer'); + cy.visit(`/FapPage/${createdFapId}?tab=3`); + + cy.get('[type="checkbox"]').first().check(); + + cy.get('[data-cy="assign-fap-members"]').click(); + + cy.get('[role="dialog"]') + .contains(fapMembers.reviewer.lastName) + .parent() + .find('input[type="checkbox"]') + .click(); + + cy.get('[role="dialog"]') + .contains(fapMembers.reviewer2.lastName) + .parent() + .find('input[type="checkbox"]') + .click(); + + cy.get('[data-cy="assign-selected-users-with-rank"]').click(); + + cy.contains('Submit Mass Assignments'); + + cy.get(`[data-cy="rank-${fapMembers.reviewer.lastName}"]`) + .first() + .type('1'); + + cy.get(`[data-cy="rank-${fapMembers.reviewer2.lastName}"]`) + .first() + .type('2'); + + cy.get('[data-cy="save-ranks"]').click(); + + clickConfirmOk(); + + //Do second one first as the first chevron tooltip covers the second one + cy.contains(secondCreatedProposalId) + .closest('tr') + .find('[data-testid="ChevronRightIcon"]') + .click(); + + cy.contains(fapMembers.reviewer.lastName).parent().contains('1'); + cy.contains(fapMembers.reviewer2.lastName).parent().contains('2'); + + cy.contains(secondCreatedProposalId) + .closest('tr') + .find('[data-testid="ChevronRightIcon"]') + .click(); + + cy.contains(firstCreatedProposalId) + .closest('tr') + .find('[data-testid="ChevronRightIcon"]') + .click(); + + cy.contains(fapMembers.reviewer.lastName).parent().contains('1'); + cy.contains(fapMembers.reviewer2.lastName).parent().contains('2'); + }); + it('Should be able to assign Fap members to proposals in existing Fap', () => { cy.assignProposalsToFaps({ fapInstruments: [ @@ -707,7 +775,7 @@ context('Fap reviews tests', () => { cy.get('[role="dialog"]').find('input[type="checkbox"]').first().click(); cy.contains('2 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -766,7 +834,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); cy.contains('1 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -823,7 +891,7 @@ context('Fap reviews tests', () => { cy.get('[role="dialog"]').find('input[type="checkbox"]').first().click(); cy.contains('2 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -1113,7 +1181,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -1208,7 +1276,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); cy.contains('1 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -1365,7 +1433,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); cy.contains('1 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -1518,7 +1586,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); cy.contains('1 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -1598,7 +1666,7 @@ context('Fap reviews tests', () => { cy.updateReview({ reviewID: proposalReviews[0].id, comment: comment1, - grade: 2, + grade: '2', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: proposalReviews[0].questionaryID, @@ -1705,16 +1773,10 @@ context('Fap reviews tests', () => { cy.setTinyMceContent('comment', faker.lorem.words(3)); - if ( - settings.getEnabledSettings().get(SettingsId.GRADE_PRECISION) === '1' - ) { - cy.get('[data-cy="grade-proposal"]').click(); - cy.get('[data-cy="grade-proposal-options"] [role="option"]') - .first() - .click(); - } else { - cy.get('[data-cy="grade-proposal"]').click().click().type('1'); - } + cy.get('[data-cy="grade-proposal"]').click(); + cy.get('[data-cy="grade-proposal-options"] [role="option"]') + .first() + .click(); cy.get(`#comment_ifr`).first().focus().click(); @@ -1802,7 +1864,7 @@ context('Fap reviews tests', () => { .find('[aria-label="Edit"]') .click(); - cy.get('[role="tablist"] [role="tab"]').should('have.length', 2); + cy.get('[role="tablist"] [role="tab"]').should('have.length', 3); cy.finishedLoading(); @@ -1838,6 +1900,76 @@ context('Fap reviews tests', () => { cy.contains(comment1).should('not.exist'); }); + + it('Fap Reviewer should be see legacy FAP proposals page', () => { + cy.createCall({ + ...updatedCall, + shortCode: 'legacy call', + esiTemplateId: createdEsiTemplateId, + proposalWorkflowId: createdWorkflowId, + faps: [createdFapId], + }).then((result) => { + createdCallId = result.createCall.id; + + cy.assignInstrumentToCall({ + callId: createdCallId, + instrumentFapIds: { instrumentId: newlyCreatedInstrumentId }, + }); + + cy.createProposal({ callId: createdCallId }).then((result) => { + const createdProposal = result.createProposal; + + if (createdProposal) { + secondCreatedProposalPk = createdProposal.primaryKey; + + cy.updateProposal({ + proposalPk: createdProposal.primaryKey, + title: proposal2.title, + abstract: proposal2.abstract, + proposerId: initialDBData.users.user1.id, + }); + + cy.submitProposal({ proposalPk: createdProposal.primaryKey }); + + cy.changeProposalsStatus({ + statusId: initialDBData.proposalStatuses.finished.id, + proposalPks: [secondCreatedProposalPk], + }); + + cy.assignProposalsToInstruments({ + instrumentIds: [newlyCreatedInstrumentId], + proposalPks: [secondCreatedProposalPk], + }); + cy.assignProposalsToFaps({ + fapInstruments: [ + { instrumentId: newlyCreatedInstrumentId, fapId: createdFapId }, + ], + proposalPks: [createdProposal.primaryKey], + }); + } + + cy.updateCall({ + id: createdCallId, + ...closedCall, + shortCode: 'legacy call', + proposalWorkflowId: createdWorkflowId, + esiTemplateId: createdEsiTemplateId, + faps: [createdFapId], + callFapReviewEnded: true, + }); + + cy.visit('/FapPage/' + createdFapId); + + cy.contains(firstCreatedProposalId).should('exist'); + cy.contains(createdProposal.proposalId).should('not.exist'); + + cy.contains('Legacy Proposals').click(); + + cy.contains(createdProposal.proposalId).should('exist'); + cy.contains(firstCreatedProposalId).should('not.exist'); + }); + }); + }); }); }); @@ -2227,7 +2359,7 @@ context('Fap meeting components tests', () => { reviewID: review.id, comment: comment1, // NOTE: Make first proposal with lower standard deviation. Grades are 2 and 4 - grade: index ? 2 : 4, + grade: index ? '2' : '4', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: review.questionaryID, @@ -2245,7 +2377,7 @@ context('Fap meeting components tests', () => { reviewID: review.id, comment: comment2, // NOTE: Make second proposal with higher standard deviation. Grades are 1 and 5 - grade: index ? 1 : 5, + grade: index ? '1' : '5', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: review.questionaryID, @@ -2805,7 +2937,7 @@ context('Fap meeting components tests', () => { reviewID: review.id, comment: comment1, // NOTE: Make first proposal with lower standard deviation. Grades are 2 and 4 - grade: index ? 2 : 4, + grade: index ? '2' : '4', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: review.questionaryID, @@ -2823,7 +2955,7 @@ context('Fap meeting components tests', () => { reviewID: review.id, comment: comment2, // NOTE: Make second proposal with higher standard deviation. Grades are 1 and 5 - grade: index ? 1 : 5, + grade: index ? '1' : '5', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: review.questionaryID, @@ -3130,17 +3262,10 @@ context('Fap meeting components tests', () => { const commentContent = faker.lorem.words(3); cy.setTinyMceContent('comment', commentContent); - if ( - settings.getEnabledSettings().get(SettingsId.GRADE_PRECISION) === '1' - ) { - cy.get('@dialog').get('[data-cy="grade-proposal"]').click(); - - cy.get('[role="listbox"] > [role="option"]').first().click(); - - cy.get('[data-cy="grade-proposal"] input').should('have.value', '1'); - } else { - cy.get('@dialog').get('[data-cy="grade-proposal"]').click().type('1'); - } + cy.get('[data-cy="grade-proposal"]').click(); + cy.get('[data-cy="grade-proposal-options"] [role="option"]') + .first() + .click(); cy.get(`#comment_ifr`).first().focus().click(); @@ -3397,7 +3522,7 @@ context('Fap meeting components tests', () => { reviewID: review.id, comment: comment1, // NOTE: Make first proposal with lower standard deviation. Grades are 2 and 4 - grade: index ? 2 : 4, + grade: index ? '2' : '4', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: review.questionaryID, @@ -3415,7 +3540,7 @@ context('Fap meeting components tests', () => { reviewID: review.id, comment: comment2, // NOTE: Make second proposal with higher standard deviation. Grades are 1 and 5 - grade: index ? 1 : 5, + grade: index ? '1' : '5', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: review.questionaryID, @@ -3631,7 +3756,7 @@ context('Fap meeting components tests', () => { reviewID: review.id, comment: comment1, // NOTE: Make first proposal with lower standard deviation. Grades are 2 and 4 - grade: index ? 2 : 4, + grade: index ? '2' : '4', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: review.questionaryID, @@ -3649,7 +3774,7 @@ context('Fap meeting components tests', () => { reviewID: review.id, comment: comment2, // NOTE: Make second proposal with higher standard deviation. Grades are 1 and 5 - grade: index ? 1 : 5, + grade: index ? '1' : '5', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: review.questionaryID, @@ -3736,7 +3861,7 @@ context('Fap meeting components tests', () => { cy.contains('Comment is required'); cy.setTinyMceContent('comment', faker.lorem.words(3)); cy.get('[data-cy=save-and-continue-button]').focus().click(); - //cy.notification({ variant: 'success', text: 'Updated' }); + cy.notification({ variant: 'success', text: 'Saved' }); }); it('Fap Reviewer should be able to give non integer review', () => { @@ -3744,31 +3869,16 @@ context('Fap meeting components tests', () => { cy.visit('/'); cy.finishedLoading(); - cy.get('[data-cy="officer-menu-items"]').contains('Settings').click(); - cy.get('[data-cy="officer-menu-items"]').contains('App settings').click(); + cy.get('[data-cy="officer-menu-items"]').contains('Templates').click(); + cy.get('[data-cy="officer-menu-items"]').contains('FAP Review').click(); - cy.get('[data-cy="settings-table"]') - .get('input[aria-label="Search"]') - .type('GRADE_PRECISION'); + cy.get('[aria-label="Edit"]').click(); - cy.get('[data-cy="settings-table"]') - .contains('GRADE_PRECISION') - .parent() - .find('button[aria-label="Edit"]') - .click(); + cy.contains('fap_review_basis').click(); - cy.get('[data-cy="settings-table"]') - .contains('GRADE_PRECISION') - .parent() - .find(`input[value="1"]`) - .clear() - .type('0.01'); + cy.get('[data-cy="decimal_points"]').clear().type('2'); - cy.get('[data-cy="settings-table"]') - .contains('GRADE_PRECISION') - .parent() - .find('button[aria-label="Save"]') - .click(); + cy.get('[data-cy="submit"]').click(); cy.logout(); @@ -3789,16 +3899,53 @@ context('Fap meeting components tests', () => { cy.get('[data-cy="save-and-continue-button"]').focus().click(); - cy.get('[data-cy="grade-proposal"] input').then(($input) => { - expect(($input[0] as HTMLInputElement).validationMessage).to.eq( - 'Value must be less than or equal to 10.' - ); - }); + cy.contains('The grade must be a most 2 dp'); cy.get('#grade-proposal').clear().type('1.01'); cy.get('[data-cy=save-and-continue-button]').click(); - //cy.notification({ variant: 'success', text: 'Updated' }); + cy.notification({ variant: 'success', text: 'Saved' }); + }); + + it('Fap Reviewer should be able to give non numeric review', () => { + cy.login(initialDBData.users.officer); + cy.visit('/'); + cy.finishedLoading(); + + cy.get('[data-cy="officer-menu-items"]').contains('Templates').click(); + cy.get('[data-cy="officer-menu-items"]').contains('FAP Review').click(); + + cy.get('[aria-label="Edit"]').click(); + + cy.contains('fap_review_basis').click(); + + cy.get('[data-cy="add-item-button"]').click(); + + cy.get('[aria-label="Answer"]').type('A'); + + cy.get('[aria-label="Save"]').click(); + + cy.get('[data-cy="submit"]').click(); + + cy.logout(); + + cy.login(fapMembers.reviewer); + cy.visit('/'); + cy.finishedLoading(); + + cy.get('[data-cy="grade-proposal-icon"]').click(); + + cy.setTinyMceContent('comment', faker.lorem.words(3)); + + cy.contains('Classification').click(); + cy.get('[data-cy="grade-proposal"]').click(); + cy.get('[data-cy="grade-proposal-options"]').last().click(); + + cy.get('[data-cy=save-and-continue-button]').click(); + + cy.contains('Comment is required').should('not.exist'); + //TODO submiting review in tests is failing as the comment + cy.notification({ variant: 'success', text: 'Saved' }); }); }); }); @@ -4337,7 +4484,7 @@ context('Fap meeting exports test', () => { reviewID: review.id, comment: comment1, // NOTE: Make first proposal with lower standard deviation. Grades are 2 and 4 - grade: index ? 2 : 4, + grade: index ? '2' : '4', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: review.questionaryID, @@ -4355,7 +4502,7 @@ context('Fap meeting exports test', () => { reviewID: review.id, comment: comment2, // NOTE: Make second proposal with higher standard deviation. Grades are 1 and 5 - grade: index ? 1 : 5, + grade: index ? '1' : '5', status: ReviewStatus.SUBMITTED, fapID: createdFapId, questionaryID: review.questionaryID, diff --git a/apps/e2e/cypress/e2e/appSettings.cy.ts b/apps/e2e/cypress/e2e/appSettings.cy.ts index 6fa027b4dd..d9d6d686ff 100644 --- a/apps/e2e/cypress/e2e/appSettings.cy.ts +++ b/apps/e2e/cypress/e2e/appSettings.cy.ts @@ -32,7 +32,9 @@ context('App settings tests', () => { cy.get('[data-cy="close-modal-btn"]').click(); - cy.get('[data-cy="officer-menu-items"]').contains('Settings').click(); + cy.get('[data-cy="officer-menu-items"]') + .contains('Configuration Settings') + .click(); cy.get('[data-cy="officer-menu-items"]').contains('App settings').click(); cy.get('[data-cy="settings-table"]') diff --git a/apps/e2e/cypress/e2e/calls.cy.ts b/apps/e2e/cypress/e2e/calls.cy.ts index 67fb2e5ed6..ae37794eca 100644 --- a/apps/e2e/cypress/e2e/calls.cy.ts +++ b/apps/e2e/cypress/e2e/calls.cy.ts @@ -129,11 +129,12 @@ context('Calls tests', () => { const workflow = result.createWorkflow; if (workflow) { cy.addWorkflowStatus({ - droppableGroupId: workflow.workflowConnectionGroups[0].groupId, statusId: initialDBData.proposalStatuses.editableSubmittedInternal.id, workflowId: workflow.id, sortOrder: 1, - prevStatusId: workflow.workflowConnectionGroups[0].connections[0].id, + prevStatusId: workflow.workflowConnections[0].id, + posX: 0, + posY: 200, }).then((result) => { if (result.addWorkflowStatus) { cy.addStatusChangingEventsToConnection({ @@ -1068,7 +1069,7 @@ context('Calls tests', () => { id: initialDBData.call.id, ...newCall, shortCode: initialDBData.call.shortCode, - endCall: DateTime.now().plus({ minutes: 1, seconds: 50 }), + endCall: DateTime.now().plus({ minutes: 1, seconds: 59 }), proposalWorkflowId: initialDBData.proposal.id, }).then(() => { cy.reload(); @@ -1181,7 +1182,7 @@ context('Calls tests', () => { ...newCall, shortCode: initialDBData.call.shortCode, endCall: yesterday, - endCallInternal: DateTime.now().plus({ minutes: 1, seconds: 30 }), + endCallInternal: DateTime.now().plus({ minutes: 1, seconds: 59 }), proposalWorkflowId: initialDBData.proposal.id, }).then(() => { cy.reload(); diff --git a/apps/e2e/cypress/e2e/eventLogs.cy.ts b/apps/e2e/cypress/e2e/eventLogs.cy.ts index 2e60b56273..cb582afac3 100644 --- a/apps/e2e/cypress/e2e/eventLogs.cy.ts +++ b/apps/e2e/cypress/e2e/eventLogs.cy.ts @@ -134,7 +134,7 @@ context('Event log tests', () => { cy.get('[data-cy="add-participant-button"]').click(); cy.get('input[type="checkbox"]').eq(0).check(); cy.get('[data-cy="assign-selected-users"]').click(); - cy.get('.MuiTabs-flexContainer > #horizontal-tab-6').click(); + cy.get('.MuiTabs-flexContainer > #horizontal-tab-7').click(); cy.contains('userId:2 impersonating userId:1'); }); }); diff --git a/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts b/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts index d3818b0e1c..383f3014dd 100644 --- a/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts +++ b/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts @@ -152,11 +152,12 @@ function createWorkflowForInstrumentScientist() { return cy .addWorkflowStatus({ - droppableGroupId: workflowData.workflowConnectionGroups[0].groupId, statusId: TEST_CONSTANTS.WORKFLOW_STATUS.IS_REVIEW, workflowId: workflowData.id, sortOrder: TEST_CONSTANTS.SORT_ORDER.FIRST, prevStatusId: TEST_CONSTANTS.WORKFLOW_STATUS.INITIAL, + posX: 0, + posY: 200, }) .then((result) => { if (result.addWorkflowStatus) { @@ -168,12 +169,12 @@ function createWorkflowForInstrumentScientist() { .then(() => { return cy .addWorkflowStatus({ - droppableGroupId: - workflowData.workflowConnectionGroups[0].groupId, statusId: TEST_CONSTANTS.WORKFLOW_STATUS.APPROVED, workflowId: workflowData.id, sortOrder: TEST_CONSTANTS.SORT_ORDER.SECOND, prevStatusId: TEST_CONSTANTS.WORKFLOW_STATUS.IS_REVIEW, + posX: 0, + posY: 200, }) .then((secondResult) => { if (secondResult.addWorkflowStatus) { @@ -216,11 +217,12 @@ function createWorkflowForESR() { return cy .addWorkflowStatus({ - droppableGroupId: workflowData.workflowConnectionGroups[0].groupId, statusId: TEST_CONSTANTS.WORKFLOW_STATUS.ESR_REVIEW, workflowId: workflowData.id, sortOrder: TEST_CONSTANTS.SORT_ORDER.FIRST, prevStatusId: TEST_CONSTANTS.WORKFLOW_STATUS.INITIAL, + posX: 0, + posY: 200, }) .then((result) => { if (result.addWorkflowStatus) { @@ -232,12 +234,12 @@ function createWorkflowForESR() { .then(() => { return cy .addWorkflowStatus({ - droppableGroupId: - workflowData.workflowConnectionGroups[0].groupId, statusId: TEST_CONSTANTS.WORKFLOW_STATUS.APPROVED, workflowId: workflowData.id, sortOrder: TEST_CONSTANTS.SORT_ORDER.SECOND, prevStatusId: TEST_CONSTANTS.WORKFLOW_STATUS.ESR_REVIEW, + posX: 0, + posY: 200, }) .then((secondResult) => { if (secondResult.addWorkflowStatus) { @@ -272,7 +274,7 @@ function approveProposal() { cy.login('officer'); cy.visit('/'); cy.contains(TEST_CONSTANTS.UI_LABELS.PROPOSALS).click(); - cy.get('[data-cy=view-proposal]').click(); + cy.get('[data-cy=view-proposal]').first().click(); cy.finishedLoading(); cy.get('[role="dialog"]').contains(TEST_CONSTANTS.UI_LABELS.ADMIN).click(); cy.get('[data-cy="proposal-final-status"]').should('exist'); @@ -515,14 +517,9 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table').should('exist'); - cy.get('table tbody tr').should('have.length', 1); cy.get('table tbody tr') - .first() - .find('td') - .eq(1) - .should('contain', TEST_CONSTANTS.EXPERIMENT.ID); - cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('td') .eq(6) .should('contain', TEST_CONSTANTS.STATUS_LABELS.ESF_IS_REVIEW); @@ -539,7 +536,8 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('[aria-label="View Experiment"]') .click(); @@ -628,7 +626,8 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('[aria-label="View Experiment"]') .click(); @@ -655,7 +654,8 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('[aria-label="View Experiment"]') .click(); cy.get('[role="dialog"]').contains('Experiment Safety Review').click(); @@ -706,7 +706,8 @@ context('Experiment Safety Review tests', () => { cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('td') .eq(6) .should('contain', TEST_CONSTANTS.STATUS_LABELS.ESF_APPROVED); @@ -766,14 +767,15 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table').should('exist'); - cy.get('table tbody tr').should('have.length', 1); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('td') .eq(1) .should('contain', TEST_CONSTANTS.EXPERIMENT.ID); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('td') .eq(6) .should('contain', TEST_CONSTANTS.STATUS_LABELS.ESF_ESR_REVIEW); @@ -789,7 +791,8 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('[aria-label="View Experiment"]') .click(); cy.get('[role="dialog"]').contains('Experiment Safety Review').click(); @@ -834,7 +837,8 @@ context('Experiment Safety Review tests', () => { cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('td') .eq(6) .should('contain', 'ESF APPROVED'); @@ -847,7 +851,8 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('[aria-label="View Experiment"]') .click(); cy.get('[role="dialog"]').contains('Experiment Safety Review').click(); @@ -875,7 +880,8 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('td') .eq(6) .should('contain', 'ESF ESR REVIEW'); @@ -893,7 +899,8 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('[aria-label="View Experiment"]') .click(); cy.get('[role="dialog"]').contains('Experiment Safety Review').click(); @@ -913,7 +920,8 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('td') .eq(6) .should('contain', 'ESF ESR REVIEW'); @@ -923,7 +931,8 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('[aria-label="View Experiment"]') .click(); cy.get('[role="dialog"]').contains('Experiment Safety Review').click(); @@ -947,7 +956,8 @@ context('Experiment Safety Review tests', () => { cy.visit('/Experiments'); cy.finishedLoading(); cy.get('table tbody tr') - .first() + .contains('td', TEST_CONSTANTS.EXPERIMENT.ID) + .parent() .find('td') .eq(6) .should('contain', 'ESF APPROVED'); diff --git a/apps/e2e/cypress/e2e/experiments.cy.ts b/apps/e2e/cypress/e2e/experiments.cy.ts index 752c01a984..4d81b90118 100644 --- a/apps/e2e/cypress/e2e/experiments.cy.ts +++ b/apps/e2e/cypress/e2e/experiments.cy.ts @@ -7,6 +7,8 @@ import featureFlags from '../support/featureFlags'; import initialDBData from '../support/initialDBData'; context('Experiments tests', () => { + const instrumentScientist1 = initialDBData.users.instrumentScientist1; + beforeEach(function () { cy.resetDB(true); cy.getAndStoreFeaturesEnabled().then(() => { @@ -63,11 +65,11 @@ context('Experiments tests', () => { cy.get('[data-cy=instrument-filter]').click(); cy.get('[role=presentation]').contains('Instrument 2').click(); - cy.contains('1-1 of 1'); + cy.contains('1-2 of 2'); cy.get('[data-cy=instrument-filter]').click(); cy.get('[role=presentation]').contains('Instrument 1').click(); - cy.contains('1-3 of 3'); + cy.contains('1-2 of 2'); }); it('Can filter by date', () => { @@ -235,5 +237,60 @@ context('Experiments tests', () => { } }); }); + + it('Instrument Scientists should be able to see Experiments only associated with their instruments', () => { + cy.login(instrumentScientist1); + cy.visit('/experiments'); + cy.finishedLoading(); + + // There should be a div with data-cy experiments-table, which will contain table inside it + cy.get('[data-cy=experiments-table]').should('exist'); + + // Wait for the table to load with data + cy.get('[data-cy=experiments-table] table tbody tr').should( + 'have.length.at.least', + 1 + ); + + // Inside the table, there should be a column with title "Instrument" + cy.get('[data-cy=experiments-table] table thead th') + .contains('Instrument') + .should('exist'); + + // This column should only contains with value Instrument 1. Make sure you check only against that column and not others + // Make sure the table is fully loaded by checking that data exists + cy.get('[data-cy=experiments-table] table tbody tr td').should('exist'); + + // Get the header row and find the Instrument column index + cy.get('[data-cy=experiments-table] table thead tr th').then( + ($headers) => { + const headers = Array.from($headers).map((header) => + Cypress.$(header).text().trim() + ); + const instrumentColumnIndex = headers.findIndex( + (header) => header === 'Instrument' + ); + + cy.log(`Found headers: ${JSON.stringify(headers)}`); + cy.log(`Instrument column index: ${instrumentColumnIndex}`); + + // Verify we found the column + expect(instrumentColumnIndex).to.be.greaterThan(-1); + + // Now check all rows in the table body for this specific column + // Use a more robust approach that re-queries the DOM each time + cy.get('[data-cy=experiments-table] table tbody tr').then(($rows) => { + const rowCount = $rows.length; + for (let i = 0; i < rowCount; i++) { + cy.get('[data-cy=experiments-table] table tbody tr') + .eq(i) + .find('td') + .eq(instrumentColumnIndex) + .should('contain.text', 'Instrument 1'); + } + }); + } + ); + }); }); }); diff --git a/apps/e2e/cypress/e2e/features.cy.ts b/apps/e2e/cypress/e2e/features.cy.ts index de8bc4acf0..7be09514d5 100644 --- a/apps/e2e/cypress/e2e/features.cy.ts +++ b/apps/e2e/cypress/e2e/features.cy.ts @@ -19,7 +19,9 @@ context('Features tests', () => { 'Instrument' ); - cy.get('[data-cy="officer-menu-items"]').contains('Settings').click(); + cy.get('[data-cy="officer-menu-items"]') + .contains('Configuration Settings') + .click(); cy.get('[data-cy="officer-menu-items"]').contains('Features').click(); cy.get('[data-cy="features-table"]') diff --git a/apps/e2e/cypress/e2e/generalFaps.cy.ts b/apps/e2e/cypress/e2e/generalFaps.cy.ts index ad9fd1b5c5..80c37a2847 100644 --- a/apps/e2e/cypress/e2e/generalFaps.cy.ts +++ b/apps/e2e/cypress/e2e/generalFaps.cy.ts @@ -729,7 +729,7 @@ context('General facility access panel tests', () => { const fileName1 = 'pdf_5_pages.pdf'; const fileName2 = 'pdf_3_pages.pdf'; cy.login('officer'); - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.intercept({ method: 'POST', @@ -762,7 +762,7 @@ context('General facility access panel tests', () => { cy.contains(fileName2).should('exist'); // Files persist after reload - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.contains(fileName1).should('exist'); cy.contains(fileName2).should('exist'); @@ -773,7 +773,7 @@ context('General facility access panel tests', () => { cy.contains(fileName2).should('not.exist'); // Files removed after reload - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.contains(fileName1).should('not.exist'); cy.contains(fileName2).should('not.exist'); @@ -798,7 +798,7 @@ context('General facility access panel tests', () => { const fileName1 = 'pdf_5_pages.pdf'; cy.login('officer'); - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.intercept({ method: 'POST', @@ -843,7 +843,7 @@ context('General facility access panel tests', () => { const fileName1 = 'pdf_5_pages.pdf'; cy.login('officer'); - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.intercept({ method: 'POST', @@ -885,7 +885,7 @@ context('General facility access panel tests', () => { const fileName1 = 'pdf_5_pages.pdf'; cy.login('officer'); - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.intercept({ method: 'POST', diff --git a/apps/e2e/cypress/e2e/invites.cy.ts b/apps/e2e/cypress/e2e/invites.cy.ts index 0fb0c3edf3..329da96baa 100644 --- a/apps/e2e/cypress/e2e/invites.cy.ts +++ b/apps/e2e/cypress/e2e/invites.cy.ts @@ -185,7 +185,7 @@ context('Invites tests', () => { .should('not.exist'); }); - it.only('Should not be able to invite the email of the current user', function () { + it('Should not be able to invite the email of the current user', function () { if (!featureFlags.getEnabledFeatures().get(FeatureId.EMAIL_INVITE)) { this.skip(); } @@ -243,7 +243,7 @@ context('Invites tests', () => { cy.getAndStoreFeaturesEnabled(); }); - it('Should be able to accept invite', function () { + it('Should be able to accept invite and then see that in log', function () { if (!featureFlags.getEnabledFeatures().get(FeatureId.EMAIL_INVITE)) { this.skip(); } @@ -256,13 +256,25 @@ context('Invites tests', () => { cy.get('[data-cy=join-proposal-btn]').click(); cy.get('#code').type(response.setCoProposerInvites[0].code ?? ''); cy.get('[data-cy="invitation-submit"]').click(); - cy.get('[data-testid="VisibilityIcon"]').click(); + cy.get('[data-testid="VisibilityIcon"]').first().click(); cy.get('.MuiTabs-flexContainer > #horizontal-tab-1').click(); cy.get('[data-cy=questionary-details-view]').should( 'contain.text', initialDBData.users.user3.lastName ); + cy.logout(); + + cy.login('officer', initialDBData.roles.userOfficer); + cy.visit('/'); }); + cy.get('[data-testid="VisibilityIcon"] > path').first().click(); + cy.get('[data-cy="proposal-review-tabs"]').contains('Logs').click(); + cy.get('[data-cy="event-logs-table"]').contains( + 'PROPOSAL_CO_PROPOSER_INVITE_SENT' + ); + cy.get('[data-cy="event-logs-table"]').contains( + 'PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED' + ); }); }); diff --git a/apps/e2e/cypress/e2e/pages.cy.ts b/apps/e2e/cypress/e2e/pages.cy.ts index 548611c101..536402f483 100644 --- a/apps/e2e/cypress/e2e/pages.cy.ts +++ b/apps/e2e/cypress/e2e/pages.cy.ts @@ -11,7 +11,7 @@ context('Page tests', () => { it('Should be able update FAQ', () => { cy.login('officer'); cy.visit('/'); - + cy.contains('Configuration Settings').click(); cy.contains('Pages').click(); cy.contains('Set user homepage'); diff --git a/apps/e2e/cypress/e2e/peopleTable.cy.ts b/apps/e2e/cypress/e2e/peopleTable.cy.ts index 20b1ab6826..c882e0a42b 100644 --- a/apps/e2e/cypress/e2e/peopleTable.cy.ts +++ b/apps/e2e/cypress/e2e/peopleTable.cy.ts @@ -257,8 +257,6 @@ context('PageTable component tests', () => { cy.get('@modal').find('tr[index="0"] input:checked'); cy.get('@modal').contains('1 user(s) selected'); cy.get('[data-cy="assign-selected-users"]').click(); - - cy.logout(); }); }); diff --git a/apps/e2e/cypress/e2e/personalInformation.cy.ts b/apps/e2e/cypress/e2e/personalInformation.cy.ts index 113cfc0c30..744b9be6f1 100644 --- a/apps/e2e/cypress/e2e/personalInformation.cy.ts +++ b/apps/e2e/cypress/e2e/personalInformation.cy.ts @@ -159,7 +159,7 @@ context('Personal information tests', () => { .should('have.length', 2); }); - it('Should be able to change role even in the view where next role is not allowed to be', () => { + it('Should allow role switching from restricted page to role without access to that page', () => { if (featureFlags.getEnabledFeatures().get(FeatureId.USER_MANAGEMENT)) { cy.updateUserRoles({ id: initialDBData.users.officer.id, @@ -184,7 +184,7 @@ context('Personal information tests', () => { cy.notification({ variant: 'success', text: 'created successfully' }); - cy.get('[data-cy^="connection_DRAFT_1"]').should('contain.text', 'DRAFT'); + cy.get('[data-cy="connection_DRAFT"]').should('contain.text', 'DRAFT'); cy.get("[data-cy='profile-page-btn']").click(); diff --git a/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts b/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts index 06118771a9..ca2faed7c6 100644 --- a/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts +++ b/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts @@ -110,11 +110,12 @@ context('Pregenerated PDF tests', () => { }); cy.addWorkflowStatus({ - droppableGroupId: initialDBData.workflows.defaultDroppableGroup, statusId: initialDBData.proposalStatuses.editableSubmitted.id, workflowId: initialDBData.workflows.defaultWorkflow.id, sortOrder: 1, prevStatusId: initialDBData.proposalStatuses.draft.id, + posX: 0, + posY: 200, }).then((result) => { const connection = result.addWorkflowStatus; if (connection) { @@ -516,7 +517,7 @@ context('Pregenerated PDF tests', () => { }); it('User officer can download pregenerated and generated PDFs in a zip', () => { - cy.login('user1', initialDBData.roles.user).then(() => { + cy.login('officer', initialDBData.roles.userOfficer).then(() => { const token = window.localStorage.getItem('token'); if (!token) { diff --git a/apps/e2e/cypress/e2e/proposals.cy.ts b/apps/e2e/cypress/e2e/proposals.cy.ts index 32adf63e92..bb4058d2b0 100644 --- a/apps/e2e/cypress/e2e/proposals.cy.ts +++ b/apps/e2e/cypress/e2e/proposals.cy.ts @@ -3,6 +3,7 @@ import { AllocationTimeUnits, DataType, FeatureId, + ProposalEndStatus, SettingsId, TemplateCategoryId, TemplateGroupId, @@ -128,11 +129,12 @@ context('Proposal tests', () => { }).then((result) => { if (result.createWorkflow) { cy.addWorkflowStatus({ - droppableGroupId: 'proposalWorkflowConnections_0', statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: result.createWorkflow.id, sortOrder: 1, prevStatusId: 1, + posX: 0, + posY: 200, }); createdWorkflowId = result.createWorkflow.id; } @@ -151,6 +153,38 @@ context('Proposal tests', () => { }); }); + it('Should be able add user to proposal though data access feature', function () { + if (!featureFlags.getEnabledFeatures().get(FeatureId.DATA_ACCESS_USERS)) { + this.skip(); + } + cy.submitProposal({ proposalPk: createdProposalPk }); + + cy.updateProposalManagementDecision({ + proposalPk: createdProposalPk, + finalStatus: ProposalEndStatus.ACCEPTED, + managementTimeAllocations: [ + { instrumentId: initialDBData.instrument1.id, value: 5 }, + ], + managementDecisionSubmitted: true, + }); + + cy.login('user1', initialDBData.roles.user); + cy.visit('/'); + cy.get('[data-testid="PeopleIcon"]').click(); + cy.get('[data-cy="add-participant-button"]').click(); + cy.get('#Email-input').type(initialDBData.users.user3.email); + cy.get('[data-cy="findUser"]').click(); + cy.get('[data-cy="assign-selected-users"]').click(); + cy.get('[data-cy="save-data-access-users-modal"]').click(); + cy.logout(); + cy.login('user3', initialDBData.roles.user); + cy.get('[data-testid="VisibilityIcon"]').click(); + cy.get('[data-cy="questionary-details-view"]').contains( + createdProposalId + ); + cy.get('[data-cy="questionary-details-view"]').contains(newProposalTitle); + }); + it('Copy to clipboard should work for Proposal ID', () => { cy.login('officer'); cy.visit('/'); @@ -283,6 +317,11 @@ context('Proposal tests', () => { cy.contains('OK').click(); + cy.get('[data-cy="questionary-stepper"').contains('New proposal').click(); + + cy.get('#title-input').should('have.value', title); + cy.get('#abstract-input').should('have.value', modifiedAbstract); + cy.contains('Dashboard').click(); cy.contains(title); cy.contains('submitted'); @@ -1054,7 +1093,7 @@ context('Proposal tests', () => { it('User officer should reopen proposal', () => { cy.login('user1', initialDBData.roles.user); cy.visit('/'); - cy.get('[aria-label="View proposal"]').click(); + cy.get('[aria-label="View proposal"]').first().click(); cy.get('[role="tablist"]').contains('Proposal').click(); cy.get('[data-cy=button-submit-proposal]').should('be.disabled'); diff --git a/apps/e2e/cypress/e2e/samples.cy.ts b/apps/e2e/cypress/e2e/samples.cy.ts index 15c876c1f8..b2c3a511c2 100644 --- a/apps/e2e/cypress/e2e/samples.cy.ts +++ b/apps/e2e/cypress/e2e/samples.cy.ts @@ -508,6 +508,41 @@ context('Samples tests', () => { ).contains(sampleTitle); }); + it('User officer should be able to add sample to submitted proposal', () => { + cy.createSample({ + proposalPk: createdProposalPk, + templateId: createdSampleTemplateId, + questionId: createdSampleQuestionId, + title: sampleTitle, + }); + cy.visit('/'); + + cy.contains('Proposals').click(); + + cy.contains(proposalTitle) + .parent() + .find('[aria-label="View proposal"]') + .click(); + /* ==== Generated with Cypress Studio ==== */ + cy.get('[data-cy="toggle-edit-proposal"]').click(); + cy.get( + ':nth-child(3) > .MuiButtonBase-root > .MuiStepLabel-root > .MuiStepLabel-labelContainer > .MuiStepLabel-label > span' + ).click(); + cy.get('[data-cy="add-button"]').click(); + + const newSampleTitle = faker.lorem.words(3); + cy.get('#sample_basis-field').clear(); + cy.get('#sample_basis-field').type(newSampleTitle); + cy.get( + '[data-cy="sample-declaration-modal"] [data-cy="save-and-continue-button"]' + ).click(); + cy.get( + '[data-cy="sample-declaration-modal"] [data-cy="save-and-continue-button"]' + ).click(); + + cy.get('[data-cy="questionnaires-list-item"]').contains(newSampleTitle); + }); + it('User should not be able to submit proposal with unfinished sample', () => { cy.login('user1', initialDBData.roles.user); cy.visit('/'); diff --git a/apps/e2e/cypress/e2e/settings.cy.ts b/apps/e2e/cypress/e2e/settings.cy.ts index adbd293551..4963753432 100644 --- a/apps/e2e/cypress/e2e/settings.cy.ts +++ b/apps/e2e/cypress/e2e/settings.cy.ts @@ -15,6 +15,17 @@ import initialDBData from '../support/initialDBData'; import settings from '../support/settings'; import { updatedCall } from '../support/utils'; +const { + feasibilityReview, + fapSelection, + notFeasible, + draft, + esfIsReview, + awaitingEsf, + esfEsrReview, + esfRejected, +} = initialDBData.proposalStatuses; + context('Settings tests', () => { beforeEach(() => { cy.resetDB(); @@ -150,9 +161,9 @@ context('Settings tests', () => { description: faker.random.words(5), managerUserId: initialDBData.users.user1.id, }; - let workflowDroppableGroupId: string; let createdWorkflowId: number; let prevStatusId: number; + let prevConnectionId: number; let createdEsiTemplateId: number; let createdInstrumentId: number; @@ -169,11 +180,12 @@ context('Settings tests', () => { }); }; - const addMultipleStatusesToProposalWorkflowWithChangingEvents = () => { + const addWorkflowWithChangingEvents = () => { cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: createdWorkflowId, + posX: 0, + posY: 100, sortOrder: 1, prevStatusId: prevStatusId, }).then((result) => { @@ -186,9 +198,10 @@ context('Settings tests', () => { } }); cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.fapSelection.id, workflowId: createdWorkflowId, + posX: 0, + posY: 200, sortOrder: 2, prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, }).then((result) => { @@ -203,9 +216,10 @@ context('Settings tests', () => { } }); cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.fapReview.id, workflowId: createdWorkflowId, + posX: 0, + posY: 300, sortOrder: 3, prevStatusId: initialDBData.proposalStatuses.fapSelection.id, }).then((result) => { @@ -217,9 +231,10 @@ context('Settings tests', () => { } }); cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.fapMeeting.id, workflowId: createdWorkflowId, + posX: 0, + posY: 400, sortOrder: 4, prevStatusId: initialDBData.proposalStatuses.fapReview.id, }).then((result) => { @@ -232,58 +247,56 @@ context('Settings tests', () => { }); }; - const addMultipleStatusesToMultiColumnProposalWorkflowWithChangingEvents = - () => { - cy.addWorkflowStatus({ - droppableGroupId: 'proposalWorkflowConnections_0', - statusId: initialDBData.proposalStatuses.feasibilityReview.id, - workflowId: createdWorkflowId, - sortOrder: 1, - prevStatusId: prevStatusId, - }).then((result) => { - const connection = result.addWorkflowStatus; - if (connection) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: connection.id, - statusChangingEvents: [Event.PROPOSAL_SUBMITTED], - }); - } - }); - cy.addWorkflowStatus({ - droppableGroupId: 'proposalWorkflowConnections_1', - statusId: initialDBData.proposalStatuses.fapSelection.id, - workflowId: createdWorkflowId, - sortOrder: 0, - prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, - parentDroppableGroupId: 'proposalWorkflowConnections_0', - }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, - statusChangingEvents: [ - Event.PROPOSAL_FEASIBILITY_REVIEW_FEASIBLE, - ], - }); - } - }); - cy.addWorkflowStatus({ - droppableGroupId: 'proposalWorkflowConnections_2', - statusId: initialDBData.proposalStatuses.notFeasible.id, - workflowId: createdWorkflowId, - sortOrder: 0, - prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, - parentDroppableGroupId: 'proposalWorkflowConnections_0', - }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, - statusChangingEvents: [ - Event.PROPOSAL_FEASIBILITY_REVIEW_UNFEASIBLE, - ], - }); - } - }); - }; + const addWorkflowWithBranchesAndChangingEvents = () => { + cy.addWorkflowStatus({ + statusId: initialDBData.proposalStatuses.feasibilityReview.id, + workflowId: createdWorkflowId, + posX: 0, + posY: 500, + sortOrder: 1, + prevStatusId: prevStatusId, + }).then((result) => { + const connection = result.addWorkflowStatus; + if (connection) { + cy.addStatusChangingEventsToConnection({ + workflowConnectionId: connection.id, + statusChangingEvents: [Event.PROPOSAL_SUBMITTED], + }); + } + }); + cy.addWorkflowStatus({ + statusId: initialDBData.proposalStatuses.fapSelection.id, + workflowId: createdWorkflowId, + posX: 0, + posY: 600, + sortOrder: 0, + prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, + }).then((result) => { + if (result.addWorkflowStatus) { + cy.addStatusChangingEventsToConnection({ + workflowConnectionId: result.addWorkflowStatus.id, + statusChangingEvents: [Event.PROPOSAL_FEASIBILITY_REVIEW_FEASIBLE], + }); + } + }); + cy.addWorkflowStatus({ + statusId: initialDBData.proposalStatuses.notFeasible.id, + workflowId: createdWorkflowId, + posX: 0, + posY: 700, + sortOrder: 0, + prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, + }).then((result) => { + if (result.addWorkflowStatus) { + cy.addStatusChangingEventsToConnection({ + workflowConnectionId: result.addWorkflowStatus.id, + statusChangingEvents: [ + Event.PROPOSAL_FEASIBILITY_REVIEW_UNFEASIBLE, + ], + }); + } + }); + }; beforeEach(() => { // NOTE: Cypress scrolls automatically to the status position and dragging element is problematic when the droppable area is out of the view. For now this solution to extend the height of the view is the fastest @@ -296,9 +309,8 @@ context('Settings tests', () => { const workflow = result.createWorkflow; if (workflow) { createdWorkflowId = workflow.id; - prevStatusId = workflow.workflowConnectionGroups[0].connections[0].id; - workflowDroppableGroupId = - workflow.workflowConnectionGroups[0].groupId; + prevStatusId = workflow.workflowConnections[0].statusId!; + prevConnectionId = workflow.workflowConnections[0].id; cy.createTemplate({ name: 'default esi template', @@ -325,9 +337,10 @@ context('Settings tests', () => { const proposalTitle = faker.random.words(3); const editedProposalTitle = faker.random.words(3); cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.editableSubmitted.id, workflowId: createdWorkflowId, + posX: 0, + posY: 200, sortOrder: 1, prevStatusId: prevStatusId, }).then((result) => { @@ -410,9 +423,10 @@ context('Settings tests', () => { const currentDayStart = DateTime.now().startOf('day'); const editedProposalTitle = faker.random.words(3); cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.editableSubmittedInternal.id, workflowId: createdWorkflowId, + posX: 0, + posY: 200, sortOrder: 1, prevStatusId: prevStatusId, }).then((result) => { @@ -503,9 +517,10 @@ context('Settings tests', () => { const proposalTitle = faker.random.words(3); const currentDayStart = DateTime.now().startOf('day'); cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.editableSubmittedInternal.id, workflowId: createdWorkflowId, + posX: 0, + posY: 200, sortOrder: 1, prevStatusId: prevStatusId, }).then((result) => { @@ -588,9 +603,10 @@ context('Settings tests', () => { const internalProposalTitle = faker.lorem.words(3); const currentDayStart = DateTime.now().startOf('day'); cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.editableSubmitted.id, workflowId: createdWorkflowId, + posX: 0, + posY: 200, sortOrder: 1, prevStatusId: prevStatusId, }).then((result) => { @@ -602,9 +618,10 @@ context('Settings tests', () => { } }); cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.editableSubmittedInternal.id, workflowId: createdWorkflowId, + posX: 0, + posY: 200, sortOrder: 2, prevStatusId: initialDBData.proposalStatuses.editableSubmitted.id, }).then((result) => { @@ -694,7 +711,6 @@ context('Settings tests', () => { cy.finishedLoading(); cy.get('[data-cy^="connection_DRAFT"]').should('contain.text', 'DRAFT'); - cy.get('[data-cy^="status_DRAFT"]').should('exist'); cy.get('[data-cy="remove-workflow-status-button"]').should('not.exist'); }); @@ -731,12 +747,12 @@ context('Settings tests', () => { cy.finishedLoading(); - cy.get('[data-cy^="status_FEASIBILITY_REVIEW"]').dragElement([ - { direction: 'left', length: 1 }, - { direction: 'down', length: 1 }, - ]); + cy.dragStatusIntoWorkflow(feasibilityReview, { + clientX: 0, + clientY: 100, + }); - cy.get('[data-cy^="connection_FEASIBILITY_REVIEW"]').should( + cy.get('[data-cy="connection_FEASIBILITY_REVIEW"]').should( 'contain.text', 'FEASIBILITY_REVIEW' ); @@ -749,13 +765,15 @@ context('Settings tests', () => { cy.get('[data-cy^="status_FEASIBILITY_REVIEW"]').should('exist'); }); - it('User Officer should be able to select events that are triggering change to workflow status', () => { + it('User Officer should be able to select events that are triggering change to proposal workflow status', () => { cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: createdWorkflowId, + posX: 0, + posY: 150, sortOrder: 1, prevStatusId: prevStatusId, + prevConnectionId: prevConnectionId, }); cy.login('officer'); cy.visit('/'); @@ -765,7 +783,7 @@ context('Settings tests', () => { cy.contains(workflowName).parent().find('[aria-label="Edit"]').click(); - cy.get(`[data-cy^="connection_FEASIBILITY_REVIEW"]`).click(); + cy.get("[aria-label='Edge from DRAFT to FEASIBILITY_REVIEW']").click(); cy.get('[data-cy="status-events-and-actions-modal"]').should('exist'); @@ -780,7 +798,7 @@ context('Settings tests', () => { cy.closeModal(); - cy.get(`[data-cy^="connection_FEASIBILITY_REVIEW"]`).click(); + cy.get("[aria-label='Edge from DRAFT to FEASIBILITY_REVIEW']").click(); cy.get('[data-cy="status-events-and-actions-modal"]').should('exist'); @@ -793,9 +811,12 @@ context('Settings tests', () => { text: 'Status changing events added successfully!', }); - cy.contains( - `${Event.PROPOSAL_SUBMITTED} & ${Event.PROPOSAL_FEASIBILITY_REVIEW_FEASIBLE}` - ); + cy.get( + '[data-cy="edge-label-events-list-DRAFT-FEASIBILITY_REVIEW"]' + ).contains(`${Event.PROPOSAL_SUBMITTED}`); + cy.get( + '[data-cy="edge-label-events-list-DRAFT-FEASIBILITY_REVIEW"]' + ).contains(`${Event.PROPOSAL_FEASIBILITY_REVIEW_FEASIBLE}`); }); it('Proposal should follow the selected workflow', function () { @@ -805,7 +826,7 @@ context('Settings tests', () => { const internalComment = faker.random.words(2); const publicComment = faker.random.words(2); createInstrumentAndAssignItToCall(); - addMultipleStatusesToProposalWorkflowWithChangingEvents(); + addWorkflowWithChangingEvents(); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { const proposal = result.createProposal; if (proposal) { @@ -924,7 +945,7 @@ context('Settings tests', () => { }); it('Proposal status should update immediately after assigning it to a Fap', () => { - addMultipleStatusesToProposalWorkflowWithChangingEvents(); + addWorkflowWithChangingEvents(); createInstrumentAndAssignItToCall(); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { const proposal = result.createProposal; @@ -984,7 +1005,7 @@ context('Settings tests', () => { }); it('Proposal status should update multiple times if conditions are met', () => { - addMultipleStatusesToProposalWorkflowWithChangingEvents(); + addWorkflowWithChangingEvents(); cy.createInstrument(instrument1).then((result) => { if (result.createInstrument) { createdInstrumentId = result.createInstrument.id; @@ -1066,7 +1087,7 @@ context('Settings tests', () => { if (!featureFlags.getEnabledFeatures().get(FeatureId.FAP_REVIEW)) { this.skip(); } - addMultipleStatusesToProposalWorkflowWithChangingEvents(); + addWorkflowWithChangingEvents(); createInstrumentAndAssignItToCall(); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { const proposal = result.createProposal; @@ -1151,15 +1172,10 @@ context('Settings tests', () => { cy.setTinyMceContent('comment', faker.lorem.words(3)); cy.get(`#comment_ifr`).first().focus().click(); - if ( - settings.getEnabledSettings().get(SettingsId.GRADE_PRECISION) === '1' - ) { - cy.get('[data-cy="grade-proposal"]').click(); - - cy.get('[role="listbox"] > [role="option"]').first().click(); - } else { - cy.get('[data-cy="grade-proposal"]').click().type('1'); - } + cy.get('[data-cy="grade-proposal"]').click(); + cy.get('[data-cy="grade-proposal-options"] [role="option"]') + .first() + .click(); cy.get('[data-cy="save-and-continue-button"]').focus().click(); cy.get('[data-cy="is-grade-submitted"]').click(); @@ -1174,7 +1190,7 @@ context('Settings tests', () => { }); it('User Officer should be able to filter proposals based on statuses', () => { - addMultipleStatusesToProposalWorkflowWithChangingEvents(); + addWorkflowWithChangingEvents(); createInstrumentAndAssignItToCall(); cy.createProposal({ callId: initialDBData.call.id }); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { @@ -1268,7 +1284,7 @@ context('Settings tests', () => { }); it('User Officer should be able to remove statuses from proposal workflow using trash icon', () => { - addMultipleStatusesToProposalWorkflowWithChangingEvents(); + addWorkflowWithChangingEvents(); cy.login('officer'); cy.visit(''); @@ -1279,18 +1295,6 @@ context('Settings tests', () => { cy.get('[data-cy="remove-workflow-status-button"]').first().click(); - cy.get('[data-cy^="status_FEASIBILITY_REVIEW"]').should( - 'contain.text', - initialDBData.proposalStatuses.feasibilityReview.name - ); - - cy.get('[data-cy="confirmation-dialog"] .MuiDialogContent-root').should( - 'contain.text', - initialDBData.proposalStatuses.feasibilityReview.name - ); - - cy.get('[data-cy="confirm-ok"]').click(); - cy.notification({ variant: 'success', text: 'Workflow status removed successfully', @@ -1299,62 +1303,83 @@ context('Settings tests', () => { cy.get('[data-cy^="connection_FEASIBILITY_REVIEW"]').should('not.exist'); }); - it('User Officer should be able to create multi-column proposal workflow', () => { + it.skip('User Officer should be able to create proposal workflow with branches', () => { + // Skipping due to flakiness, enable once CI performance is improved cy.login('officer'); cy.visit(`/ProposalWorkflowEditor/${createdWorkflowId}`); - cy.get('[data-cy^="status_FEASIBILITY_REVIEW"]').dragElement([ - { direction: 'left', length: 1 }, - { direction: 'down', length: 1 }, - ]); - + cy.dragStatusIntoWorkflow(feasibilityReview, { + clientX: 600, + clientY: 400, + }); + cy.finishedLoading(); cy.notification({ variant: 'success', text: 'Workflow status added successfully', }); + cy.get('[data-cy="connection_FEASIBILITY_REVIEW"]').should( + 'contain.text', + 'FEASIBILITY_REVIEW' + ); - cy.contains('Add multi-column row').click(); - - cy.get('#selectedParentDroppableId-input').click(); - cy.get('[data-cy="selectParentDroppableGroup-options"] li') - .first() - .click(); - - cy.get('[data-cy="numberOfColumns"]').click(); - cy.get('[data-cy="numberOfColumnsOptions"] li[data-value="2"]').click(); - - cy.contains('Add row').click(); - - cy.get('[data-cy="droppable-group"]').should('have.length', 3); - - cy.get('[data-cy^="status_FAP_SELECTION"]').dragElement([ - { direction: 'left', length: 2 }, - ]); - + cy.dragStatusIntoWorkflow(fapSelection, { + clientX: 300, + clientY: 800, + }); + cy.finishedLoading(); cy.notification({ variant: 'success', text: 'Workflow status added successfully', }); + cy.get('[data-cy="connection_FAP_SELECTION"]').should( + 'contain.text', + 'FAP_SELECTION' + ); - cy.get('[data-cy^="status_NOT_FEASIBLE"]').dragElement([ - { direction: 'left', length: 1 }, - ]); - + cy.dragStatusIntoWorkflow(notFeasible, { + clientX: 900, + clientY: 800, + }); + cy.finishedLoading(); cy.notification({ variant: 'success', text: 'Workflow status added successfully', }); + cy.get('[data-cy="connection_NOT_FEASIBLE"]').should( + 'contain.text', + 'NOT_FEASIBLE' + ); - cy.reload(); + cy.finishedLoading(); - cy.get('[data-cy="droppable-group"]').should('have.length', 3); + cy.get('[title="fit view"]').click(); + cy.connectReactFlowNodes(feasibilityReview, fapSelection, { + force: true, + }); + cy.finishedLoading(); cy.get( - '[data-cy="workflow-connections"] .MuiGrid-container .MuiGrid-grid-xs-6' - ).should('have.length', 2); + `[aria-label="Edge from FEASIBILITY_REVIEW to FAP_SELECTION"]` + ).should('exist'); + + cy.connectReactFlowNodes(feasibilityReview, notFeasible, { + force: true, + }); + cy.finishedLoading(); + cy.get( + `[aria-label="Edge from FEASIBILITY_REVIEW to NOT_FEASIBLE"]` + ).should('exist'); + + cy.connectReactFlowNodes(draft, feasibilityReview, { + force: true, + }); + cy.finishedLoading(); + cy.get('[aria-label="Edge from DRAFT to FEASIBILITY_REVIEW"]').should( + 'exist' + ); }); - it('Proposal should follow multi-column workflow', function () { + it('Proposal should follow workflow with branches', function () { if (!featureFlags.getEnabledFeatures().get(FeatureId.TECHNICAL_REVIEW)) { this.skip(); } @@ -1364,7 +1389,7 @@ context('Settings tests', () => { const secondProposalAbstract = faker.random.words(5); const internalComment = faker.random.words(2); const publicComment = faker.random.words(2); - addMultipleStatusesToMultiColumnProposalWorkflowWithChangingEvents(); + addWorkflowWithBranchesAndChangingEvents(); createInstrumentAndAssignItToCall(); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { const proposal = result.createProposal; @@ -1504,9 +1529,10 @@ context('Settings tests', () => { }); cy.addWorkflowStatus({ - droppableGroupId: workflowDroppableGroupId, statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: createdWorkflowId, + posX: 0, + posY: 200, sortOrder: 1, prevStatusId: prevStatusId, }).then((result) => { @@ -1607,11 +1633,7 @@ context('Settings tests', () => { cy.notification({ variant: 'success', text: 'created successfully' }); cy.finishedLoading(); - cy.get('[data-cy^="connection_AWAITING_ESF"]').should( - 'contain.text', - 'AWAITING_ESF' - ); - cy.get('[data-cy^="status_AWAITING_ESF"]').should('exist'); + cy.get('[data-cy^="connection_AWAITING_ESF"]').should('exist'); cy.get('[data-cy="remove-workflow-status-button"]').should('not.exist'); }); @@ -1637,7 +1659,7 @@ context('Settings tests', () => { .should('contain.text', updatedWorkflowDescription); }); - it('User Officer should be able to add more statuses in experiment workflow', function () { + it('User Officer should be able to add more statuses of the same type in experiment workflow', function () { cy.login('officer'); cy.visit('/'); @@ -1647,23 +1669,24 @@ context('Settings tests', () => { cy.contains(workflowName).parent().find('[aria-label="Edit"]').click(); cy.finishedLoading(); - cy.get('[data-cy^="status_ESF_IS_REVIEW"]').dragElement([ - { direction: 'left', length: 1 }, - { direction: 'down', length: 1 }, - ]); - cy.get('[data-cy^="connection_ESF_IS_REVIEW"]').should( - 'contain.text', - 'ESF IS REVIEW' - ); + + cy.dragStatusIntoWorkflow(esfIsReview, { + clientX: 300, + clientY: 300, + }); cy.notification({ variant: 'success', text: 'Workflow status added successfully', }); + cy.get('[data-cy^="connection_ESF_IS_REVIEW"]').should( + 'contain.text', + 'ESF IS REVIEW' + ); cy.get('[data-cy^="status_ESF_IS_REVIEW"]').should('exist'); }); - it('User Officer should be able to select events that are triggering change to workflow status', () => { + it('User Officer should be able to select events that are triggering change to ESF workflow status', () => { cy.login('officer'); cy.visit('/'); @@ -1673,22 +1696,26 @@ context('Settings tests', () => { cy.contains(workflowName).parent().find('[aria-label="Edit"]').click(); cy.finishedLoading(); - cy.get('[data-cy^="status_ESF_IS_REVIEW"]').dragElement([ - { direction: 'left', length: 1 }, - { direction: 'down', length: 1 }, - ]); - cy.get('[data-cy^="connection_ESF_IS_REVIEW"]').should( - 'contain.text', - 'ESF IS REVIEW' - ); + + cy.dragStatusIntoWorkflow(esfIsReview, { + clientX: 300, + clientY: 300, + }); + + cy.get('[data-cy^="connection_ESF_IS_REVIEW"]').should('exist'); + cy.notification({ variant: 'success', text: 'Workflow status added successfully', }); - cy.get('[data-cy^="status_ESF_IS_REVIEW"]').should('exist'); + cy.connectReactFlowNodes(awaitingEsf, esfIsReview, { + force: true, + }); - cy.get(`[data-cy^="connection_ESF_IS_REVIEW"]`).click(); + cy.reload(); + + cy.get(`[aria-label="Edge from AWAITING_ESF to ESF_IS_REVIEW"]`).click(); cy.get('[data-cy="status-events-and-actions-modal"]').should('exist'); cy.contains(Event.EXPERIMENT_ESF_SUBMITTED).click(); @@ -1701,7 +1728,11 @@ context('Settings tests', () => { cy.closeModal(); - cy.get(`[data-cy^="connection_ESF_IS_REVIEW"]`).click(); + cy.get('[data-cy="edge-label-events-list-AWAITING_ESF-ESF_IS_REVIEW"]') + .contains(Event.EXPERIMENT_ESF_SUBMITTED) + .should('exist'); + + cy.get(`[aria-label="Edge from AWAITING_ESF to ESF_IS_REVIEW"]`).click(); cy.get('[data-cy="status-events-and-actions-modal"]').should('exist'); cy.contains(Event.EXPERIMENT_ESF_APPROVED_BY_IS).click(); @@ -1712,65 +1743,90 @@ context('Settings tests', () => { text: 'Status changing events added successfully!', }); - cy.contains( - `${Event.EXPERIMENT_ESF_SUBMITTED} & ${Event.EXPERIMENT_ESF_APPROVED_BY_IS}` + cy.get( + '[data-cy="edge-label-events-list-AWAITING_ESF-ESF_IS_REVIEW"]' + ).should( + 'have.text', + 'EXPERIMENT_ESF_SUBMITTEDEXPERIMENT_ESF_APPROVED_BY_IS' ); }); - it('User Officer should be able to create multi-column experiment workflow', () => { + it.skip('User Officer should be able to create experiment workflow with branches', () => { + // Skipping due to flakiness, enable once CI performance is improved cy.login('officer'); cy.visit(`/ExperimentWorkflowEditor/${createdWorkflowId}`); - cy.get('[data-cy^="status_ESF_IS_REVIEW"]').dragElement([ - { direction: 'left', length: 1 }, - { direction: 'down', length: 1 }, - ]); - + cy.dragStatusIntoWorkflow(esfIsReview, { + clientX: 600, + clientY: 400, + }); + cy.finishedLoading(); cy.notification({ variant: 'success', text: 'Workflow status added successfully', }); + cy.get('[data-cy="connection_ESF_IS_REVIEW"]').should( + 'contain.text', + esfIsReview.name + ); - cy.contains('Add multi-column row').click(); - - cy.get('#selectedParentDroppableId-input').click(); - cy.get('[data-cy="selectParentDroppableGroup-options"] li') - .first() - .click(); - - cy.get('[data-cy="numberOfColumns"]').click(); - cy.get('[data-cy="numberOfColumnsOptions"] li[data-value="2"]').click(); - - cy.contains('Add row').click(); - - cy.get('[data-cy="droppable-group"]').should('have.length', 3); - cy.get('[data-cy^="status_ESF_ESR_REVIEW"]').dragElement([ - { direction: 'left', length: 2 }, - ]); - + cy.dragStatusIntoWorkflow(esfEsrReview, { + clientX: 300, + clientY: 800, + }); + cy.finishedLoading(); cy.notification({ variant: 'success', text: 'Workflow status added successfully', }); + cy.get('[data-cy="connection_ESF_ESR_REVIEW"]').should( + 'contain.text', + esfEsrReview.name + ); - cy.get('[data-cy^="status_ESF_REJECTED"]').dragElement([ - { direction: 'left', length: 1 }, - ]); - + cy.dragStatusIntoWorkflow(esfRejected, { + clientX: 900, + clientY: 800, + }); + cy.finishedLoading(); cy.notification({ variant: 'success', text: 'Workflow status added successfully', }); + cy.get('[data-cy="connection_ESF_REJECTED"]').should( + 'contain.text', + esfRejected.name + ); - cy.reload(); - cy.get('[data-cy="droppable-group"]').should('have.length', 3); + cy.finishedLoading(); - cy.get( - '[data-cy="workflow-connections"] .MuiGrid-container .MuiGrid-grid-xs-6' - ).should('have.length', 2); + cy.get('[title="fit view"]').click(); + + cy.connectReactFlowNodes(esfIsReview, esfEsrReview, { + force: true, + }); + cy.finishedLoading(); + cy.get(`[aria-label="Edge from ESF_IS_REVIEW to ESF_ESR_REVIEW"]`).should( + 'exist' + ); + + cy.connectReactFlowNodes(esfIsReview, esfRejected, { + force: true, + }); + cy.finishedLoading(); + cy.get(`[aria-label="Edge from ESF_IS_REVIEW to ESF_REJECTED"]`).should( + 'exist' + ); + + cy.connectReactFlowNodes(awaitingEsf, esfIsReview, { + force: true, + }); + cy.finishedLoading(); + cy.get(`[aria-label="Edge from AWAITING_ESF to ESF_IS_REVIEW"]`).should( + 'exist' + ); }); }); - describe('API access tokens tests', () => { const accessTokenName = faker.lorem.words(2); let removedAccessToken: string; diff --git a/apps/e2e/cypress/e2e/shipments.cy.ts b/apps/e2e/cypress/e2e/shipments.cy.ts index 79963f58b3..bdc4a3aa15 100644 --- a/apps/e2e/cypress/e2e/shipments.cy.ts +++ b/apps/e2e/cypress/e2e/shipments.cy.ts @@ -127,7 +127,13 @@ context('Shipments tests', () => { cy.get('[data-cy=select-proposal-dropdown]').click(); - cy.get('[role="listbox"]').contains(existingProposal.title).click(); + cy.get('[role="listbox"]') + .find('li') + .filter( + (index: number, el: HTMLElement) => + !!el.textContent && el.textContent.trim() === existingProposal.title + ) + .click(); cy.get('[data-cy=samples-dropdown]').click(); diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 6977776f3d..50c760ba69 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -51,18 +51,20 @@ context('Status actions tests', () => { describe('Status actions workflow tests', () => { it('User Officer should be able to add a status action to workflow connection', () => { cy.addWorkflowStatus({ - droppableGroupId: initialDBData.workflows.defaultDroppableGroup, statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, sortOrder: 1, prevStatusId: initialDBData.proposalStatuses.draft.id, + posX: 0, + posY: 200, + prevConnectionId: 1, }); cy.login('officer'); cy.visit('/ProposalWorkflowEditor/1'); cy.finishedLoading(); - cy.get(`[data-cy^="connection_FEASIBILITY_REVIEW"]`).click(); + cy.get(`[aria-label="Edge from DRAFT to FEASIBILITY_REVIEW"]`).click(); cy.get('[data-cy="status-events-and-actions-modal"]').should('exist'); cy.get('[data-cy="status-events-and-actions-modal"]') @@ -112,16 +114,11 @@ context('Status actions tests', () => { cy.closeModal(); cy.get( - `[data-cy^="connection_FEASIBILITY_REVIEW"] [data-testid="PendingActionsIcon"]` - ).should('exist'); + `[data-cy="edge-label-actions-list-DRAFT-FEASIBILITY_REVIEW"]` + ).contains('Email action'); cy.get( - `[data-cy^="connection_FEASIBILITY_REVIEW"] [data-testid="PendingActionsIcon"]` - ).realHover(); - - cy.get('[role="tooltip"]') - .should('contain', 'Status actions') - .and('contain', 'Email action') - .and('contain', 'Proposal download action'); + `[data-cy="edge-label-actions-list-DRAFT-FEASIBILITY_REVIEW"]` + ).contains('Proposal download action'); }); it('User Officer should be able to update a status action added to the workflow connection', () => { @@ -139,12 +136,15 @@ context('Status actions tests', () => { }; cy.addWorkflowStatus({ - droppableGroupId: initialDBData.workflows.defaultDroppableGroup, statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, sortOrder: 1, prevStatusId: initialDBData.proposalStatuses.draft.id, + posX: 0, + posY: 200, + prevConnectionId: 1, }).then((result) => { + cy.reload(); cy.addConnectionStatusActions({ actions: [ { @@ -164,15 +164,10 @@ context('Status actions tests', () => { cy.finishedLoading(); cy.get( - `[data-cy^="connection_FEASIBILITY_REVIEW"] [data-testid="PendingActionsIcon"]` - ).should('exist'); - cy.get( - `[data-cy^="connection_FEASIBILITY_REVIEW"] [data-testid="PendingActionsIcon"]` - ).realHover(); + `[data-cy="edge-label-actions-list-DRAFT-FEASIBILITY_REVIEW"]` + ).contains('Email action'); - cy.get('[role="tooltip"]').contains('Status actions: Email action'); - - cy.get(`[data-cy^="connection_FEASIBILITY_REVIEW"]`).click(); + cy.get(`[aria-label="Edge from DRAFT to FEASIBILITY_REVIEW"]`).click(); cy.get('[data-cy="status-events-and-actions-modal"]') .contains('Status actions') @@ -229,11 +224,13 @@ context('Status actions tests', () => { }; cy.addWorkflowStatus({ - droppableGroupId: initialDBData.workflows.defaultDroppableGroup, statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, sortOrder: 1, prevStatusId: initialDBData.proposalStatuses.draft.id, + posX: 0, + posY: 200, + prevConnectionId: 1, }).then((result) => { cy.addConnectionStatusActions({ actions: [ @@ -254,7 +251,7 @@ context('Status actions tests', () => { cy.finishedLoading(); - cy.get(`[data-cy^="connection_FEASIBILITY_REVIEW"]`).click(); + cy.get(`[aria-label="Edge from DRAFT to FEASIBILITY_REVIEW"]`).click(); cy.get('[data-cy="status-events-and-actions-modal"]') .contains('Status actions') @@ -279,7 +276,7 @@ context('Status actions tests', () => { cy.closeModal(); cy.get( - `[data-cy^="connection_FEASIBILITY_REVIEW"] [data-testid="PendingActionsIcon"]` + '[data-cy="edge-label-events-list-DRAFT-FEASIBILITY_REVIEW"]' ).should('not.exist'); }); @@ -299,11 +296,13 @@ context('Status actions tests', () => { const validEmail = faker.internet.email(); cy.addWorkflowStatus({ - droppableGroupId: initialDBData.workflows.defaultDroppableGroup, statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, sortOrder: 1, prevStatusId: initialDBData.proposalStatuses.draft.id, + posX: 0, + posY: 200, + prevConnectionId: 1, }).then((result) => { cy.addConnectionStatusActions({ actions: [ @@ -323,7 +322,7 @@ context('Status actions tests', () => { cy.finishedLoading(); - cy.get(`[data-cy^="connection_FEASIBILITY_REVIEW"]`).click(); + cy.get(`[aria-label="Edge from DRAFT to FEASIBILITY_REVIEW"]`).click(); cy.get('[data-cy="status-events-and-actions-modal"]') .contains('Status actions') @@ -416,7 +415,7 @@ context('Status actions tests', () => { cy.closeModal(); - cy.get(`[data-cy^="connection_FEASIBILITY_REVIEW"]`).click(); + cy.get(`[aria-label="Edge from DRAFT to FEASIBILITY_REVIEW"]`).click(); cy.get('[data-cy="status-events-and-actions-modal"]') .contains('Status actions') @@ -448,11 +447,13 @@ context('Status actions tests', () => { }; cy.addWorkflowStatus({ - droppableGroupId: initialDBData.workflows.defaultDroppableGroup, statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, sortOrder: 1, prevStatusId: initialDBData.proposalStatuses.draft.id, + posX: 0, + posY: 150, + prevConnectionId: 1, }).then((result) => { cy.addConnectionStatusActions({ actions: [ @@ -473,7 +474,7 @@ context('Status actions tests', () => { cy.finishedLoading(); - cy.get(`[data-cy^="connection_FEASIBILITY_REVIEW"]`).click(); + cy.get(`[aria-label="Edge from DRAFT to FEASIBILITY_REVIEW"]`).click(); cy.get('[data-cy="status-events-and-actions-modal"]') .contains('Status actions') @@ -499,19 +500,16 @@ context('Status actions tests', () => { cy.closeModal(); cy.get( - `[data-cy^="connection_FEASIBILITY_REVIEW"] [data-testid="PendingActionsIcon"]` - ).should('exist'); + '[data-cy="edge-label-actions-list-DRAFT-FEASIBILITY_REVIEW"]' + ).contains('Email action'); cy.get( - `[data-cy^="connection_FEASIBILITY_REVIEW"] [data-testid="PendingActionsIcon"]` - ).realHover(); - - cy.get('[role="tooltip"]') - .should('contain', 'Status actions') - .and('contain', 'Email action') - .and('contain', 'RabbitMQ action') - .and('contain', 'Proposal download action'); + '[data-cy="edge-label-actions-list-DRAFT-FEASIBILITY_REVIEW"]' + ).contains('RabbitMQ action'); + cy.get( + '[data-cy="edge-label-actions-list-DRAFT-FEASIBILITY_REVIEW"]' + ).contains('Proposal download action'); - cy.get(`[data-cy^="connection_FEASIBILITY_REVIEW"]`).click(); + cy.get(`[aria-label="Edge from DRAFT to FEASIBILITY_REVIEW"]`).click(); cy.get('[data-cy="status-events-and-actions-modal"]') .contains('Status actions') @@ -570,11 +568,13 @@ context('Status actions tests', () => { }; cy.addWorkflowStatus({ - droppableGroupId: initialDBData.workflows.defaultDroppableGroup, statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, sortOrder: 1, prevStatusId: initialDBData.proposalStatuses.draft.id, + posX: 0, + posY: 200, + prevConnectionId: 1, }).then((result) => { cy.addConnectionStatusActions({ actions: [ @@ -690,11 +690,12 @@ context('Status actions tests', () => { }; cy.addWorkflowStatus({ - droppableGroupId: initialDBData.workflows.defaultDroppableGroup, statusId: initialDBData.proposalStatuses.editableSubmitted.id, workflowId: initialDBData.workflows.defaultWorkflow.id, sortOrder: 1, prevStatusId: initialDBData.proposalStatuses.draft.id, + posX: 0, + posY: 200, }).then((result) => { const connection = result.addWorkflowStatus; if (connection) { diff --git a/apps/e2e/cypress/e2e/techniqueProposals.cy.ts b/apps/e2e/cypress/e2e/techniqueProposals.cy.ts index 2be1bdd313..7c8833b923 100644 --- a/apps/e2e/cypress/e2e/techniqueProposals.cy.ts +++ b/apps/e2e/cypress/e2e/techniqueProposals.cy.ts @@ -37,6 +37,7 @@ context('Technique Proposal tests', () => { let callWorkflowId: number; // Workflow with QUICK_REVIEW status + faker.seed(1); // sometimes faker generates the same value twice in a row. This seed is safe to use const proposalWorkflow = { name: faker.lorem.words(2), description: faker.lorem.words(5), @@ -256,10 +257,11 @@ context('Technique Proposal tests', () => { if (result.createWorkflow) { cy.addWorkflowStatus({ - droppableGroupId: workflow.workflowConnectionGroups[0].groupId, statusId: quickReviewStatus.id as number, workflowId: callWorkflowId, sortOrder: 1, + posX: 0, + posY: 200, }); } diff --git a/apps/e2e/cypress/e2e/templatesBasic.cy.ts b/apps/e2e/cypress/e2e/templatesBasic.cy.ts index 670b7abccb..3e421df31a 100644 --- a/apps/e2e/cypress/e2e/templatesBasic.cy.ts +++ b/apps/e2e/cypress/e2e/templatesBasic.cy.ts @@ -504,7 +504,7 @@ context('Template Basic tests', () => { cy.get( `[data-cy=proposals-count-${initialDBData.template.id}]` ) - .contains('1') + .contains('2') .click(); cy.get('[data-cy=proposals-modal]').contains( @@ -767,6 +767,107 @@ context('Template Basic tests', () => { } }); + it('should render the Interval question field accepting only positive, negative numbers if set', () => { + const generateId = () => + `${faker.lorem.word()}_${faker.lorem.word()}_${faker.lorem.word()}`; + + const questions = [ + { + id: generateId(), + title: faker.lorem.words(3), + valueConstraint: 'Only positive numbers', + fieldName: 'numberField2', + badInput: '1{leftarrow}-', + goodInput: '1', + failureMessage: 'Value must be a positive number', + }, + { + id: generateId(), + title: faker.lorem.words(3), + valueConstraint: 'Only negative numbers', + fieldName: 'numberField3', + badInput: '1', + goodInput: '1{leftarrow}-', + failureMessage: 'Value must be a negative number', + }, + { + id: generateId(), + title: faker.lorem.words(3), + valueConstraint: 'Only negative integers', + fieldName: 'numberField4', + badInput: '1.1{leftarrow}{leftarrow}{leftarrow}-', + goodInput: '-1', + failureMessage: 'Value must be negative whole number', + }, + { + id: generateId(), + title: faker.lorem.words(3), + valueConstraint: 'Only positive integers', + fieldName: 'numberField5', + badInput: '1.1', + goodInput: '1', + failureMessage: 'Value must be positive whole number', + }, + ]; + + cy.login('officer'); + cy.visit(`/QuestionaryEditor/${initialDBData.template.id}`); + cy.finishedLoading(); + + /* Create questions */ + for (const question of questions) { + cy.createIntervalQuestion(question.title, { + key: question.id, + units: ['kelvin'], + valueConstraint: question.valueConstraint, + firstTopic: true, + }); + } + + cy.logout(); + + cy.login('user1', initialDBData.roles.user); + cy.visit('/'); + + cy.contains('New Proposal').click(); + cy.get('[data-cy=call-list]').find('li:first-child').click(); + + /* Test questions exist */ + for (const question of questions) { + cy.contains(question.title); + } + + /* Test bad inputs */ + for (const question of questions) { + cy.get(`[data-natural-key="${question.id}-Min"] input`).type( + question.badInput + ); + cy.get(`[data-natural-key="${question.id}-Max"] input`).type( + question.badInput + ); + } + cy.get('[data-cy="save-and-continue-button"]').focus(); + cy.get('[data-cy="save-and-continue-button"]').click(); + for (const question of questions) { + cy.contains(question.failureMessage); + } + + /* Test good inputs */ + for (const question of questions) { + cy.get(`[data-natural-key="${question.id}-Min"] input`) + .clear() + .type(question.goodInput); + cy.get(`[data-natural-key="${question.id}-Max"] input`) + .clear() + .type(question.goodInput); + } + cy.get('[data-cy="save-and-continue-button"]').focus(); + cy.get('[data-cy="save-and-continue-button"]').click(); + for (const question of questions) { + cy.contains(question.failureMessage).should('not.exist'); + } + }); + it('User officer can add multiple choice question as a dependency', () => { cy.createProposal({ callId: initialDBData.call.id }).then((result) => { const createdProposal = result.createProposal; @@ -1139,7 +1240,7 @@ context('Template Basic tests', () => { cy.visit('/Proposals'); - cy.get('[data-cy="view-proposal').click(); + cy.get('[data-cy="view-proposal"]').first().click(); cy.contains(booleanQuestion).should('exist'); diff --git a/apps/e2e/cypress/fixtures/exampleCallFapExportSTFC.json b/apps/e2e/cypress/fixtures/exampleCallFapExportSTFC.json index 959c30ca76..79e3836dac 100755 --- a/apps/e2e/cypress/fixtures/exampleCallFapExportSTFC.json +++ b/apps/e2e/cypress/fixtures/exampleCallFapExportSTFC.json @@ -14,10 +14,10 @@ "Technical Review Comment": "earum ante at accusantium crustulum", "Average score": 3, "Reviewer 1": "Nils Nilsson ", - "Reviewer 1 score": 4, + "Reviewer 1 score": "4", "Reviewer 1 review comment": "earum ante at accusantium crustulum", "Reviewer 2": " Dawson ", - "Reviewer 2 score": 2, + "Reviewer 2 score": "2", "Reviewer 2 review comment": "earum ante at accusantium crustulum", "Reviewer 3": "No reviewer", "Reviewer 3 score": "-", @@ -39,10 +39,10 @@ "Technical Review Comment": "acquiro amita ad dolore cariosus", "Average score": 3, "Reviewer 1": "Nils Nilsson ", - "Reviewer 1 score": 5, + "Reviewer 1 score": "5", "Reviewer 1 review comment": "acquiro amita ad dolore cariosus", "Reviewer 2": " Dawson ", - "Reviewer 2 score": 1, + "Reviewer 2 score": "1", "Reviewer 2 review comment": "acquiro amita ad dolore cariosus", "Reviewer 3": "No reviewer", "Reviewer 3 score": "-", diff --git a/apps/e2e/cypress/fixtures/exampleFapExportSTFC.json b/apps/e2e/cypress/fixtures/exampleFapExportSTFC.json index 61eb40b40a..a4572b0a88 100755 --- a/apps/e2e/cypress/fixtures/exampleFapExportSTFC.json +++ b/apps/e2e/cypress/fixtures/exampleFapExportSTFC.json @@ -11,10 +11,10 @@ "Technical Review Comment": "earum ante at accusantium crustulum", "Average score": 3, "Reviewer 1": "Nils Nilsson ", - "Reviewer 1 score": 4, + "Reviewer 1 score": "4", "Reviewer 1 review comment": "earum ante at accusantium crustulum", "Reviewer 2": " Dawson ", - "Reviewer 2 score": 2, + "Reviewer 2 score": "2", "Reviewer 2 review comment": "earum ante at accusantium crustulum", "Reviewer 3": "No reviewer", "Reviewer 3 score": "-", @@ -32,10 +32,10 @@ "Technical Review Comment": "acquiro amita ad dolore cariosus", "Average score": 3, "Reviewer 1": "Nils Nilsson ", - "Reviewer 1 score": 5, + "Reviewer 1 score": "5", "Reviewer 1 review comment": "acquiro amita ad dolore cariosus", "Reviewer 2": " Dawson ", - "Reviewer 2 score": 1, + "Reviewer 2 score": "1", "Reviewer 2 review comment": "acquiro amita ad dolore cariosus", "Reviewer 3": "No reviewer", "Reviewer 3 score": "-", diff --git a/apps/e2e/cypress/fixtures/template_export.json b/apps/e2e/cypress/fixtures/template_export.json index 34352ec02d..6d941a3cd5 100644 --- a/apps/e2e/cypress/fixtures/template_export.json +++ b/apps/e2e/cypress/fixtures/template_export.json @@ -1,7 +1,7 @@ { "metadata": { "version": "1.2.0", - "exportDate": "2025-08-06T13:41:10.324Z" + "exportDate": "2025-10-17T11:23:20.021Z" }, "data": { "template": { @@ -246,121 +246,6 @@ "dependencies": [], "dependenciesOperator": "AND" }, - { - "question": { - "categoryId": 1, - "id": "dynamic_multiple_choice_question", - "naturalKey": "dynamic_multiple_choice_question", - "dataType": "DYNAMIC_MULTIPLE_CHOICE", - "question": "Dynamic multiple choice question from seeds", - "config": { - "required": false, - "small_label": "", - "tooltip": "", - "variant": "dropdown", - "url": "", - "jsonPath": "", - "isMultipleSelect": true, - "apiCallRequestHeaders": [], - "readPermissions": [] - } - }, - "topicId": 8, - "sortOrder": 6, - "config": { - "required": false, - "small_label": "", - "tooltip": "", - "variant": "dropdown", - "url": "", - "jsonPath": "", - "isMultipleSelect": true, - "apiCallRequestHeaders": [], - "readPermissions": [] - }, - "dependencies": [], - "dependenciesOperator": "AND" - }, - { - "question": { - "categoryId": 1, - "id": "instrument_picker_question", - "naturalKey": "instrument_picker_question", - "dataType": "INSTRUMENT_PICKER", - "question": "Instrument Picker question from seeds", - "config": { - "small_label": "", - "required": false, - "tooltip": "", - "variant": "dropdown", - "instruments": [], - "isMultipleSelect": false, - "requestTime": false, - "readPermissions": [], - "options": [ - 1, - 2 - ] - } - }, - "topicId": 8, - "sortOrder": 6, - "config": { - "small_label": "", - "required": false, - "tooltip": "", - "variant": "dropdown", - "instruments": [], - "isMultipleSelect": false, - "requestTime": false, - "readPermissions": [], - "options": [ - 1, - 2 - ] - }, - "dependencies": [], - "dependenciesOperator": "AND" - }, - { - "question": { - "categoryId": 1, - "id": "technique_picker_question", - "naturalKey": "technique_picker_question", - "dataType": "TECHNIQUE_PICKER", - "question": "Technique Picker question from seeds", - "config": { - "small_label": "", - "required": false, - "tooltip": "", - "variant": "dropdown", - "techniques": [], - "isMultipleSelect": false, - "readPermissions": [], - "options": [ - 1, - 2 - ] - } - }, - "topicId": 8, - "sortOrder": 6, - "config": { - "small_label": "", - "required": false, - "tooltip": "", - "variant": "dropdown", - "techniques": [], - "isMultipleSelect": false, - "readPermissions": [], - "options": [ - 1, - 2 - ] - }, - "dependencies": [], - "dependenciesOperator": "AND" - }, { "question": { "categoryId": 1, @@ -539,6 +424,121 @@ }, "dependencies": [], "dependenciesOperator": "AND" + }, + { + "question": { + "categoryId": 1, + "id": "dynamic_multiple_choice_question", + "naturalKey": "dynamic_multiple_choice_question", + "dataType": "DYNAMIC_MULTIPLE_CHOICE", + "question": "Dynamic multiple choice question from seeds", + "config": { + "required": false, + "small_label": "", + "tooltip": "", + "variant": "dropdown", + "url": "", + "jsonPath": "", + "isMultipleSelect": true, + "apiCallRequestHeaders": [], + "readPermissions": [] + } + }, + "topicId": 8, + "sortOrder": 10, + "config": { + "required": false, + "small_label": "", + "tooltip": "", + "variant": "dropdown", + "url": "", + "jsonPath": "", + "isMultipleSelect": true, + "apiCallRequestHeaders": [], + "readPermissions": [] + }, + "dependencies": [], + "dependenciesOperator": "AND" + }, + { + "question": { + "categoryId": 1, + "id": "instrument_picker_question", + "naturalKey": "instrument_picker_question", + "dataType": "INSTRUMENT_PICKER", + "question": "Instrument Picker question from seeds", + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "variant": "dropdown", + "instruments": [], + "isMultipleSelect": false, + "requestTime": false, + "readPermissions": [], + "options": [ + 1, + 2 + ] + } + }, + "topicId": 8, + "sortOrder": 11, + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "variant": "dropdown", + "instruments": [], + "isMultipleSelect": false, + "requestTime": false, + "readPermissions": [], + "options": [ + 1, + 2 + ] + }, + "dependencies": [], + "dependenciesOperator": "AND" + }, + { + "question": { + "categoryId": 1, + "id": "technique_picker_question", + "naturalKey": "technique_picker_question", + "dataType": "TECHNIQUE_PICKER", + "question": "Technique Picker question from seeds", + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "variant": "dropdown", + "techniques": [], + "isMultipleSelect": false, + "readPermissions": [], + "options": [ + 1, + 2 + ] + } + }, + "topicId": 8, + "sortOrder": 12, + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "variant": "dropdown", + "techniques": [], + "isMultipleSelect": false, + "readPermissions": [], + "options": [ + 1, + 2 + ] + }, + "dependencies": [], + "dependenciesOperator": "AND" } ] } @@ -654,65 +654,6 @@ "readPermissions": [] } }, - { - "categoryId": 1, - "id": "dynamic_multiple_choice_question", - "naturalKey": "dynamic_multiple_choice_question", - "dataType": "DYNAMIC_MULTIPLE_CHOICE", - "question": "Dynamic multiple choice question from seeds", - "config": { - "required": false, - "small_label": "", - "tooltip": "", - "variant": "dropdown", - "url": "", - "jsonPath": "", - "isMultipleSelect": true, - "apiCallRequestHeaders": [], - "readPermissions": [] - } - }, - { - "categoryId": 1, - "id": "instrument_picker_question", - "naturalKey": "instrument_picker_question", - "dataType": "INSTRUMENT_PICKER", - "question": "Instrument Picker question from seeds", - "config": { - "small_label": "", - "required": false, - "tooltip": "", - "variant": "dropdown", - "instruments": [], - "isMultipleSelect": false, - "requestTime": false, - "readPermissions": [], - "options": [ - 1, - 2 - ] - } - }, - { - "categoryId": 1, - "id": "technique_picker_question", - "naturalKey": "technique_picker_question", - "dataType": "TECHNIQUE_PICKER", - "question": "Technique Picker question from seeds", - "config": { - "small_label": "", - "required": false, - "tooltip": "", - "variant": "dropdown", - "techniques": [], - "isMultipleSelect": false, - "readPermissions": [], - "options": [ - 1, - 2 - ] - } - }, { "categoryId": 1, "id": "selection_from_options_question", @@ -804,6 +745,65 @@ "required": false, "small_label": "" } + }, + { + "categoryId": 1, + "id": "dynamic_multiple_choice_question", + "naturalKey": "dynamic_multiple_choice_question", + "dataType": "DYNAMIC_MULTIPLE_CHOICE", + "question": "Dynamic multiple choice question from seeds", + "config": { + "required": false, + "small_label": "", + "tooltip": "", + "variant": "dropdown", + "url": "", + "jsonPath": "", + "isMultipleSelect": true, + "apiCallRequestHeaders": [], + "readPermissions": [] + } + }, + { + "categoryId": 1, + "id": "instrument_picker_question", + "naturalKey": "instrument_picker_question", + "dataType": "INSTRUMENT_PICKER", + "question": "Instrument Picker question from seeds", + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "variant": "dropdown", + "instruments": [], + "isMultipleSelect": false, + "requestTime": false, + "readPermissions": [], + "options": [ + 1, + 2 + ] + } + }, + { + "categoryId": 1, + "id": "technique_picker_question", + "naturalKey": "technique_picker_question", + "dataType": "TECHNIQUE_PICKER", + "question": "Technique Picker question from seeds", + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "variant": "dropdown", + "techniques": [], + "isMultipleSelect": false, + "readPermissions": [], + "options": [ + 1, + 2 + ] + } } ], "subTemplates": [ diff --git a/apps/e2e/cypress/support/initialDBData.ts b/apps/e2e/cypress/support/initialDBData.ts index 7615fa107a..e85b5a381a 100644 --- a/apps/e2e/cypress/support/initialDBData.ts +++ b/apps/e2e/cypress/support/initialDBData.ts @@ -299,6 +299,13 @@ export default { email: 'instr.sci1@local.host', password: 'Test1234!', }, + instrumentScientist2: { + id: 101, + firstName: 'Instrument', + lastName: 'Scientist2', + email: 'instr.sci2@local.host', + password: 'Test1234!', + }, experimentSafetyReviewer1: { id: 103, firstName: 'Experiment', @@ -311,52 +318,92 @@ export default { draft: { id: 1, name: 'DRAFT', + shortCode: 'DRAFT', }, feasibilityReview: { id: 2, name: 'FEASIBILITY_REVIEW', + shortCode: 'FEASIBILITY_REVIEW', + }, + notFeasible: { + id: 3, + name: 'NOT_FEASIBLE', + shortCode: 'NOT_FEASIBLE', + }, + fapSelection: { + id: 4, + name: 'FAP_SELECTION', + shortCode: 'FAP_SELECTION', }, - notFeasible: { id: 3 }, - fapSelection: { id: 4 }, fapReview: { id: 5, name: 'FAP_REVIEW', + shortCode: 'FAP_REVIEW', }, expired: { id: 9, name: 'EXPIRED', + shortCode: 'EXPIRED', }, fapMeeting: { id: 12, name: 'FAP Meeting', + shortCode: 'FAP_MEETING', }, editableSubmitted: { id: 14, name: 'EDITABLE_SUBMITTED', + shortCode: 'EDITABLE_SUBMITTED', }, editableSubmittedInternal: { id: 15, name: 'EDITABLE_SUBMITTED_INTERNAL', + shortCode: 'EDITABLE_SUBMITTED_INTERNAL', + }, + awaitingEsf: { + id: 17, + name: 'AWAITING ESF', + shortCode: 'AWAITING_ESF', + }, + esfIsReview: { + id: 18, + name: 'ESF_IS_REVIEW', + shortCode: 'ESF_IS_REVIEW', + }, + esfEsrReview: { + id: 19, + name: 'ESF ESR REVIEW', + shortCode: 'ESF_ESR_REVIEW', + }, + esfRejected: { + id: 20, + name: 'ESF REJECTED', + shortCode: 'ESF_REJECTED', }, quickReview: { id: 22, name: 'QUICK_REVIEW', + shortCode: 'QUICK_REVIEW', }, underReview: { id: 23, name: 'UNDER_REVIEW', + shortCode: 'UNDER_REVIEW', }, approved: { id: 24, name: 'APPROVED', + shortCode: 'APPROVED', }, unsuccessful: { id: 25, name: 'UNSUCCESSFUL', + shortCode: 'UNSUCCESSFUL', }, finished: { id: 26, name: 'FINISHED', + shortCode: 'FINISHED', }, }, experiments: { diff --git a/apps/e2e/cypress/support/template.ts b/apps/e2e/cypress/support/template.ts index 4b07e78dc2..905d816ecf 100644 --- a/apps/e2e/cypress/support/template.ts +++ b/apps/e2e/cypress/support/template.ts @@ -546,12 +546,24 @@ function createNumberInputQuestion( function createIntervalQuestion( question: string, - options?: { units?: string[] } + options?: { + key?: string; + isRequired?: boolean; + units?: string[]; + valueConstraint?: string; + firstTopic?: boolean; + } ) { - openQuestionsMenu(); + openQuestionsMenu({ + firstTopic: options?.firstTopic, + }); cy.contains('Add Interval').click(); + if (options?.key) { + cy.get('[data-cy=natural_key]').clear().type(options.key); + } + cy.get('[data-cy=question]').clear().type(question); if (options?.units && options.units.length > 0) { @@ -561,6 +573,11 @@ function createIntervalQuestion( } } + if (options?.valueConstraint) { + cy.get('[data-cy="numberValueConstraint"]').click(); + cy.contains(options?.valueConstraint).click(); + } + cy.contains('Save').click(); cy.contains(question) diff --git a/apps/e2e/cypress/support/utils.ts b/apps/e2e/cypress/support/utils.ts index b9a7da06a9..0b9af3c262 100644 --- a/apps/e2e/cypress/support/utils.ts +++ b/apps/e2e/cypress/support/utils.ts @@ -189,8 +189,18 @@ const setTinyMceContent = (tinyMceId: string, content: string) => { cy.wait(1000); cy.window().then((win) => { + // setContent does not trigger change and blur event so we need to do it manually + // However, blur event needs to be triggered after a timeout as the onChange function + // may not have finished before the blur event is triggered. const editor = getEditorById(win, tinyMceId); editor?.setContent(content); + editor?.fire('change'); + // Since editor is a cypress object we cannot just use a cy.wait here + setTimeout(() => { + editor?.fire('blur'); + }, 1000); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); }); }; diff --git a/apps/e2e/cypress/support/workflow.ts b/apps/e2e/cypress/support/workflow.ts index 13785d46d7..2e4dcbdc32 100644 --- a/apps/e2e/cypress/support/workflow.ts +++ b/apps/e2e/cypress/support/workflow.ts @@ -9,6 +9,7 @@ import { CreateStatusMutationVariables, CreateWorkflowMutation, CreateWorkflowMutationVariables, + Status, } from '@user-office-software-libs/shared-types'; import initialDBData from './initialDBData'; @@ -66,13 +67,97 @@ const addConnectionStatusActions = ( const addFeasibilityReviewToDefaultWorkflow = (): Cypress.Chainable => { return cy.addWorkflowStatus({ - droppableGroupId: 'proposalWorkflowConnections_0', statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: 1, sortOrder: 1, prevStatusId: 1, + posX: 0, + posY: 200, }); }; +/** + * Creates a proper DataTransfer mock that stores and returns data like the real browser API + */ +function createDataTransferMock() { + return { + _data: new Map(), + setData: function (format: string, data: string) { + this._data.set(format, data); + }, + getData: function (format: string) { + return this._data.get(format) || ''; + }, + effectAllowed: 'move' as const, + }; +} + +/** + * Simulates dragging a ReactFlow status element to a drop target + * @param sourceSelector - Cypress selector for the draggable element + * @param targetSelector - Cypress selector for the drop target + * @param statusId - The status ID to be transferred via DataTransfer + * @param options - Additional options for the drag operation + */ + +function dragStatusIntoWorkflow( + status: Pick, + options: { + clientX?: number; + clientY?: number; + } = {} +) { + const { clientX = 500, clientY = 300 } = options; + + const sourceSelector = `[data-cy="status_${status.shortCode}"]`; + const targetSelector = '[data-testid="rf__background"]'; + cy.get(sourceSelector).then(($element) => { + const sourceElement = $element[0]; + + // Create a mock DataTransfer that stores data like the real one + const mockDataTransfer = createDataTransferMock(); + + // Trigger dragstart with the proper mock + cy.wrap(sourceElement).trigger('dragstart', { + dataTransfer: mockDataTransfer, + force: true, + }); + + // Set the status ID data as the real component would + mockDataTransfer.setData('application/reactflow', status.id.toString()); + + // Trigger dragover and drop on the target + cy.get(targetSelector).trigger('dragover', { + dataTransfer: mockDataTransfer, + force: true, + }); + + cy.get(targetSelector).trigger('drop', { + dataTransfer: mockDataTransfer, + clientX, + clientY, + force: true, + }); + }); +} + +/** + * Simulates connecting two ReactFlow nodes by dragging from source handle to target handle + * @param sourceNodeSelector - Cypress selector for the source node connection handle + * @param targetNodeSelector - Cypress selector for the target node connection handle + * @param options - Additional options for the connection operation + */ +function connectReactFlowNodes( + sourceStatus: Pick, + targetStatus: Pick, + options?: { + force?: boolean; + } +) { + const sourceNodeSelector = `[data-cy="connection_${sourceStatus.shortCode}"] [data-handlepos="bottom"]`; + const targetNodeSelector = `[data-cy="connection_${targetStatus.shortCode}"] [data-handlepos="top"]`; + cy.get(sourceNodeSelector).click(options); + cy.get(targetNodeSelector).click(options); +} Cypress.Commands.add('createWorkflow', createWorkflow); Cypress.Commands.add('createStatus', createStatus); @@ -86,3 +171,5 @@ Cypress.Commands.add( 'addFeasibilityReviewToDefaultWorkflow', addFeasibilityReviewToDefaultWorkflow ); +Cypress.Commands.add('dragStatusIntoWorkflow', dragStatusIntoWorkflow); +Cypress.Commands.add('connectReactFlowNodes', connectReactFlowNodes); diff --git a/apps/e2e/cypress/types/template.d.ts b/apps/e2e/cypress/types/template.d.ts index fbfb2563e7..b06a42f2ae 100644 --- a/apps/e2e/cypress/types/template.d.ts +++ b/apps/e2e/cypress/types/template.d.ts @@ -235,7 +235,13 @@ declare global { */ createIntervalQuestion: ( title: string, - options?: { units?: string[] } + options?: { + key?: string; + isRequired?: boolean; + units?: string[]; + valueConstraint?: string; + firstTopic?: boolean; + } ) => void; /** diff --git a/apps/e2e/cypress/types/workflow.d.ts b/apps/e2e/cypress/types/workflow.d.ts index bdda6b3b7e..da5b461d0a 100644 --- a/apps/e2e/cypress/types/workflow.d.ts +++ b/apps/e2e/cypress/types/workflow.d.ts @@ -9,6 +9,7 @@ import { AddWorkflowStatusMutation, AddConnectionStatusActionsMutation, AddConnectionStatusActionsMutationVariables, + Status, } from '@user-office-software-libs/shared-types'; declare global { @@ -82,6 +83,41 @@ declare global { * cy.addFeasibilityReviewToDefaultWorkflow() */ addFeasibilityReviewToDefaultWorkflow: () => Cypress.Chainable; + + /** + * Drags a status element into the workflow canvas area + * @param sourceSelector - Status object representing the status to be dragged + * @param options - Additional options for the drag operation + * @example + * cy.dragStatusIntoWorkflow(initialDBData.proposalStatuses.draft, { clientX: 100, clientY: 200 }); + */ + dragStatusIntoWorkflow( + sourceSelector: Pick, + options?: { + clientX?: number; + clientY?: number; + dataFormat?: string; + } + ): Chainable; + + /** + * Simulates connecting two ReactFlow nodes by dragging from source handle to target handle + * @param sourceStatus - Status object representing the source node + * @param targetStatus - Status object representing the target node + * @param options - Additional options for the connection operation + * @example + * cy.connectReactFlowNodes( + * initialDBData.proposalStatuses.draft, + * initialDBData.proposalStatuses.feasibilityReview + * ); + */ + connectReactFlowNodes( + sourceStatus: Pick, + targetStatus: Pick, + options?: { + force?: boolean; + } + ): Chainable; } } } diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 0a402eb9e5..259c021ab6 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -26,7 +26,7 @@ "@types/yup": "^0.29.13", "@uiw/react-codemirror": "^4.22.1", "@user-office-software/duo-localisation": "^1.2.0", - "@user-office-software/duo-validation": "5.1.17", + "@user-office-software/duo-validation": "5.1.19", "formik": "^2.4.6", "graphql": "^16.8.1", "graphql-request": "^6.1.0", @@ -46,6 +46,7 @@ "react-fast-compare": "^3.2.0", "react-i18next": "^14.1.2", "react-router-dom": "^6.23.1", + "reactflow": "^11.11.4", "tinymce": "^7.5.1", "typescript": "^4.7.4", "yup": "^0.32.11" @@ -3996,6 +3997,108 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/@remix-run/router": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", @@ -4271,6 +4374,259 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -4287,6 +4643,12 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", @@ -4737,9 +5099,9 @@ "integrity": "sha512-9axYsEtg5rr7FDibUTIaGxd1+sRpvreytLuhEQBZdzA+MXL5TxZcDGAqSQarrTklHP4mcJTWvDzyMV/yCKmYOw==" }, "node_modules/@user-office-software/duo-validation": { - "version": "5.1.17", - "resolved": "https://registry.npmjs.org/@user-office-software/duo-validation/-/duo-validation-5.1.17.tgz", - "integrity": "sha512-jHbkG5vJq92+BZbuCSmEtQABaPGy0rG3GilgVgPKikKcDktxEQ+x/Y3OLNymMI5dwLg+nVCWdyNxCU/u6yX0Vg==", + "version": "5.1.19", + "resolved": "https://registry.npmjs.org/@user-office-software/duo-validation/-/duo-validation-5.1.19.tgz", + "integrity": "sha512-Lt5uRHrtLbJvs79A2oESebzIKn1KaW+sAKD5vlio7y5YM/13NoyTixYFTqBhuK5Gs8xDT6gaGcVf0VKESQ5mjQ==", "license": "ISC", "dependencies": { "luxon": "^2.5.0", @@ -4755,6 +5117,7 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", "integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==", + "license": "MIT", "engines": { "node": ">=12" } @@ -5496,6 +5859,12 @@ "node": ">=10" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -5724,6 +6093,111 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -9603,6 +10077,24 @@ "react-dom": ">=16.6.0" } }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", @@ -13899,6 +14391,78 @@ "integrity": "sha512-8MsdlQJ4jaw3GT+zpCHS33nwnvzpY0ED6DEahZg9WngG++A5RMhk8LSlxdHelwaFFHFiXBjmOaj2Kpxh50VQRg==", "requires": {} }, + "@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "requires": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + } + }, + "@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "requires": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + } + }, + "@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "requires": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + } + }, + "@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "requires": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + } + }, + "@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "requires": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + } + }, + "@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "requires": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + } + }, "@remix-run/router": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", @@ -14071,6 +14635,228 @@ "@babel/types": "^7.20.7" } }, + "@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "requires": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==" + }, + "@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "requires": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" + }, + "@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==" + }, + "@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==" + }, + "@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "requires": { + "@types/d3-dsv": "*" + } + }, + "@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==" + }, + "@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==" + }, + "@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "requires": { + "@types/geojson": "*" + } + }, + "@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==" + }, + "@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==" + }, + "@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==" + }, + "@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==" + }, + "@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==" + }, + "@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" + }, + "@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "requires": { + "@types/d3-path": "*" + } + }, + "@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==" + }, + "@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "requires": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -14087,6 +14873,11 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==" + }, "@types/hoist-non-react-statics": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", @@ -14405,9 +15196,9 @@ "integrity": "sha512-9axYsEtg5rr7FDibUTIaGxd1+sRpvreytLuhEQBZdzA+MXL5TxZcDGAqSQarrTklHP4mcJTWvDzyMV/yCKmYOw==" }, "@user-office-software/duo-validation": { - "version": "5.1.17", - "resolved": "https://registry.npmjs.org/@user-office-software/duo-validation/-/duo-validation-5.1.17.tgz", - "integrity": "sha512-jHbkG5vJq92+BZbuCSmEtQABaPGy0rG3GilgVgPKikKcDktxEQ+x/Y3OLNymMI5dwLg+nVCWdyNxCU/u6yX0Vg==", + "version": "5.1.19", + "resolved": "https://registry.npmjs.org/@user-office-software/duo-validation/-/duo-validation-5.1.19.tgz", + "integrity": "sha512-Lt5uRHrtLbJvs79A2oESebzIKn1KaW+sAKD5vlio7y5YM/13NoyTixYFTqBhuK5Gs8xDT6gaGcVf0VKESQ5mjQ==", "requires": { "luxon": "^2.5.0", "sanitize-html": "^2.7.1", @@ -14955,6 +15746,11 @@ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "optional": true }, + "classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" + }, "classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -15138,6 +15934,72 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==" + }, + "d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + } + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==" + }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, + "d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "requires": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + } + }, + "d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + } + }, "data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -17884,6 +18746,19 @@ "prop-types": "^15.6.2" } }, + "reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "requires": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + } + }, "readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f9e11276b6..1f3cfc8536 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -26,7 +26,7 @@ "@types/yup": "^0.29.13", "@uiw/react-codemirror": "^4.22.1", "@user-office-software/duo-localisation": "^1.2.0", - "@user-office-software/duo-validation": "5.1.17", + "@user-office-software/duo-validation": "5.1.19", "formik": "^2.4.6", "graphql": "^16.8.1", "graphql-request": "^6.1.0", @@ -46,6 +46,7 @@ "react-fast-compare": "^3.2.0", "react-i18next": "^14.1.2", "react-router-dom": "^6.23.1", + "reactflow": "^11.11.4", "tinymce": "^7.5.1", "typescript": "^4.7.4", "yup": "^0.32.11" diff --git a/apps/frontend/src/components/call/AssignedInstrumentsTable.tsx b/apps/frontend/src/components/call/AssignedInstrumentsTable.tsx index 65edb5f386..f37a880e4a 100644 --- a/apps/frontend/src/components/call/AssignedInstrumentsTable.tsx +++ b/apps/frontend/src/components/call/AssignedInstrumentsTable.tsx @@ -148,6 +148,13 @@ const AssignedInstrumentsTable = ({ return { isValid: true }; } + // NOTE: We sometimes don't know how much availability time is needed, so null is allowed + if (rowData.availabilityTime === null) { + return { + isValid: true, + }; + } + if (rowData.availabilityTime && +rowData.availabilityTime > 0) { // NOTE: Preventing inputs grater than 32-bit integer. if (+rowData.availabilityTime >= MAX_32_BIT_INTEGER) { diff --git a/apps/frontend/src/components/call/CallGeneralInfo.tsx b/apps/frontend/src/components/call/CallGeneralInfo.tsx index ec8502dbd6..312e345be0 100644 --- a/apps/frontend/src/components/call/CallGeneralInfo.tsx +++ b/apps/frontend/src/components/call/CallGeneralInfo.tsx @@ -182,14 +182,12 @@ const CallGeneralInfo = ({ (value) => value.id === proposalWorkflowId ); if (selectedProposalWorkFlow) { - const result = selectedProposalWorkFlow.workflowConnectionGroups.some( - (workGroup) => { - return workGroup.connections.some((connectionStatus) => { - return ( - connectionStatus.status.shortCode === - ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED_INTERNAL - ); - }); + const result = selectedProposalWorkFlow.workflowConnections.some( + (connectionStatus) => { + return ( + connectionStatus.status.shortCode === + ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED_INTERNAL + ); } ); setInternalCallDate({ showField: result, isValueSet: true }); diff --git a/apps/frontend/src/components/call/CreateUpdateCall.tsx b/apps/frontend/src/components/call/CreateUpdateCall.tsx index 5416554f1d..aa61ca1bcd 100644 --- a/apps/frontend/src/components/call/CreateUpdateCall.tsx +++ b/apps/frontend/src/components/call/CreateUpdateCall.tsx @@ -168,7 +168,6 @@ const CreateUpdateCall = ({ call, close }: CreateUpdateCallProps) => { { - console.log({ values }); if (call) { const { updateCall } = await api({ toastSuccessMessage: 'Call updated successfully!', diff --git a/apps/frontend/src/components/common/UserManagementTable.tsx b/apps/frontend/src/components/common/UserManagementTable.tsx index 4569087afa..b8fb64e5ef 100644 --- a/apps/frontend/src/components/common/UserManagementTable.tsx +++ b/apps/frontend/src/components/common/UserManagementTable.tsx @@ -1,7 +1,7 @@ import { ScheduleSend } from '@mui/icons-material'; import PersonAddIcon from '@mui/icons-material/PersonAdd'; import SendIcon from '@mui/icons-material/Send'; -import { Chip } from '@mui/material'; +import { Chip, Tooltip } from '@mui/material'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import FormControl from '@mui/material/FormControl'; @@ -15,40 +15,44 @@ import { FeatureContext } from 'context/FeatureContextProvider'; import { UserContext } from 'context/UserContextProvider'; import { BasicUserDetails, FeatureId, Invite, UserRole } from 'generated/sdk'; -import InviteUser from '../proposal/InviteUser'; import ParticipantModal from '../proposal/ParticipantModal'; +import ParticipantSelector from '../proposal/ParticipantSelector'; export type UserManagementTableProps = { /** Basic user details array to be shown in the table. */ users: BasicUserDetails[]; /** Function for setting up the users. */ setUsers: (users: BasicUserDetails[]) => void; - invites: Invite[]; - setInvites: (invites: Invite[]) => void; + invites?: Invite[]; + setInvites?: (invites: Invite[]) => void; sx?: SxProps; title: string; preserveSelf?: boolean; addButtonLabel?: string; + addButtonTooltip?: string; /** Disable the add button */ disabled?: boolean; /** Custom actions to be passed to PeopleTable */ onUserAction?: (action: string, user: BasicUserDetails) => void; /** Additional excluded user IDs for invite flow */ excludeUserIds?: number[]; + allowInviteByEmail?: boolean; }; const UserManagementTable = ({ users, setUsers, - invites, + invites = [], setInvites, sx, title, preserveSelf, addButtonLabel = 'Add', + addButtonTooltip = 'Add a participant', disabled = false, onUserAction, excludeUserIds = [], + allowInviteByEmail = false, }: UserManagementTableProps) => { const [modalOpen, setOpen] = useState(false); const { featuresMap } = useContext(FeatureContext); @@ -71,7 +75,7 @@ const UserManagementTable = ({ invites: Invite[]; }) => { setUsers([...users, ...props.users]); - setInvites([...invites, ...props.invites]); + setInvites?.([...invites, ...props.invites]); setOpen(false); }; @@ -83,12 +87,13 @@ const UserManagementTable = ({ }; const handleDeleteInvite = (invite: Invite) => { - setInvites(invites.filter((i) => i.email !== invite.email)); + setInvites?.(invites.filter((i) => i.email !== invite.email)); }; const InviteComponent = ( - setOpen(false)} onAddParticipants={handleAddParticipants} excludeUserIds={[...users.map((user) => user.id), ...excludeUserIds]} @@ -96,6 +101,7 @@ const UserManagementTable = ({ ...(invites?.map((invite) => invite.email) || []), ...(currentUser.email ? [currentUser.email.toLowerCase()] : []), ]} + allowInviteByEmail={allowInviteByEmail} /> ); @@ -194,16 +200,18 @@ const UserManagementTable = ({ marginTop: theme.spacing(1), })} > - + + + diff --git a/apps/frontend/src/components/experiment/ExperimentVisitsTable.tsx b/apps/frontend/src/components/experiment/ExperimentVisitsTable.tsx index 39c9d01720..99617f3b1e 100644 --- a/apps/frontend/src/components/experiment/ExperimentVisitsTable.tsx +++ b/apps/frontend/src/components/experiment/ExperimentVisitsTable.tsx @@ -15,7 +15,7 @@ import { SettingsContext } from 'context/SettingsContextProvider'; import { SettingsId, VisitRegistrationStatus, - GetAllExperimentsQuery, + GetExperimentsQuery, GetExperimentQuery, } from 'generated/sdk'; import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; @@ -26,7 +26,7 @@ import withConfirm, { WithConfirmProps } from 'utils/withConfirm'; type RowType = NonNullable< NonNullable< - NonNullable['experiments'] + NonNullable['experiments'] >[0]['visit'] >['registrations'][0]; diff --git a/apps/frontend/src/components/experiment/ExperimentsTable.tsx b/apps/frontend/src/components/experiment/ExperimentsTable.tsx index 52a02f51ba..53c3f35ff8 100644 --- a/apps/frontend/src/components/experiment/ExperimentsTable.tsx +++ b/apps/frontend/src/components/experiment/ExperimentsTable.tsx @@ -101,7 +101,7 @@ export default function ExperimentsTable({ experimentStartDate, experimentEndDate, } = experimentsFilter; - const { allExperiments } = await api().getAllExperiments({ + const { allExperiments } = await api().getExperiments({ filter: { callId, instrumentId, @@ -202,7 +202,7 @@ export default function ExperimentsTable({ ]; return ( - <> +
)} - +
); } diff --git a/apps/frontend/src/components/fap/FapPage.tsx b/apps/frontend/src/components/fap/FapPage.tsx index c38ef6b23b..0bc83e5759 100644 --- a/apps/frontend/src/components/fap/FapPage.tsx +++ b/apps/frontend/src/components/fap/FapPage.tsx @@ -17,6 +17,7 @@ import FapGeneralInfo from './General/FapGeneralInfo'; import FapMeetingComponentsView from './MeetingComponents/FapMeetingComponentsView'; import FapMembers from './Members/FapMembers'; import FapProposalsAndAssignmentsView from './Proposals/FapProposalsAndAssignmentsView'; +import LegacyFapProposals from './Proposals/LegacyFapProposals'; const FapPage = () => { const { id } = useParams(); @@ -76,6 +77,15 @@ const FapPage = () => { name: 'Documents', element: , }, + { + name: 'Legacy Proposals', + element: ( + setFap(newFap)} + /> + ), + }, ]; if (isFapChairOrSecretary || isUserOfficer) { @@ -120,6 +130,15 @@ const FapPage = () => { name: 'Meeting Components', element: , }, + { + name: 'Legacy Proposals', + element: ( + setFap(newFap)} + /> + ), + }, ]; } diff --git a/apps/frontend/src/components/fap/Proposals/AssignFapMemberToProposalModal.tsx b/apps/frontend/src/components/fap/Proposals/AssignFapMemberToProposalModal.tsx index cff0327f8a..7a79bb200e 100644 --- a/apps/frontend/src/components/fap/Proposals/AssignFapMemberToProposalModal.tsx +++ b/apps/frontend/src/components/fap/Proposals/AssignFapMemberToProposalModal.tsx @@ -9,8 +9,11 @@ import PeopleTable from 'components/user/PeopleTable'; import { BasicUserDetails, Maybe, Role } from 'generated/sdk'; import { useFapMembersData } from 'hooks/fap/useFapMembersData'; +import { MultiRankAssignmentDialog } from './MultiRankAssignmentDialog'; + export type FapAssignedMember = BasicUserDetails & { role?: Maybe>; + rank?: number | null; }; type AssignFapMemberToProposalModalProps = { @@ -44,6 +47,7 @@ const AssignFapMemberToProposalModal = ({ BasicUserDetails[] >([]); const { loadingMembers, FapMembersData } = useFapMembersData(fapId, false); + const [rankSelectorOpen, setRankSelectorOpen] = useState(false); useEffect(() => { if (proposalPks.length === 0) { @@ -98,8 +102,27 @@ const AssignFapMemberToProposalModal = ({ disabled={selectedParticipants.length === 0} data-cy="assign-selected-users" > - Update + Assign without Rankings + + + {rankSelectorOpen && ( + + )} ); diff --git a/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx b/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx index ab2dbb5ca1..ac463898c0 100644 --- a/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx +++ b/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx @@ -2,7 +2,10 @@ import MaterialTable from '@material-table/core'; import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; import RateReviewIcon from '@mui/icons-material/RateReview'; import Visibility from '@mui/icons-material/Visibility'; +import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; +import { Tooltip } from '@mui/material'; import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; import React, { useCallback, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; @@ -30,42 +33,18 @@ type FapAssignedReviewersTableProps = { proposalPk: number ) => Promise; updateView: (proposalPk: number) => Promise; + editable?: boolean; }; -const assignmentColumns = [ - { - title: 'First name', - field: 'user.firstname', - }, - { - title: 'Last name', - field: 'user.lastname', - }, - { - title: 'Date assigned', - field: 'dateAssignedFormatted', - }, - { - title: 'Rank', - field: 'rank', - emptyValue: '-', - hidden: true, - }, - { title: 'Review status', field: 'review.status' }, - { - title: 'Grade', - field: 'review.grade', - emptyValue: '-', - }, -]; - const FapAssignedReviewersTable = ({ fapProposal, fapSecs, removeAssignedReviewer, updateView, + editable = true, }: FapAssignedReviewersTableProps) => { const { api } = useDataApiWithFeedback(); + const theme = useTheme(); const [searchParams, setSearchParams] = useSearchParams(); const reviewerModal = searchParams.get('reviewerModal'); @@ -94,10 +73,6 @@ const FapAssignedReviewersTable = ({ PROPOSAL_MODAL_TAB_NAMES.GRADE, ]; - assignmentColumns[ - assignmentColumns.findIndex((col) => col.field === 'rank') - ].hidden = !hasAccessRights; - const fapAssignmentsStringified = JSON.stringify(fapProposal.assignments); const getFapAssignments = useCallback( () => @@ -128,6 +103,60 @@ const FapAssignedReviewersTable = ({ setFapAssignmentsWithIdAndFormattedDate(getFapAssignments()); }, [getFapAssignments]); + const assignmentColumns = [ + { + title: 'First name', + field: 'user.firstname', + }, + { + title: 'Last name', + field: 'user.lastname', + }, + { + title: 'Date assigned', + field: 'dateAssignedFormatted', + }, + { + title: 'Rank', + field: 'rank', + emptyValue: '-', + hidden: !hasAccessRights, + render: (rowData: FapProposalAssignmentType) => { + if ( + rowData.rank !== null && + fapAssignmentsWithIdAndFormattedDate.some( + (fa) => fa.user?.id !== rowData.user?.id && fa.rank === rowData.rank + ) + ) { + return ( + + + + {rowData.rank} + + + ); + } + + return {rowData.rank}; + }, + }, + { title: 'Review status', field: 'review.status' }, + { + title: 'Grade', + field: 'review.grade', + emptyValue: '-', + }, + ]; + const proposalReviewModalShouldOpen = !!reviewerModal && openProposalPk === fapProposal.proposalPk; @@ -142,15 +171,44 @@ const FapAssignedReviewersTable = ({ setOpenProposalPk(null); }; - const editableTableRow = hasAccessRights - ? { - deleteTooltip: () => 'Remove assignment', - onRowDelete: ( - rowAssignmentsData: FapProposalAssignmentType - ): Promise => - removeAssignedReviewer(rowAssignmentsData, fapProposal.proposalPk), - } - : {}; + const editableTableRow = + editable && hasAccessRights + ? { + deleteTooltip: () => 'Remove assignment', + onRowDelete: ( + rowAssignmentsData: FapProposalAssignmentType + ): Promise => + removeAssignedReviewer(rowAssignmentsData, fapProposal.proposalPk), + } + : {}; + + const reviewProposals = ( + review: { + id: number; + status: ReviewStatus | undefined; + } | null + ) => { + if (!review) { + return; + } + + setSearchParams((searchParams) => { + if (review) searchParams.set('reviewerModal', review.id.toString()); + searchParams.set( + 'modalTab', + isDraftStatus(review?.status) + ? reviewProposalTabNames + .indexOf(PROPOSAL_MODAL_TAB_NAMES.GRADE) + .toString() + : reviewProposalTabNames + .indexOf(PROPOSAL_MODAL_TAB_NAMES.PROPOSAL_INFORMATION) + .toString() + ); + + return searchParams; + }); + setOpenProposalPk(fapProposal.proposalPk); + }; return ( ({ - icon: isDraftStatus(rowData?.review?.status) - ? () => - : () => , - onClick: () => { - if (!rowData.review) { - return; - } - - setSearchParams((searchParams) => { - if (rowData.review) - searchParams.set( - 'reviewerModal', - rowData.review.id.toString() - ); - searchParams.set( - 'modalTab', - isDraftStatus(rowData.review?.status) - ? reviewProposalTabNames - .indexOf(PROPOSAL_MODAL_TAB_NAMES.GRADE) - .toString() - : reviewProposalTabNames - .indexOf(PROPOSAL_MODAL_TAB_NAMES.PROPOSAL_INFORMATION) - .toString() - ); - - return searchParams; - }); - setOpenProposalPk(fapProposal.proposalPk); - }, - tooltip: isDraftStatus(rowData.review?.status) - ? 'Grade proposal' - : 'View review', - }), - (rowData) => ({ - icon: () => , - onClick: () => { - setRankReviewer({ - fapReviewId: rowData.review?.id as number, - reviewer: rowData.fapMemberUserId, - rank: rowData.rank, - }); - }, - tooltip: 'Rank Reviewer', - }), - ]} + actions={ + editable + ? [ + (rowData) => ({ + icon: isDraftStatus(rowData?.review?.status) + ? () => + : () => , + onClick: () => reviewProposals(rowData.review), + tooltip: isDraftStatus(rowData.review?.status) + ? 'Grade proposal' + : 'View review', + }), + (rowData) => ({ + icon: () => ( + + ), + onClick: () => { + setRankReviewer({ + fapReviewId: rowData.review?.id as number, + reviewer: rowData.fapMemberUserId, + rank: rowData.rank, + }); + }, + tooltip: 'Rank Reviewer', + }), + ] + : [ + (rowData) => ({ + icon: () => ( + + ), + onClick: () => reviewProposals(rowData.review), + tooltip: 'View review', + }), + ] + } options={{ search: false, paging: false, diff --git a/apps/frontend/src/components/fap/Proposals/FapLegacyProposalsTable.tsx b/apps/frontend/src/components/fap/Proposals/FapLegacyProposalsTable.tsx new file mode 100644 index 0000000000..3dc10d8b40 --- /dev/null +++ b/apps/frontend/src/components/fap/Proposals/FapLegacyProposalsTable.tsx @@ -0,0 +1,431 @@ +import { Action, Column } from '@material-table/core'; +import GetAppIcon from '@mui/icons-material/GetApp'; +import Visibility from '@mui/icons-material/Visibility'; +import { IconButton, Tooltip, Typography } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; + +import CopyToClipboard from 'components/common/CopyToClipboard'; +import MaterialTable from 'components/common/DenseMaterialTable'; +import FapAssignedReviewersTable from 'components/fap/Proposals/FapAssignedReviewersTable'; +import ProposalReviewContent, { + PROPOSAL_MODAL_TAB_NAMES, +} from 'components/review/ProposalReviewContent'; +import ProposalReviewModal from 'components/review/ProposalReviewModal'; +import { Review, SettingsId, Fap } from 'generated/sdk'; +import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; +import { + FapProposalType, + FapProposalAssignmentType, + FapProposals, +} from 'hooks/fap/useFapProposalsData'; +import { useDownloadPDFProposal } from 'hooks/proposal/useDownloadPDFProposal'; +import { tableIcons } from 'utils/materialIcons'; +import { + average, + getGradesFromReviews, + standardDeviation, +} from 'utils/mathFunctions'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; +import withConfirm, { WithConfirmType } from 'utils/withConfirm'; + +type FapLegacyProposalsTableProps = { + /** Fap we are assigning members to */ + data: Fap; + /** Call this function in case of Fap assigned members update */ + onAssignmentsUpdate: (fap: Fap) => void; + /** Call id that we want to filter by */ + selectedCallId: number | null; + /** Confirmation function that comes from withConfirm HOC */ + confirm: WithConfirmType; + selectedInstrumentId: number | null; + fapProposals: FapProposals; +}; + +const getReviewsFromAssignments = (assignments: FapProposalAssignmentType[]) => + assignments + .map((assignment) => assignment.review) + .filter((review): review is Review => !!review); + +const FapProposalColumns: Column[] = [ + { + title: 'Actions', + cellStyle: { padding: 0, minWidth: 80 }, + sorting: false, + removable: false, + field: 'rowActionButtons', + }, + { + title: 'ID', + field: 'proposal.proposalId', + render: (rawData) => ( + + {rawData.proposal.proposalId || ''} + + ), + }, + { + title: 'Title', + field: 'proposal.title', + }, + { + title: 'Status', + field: 'proposal.status.name', + }, + { + title: 'Date assigned', + field: 'dateAssignedFormatted', + }, + { + title: 'Reviewers', + render: (data) => data.assignments?.length, + }, + { + title: 'Reviews', + render: (rowData) => { + const totalReviews = rowData.assignments?.length; + const gradedProposals = rowData.assignments?.filter( + (assignment) => + assignment.review !== null && assignment.review.grade !== null + ); + const countReviews = gradedProposals?.length || 0; + + return totalReviews === 0 ? '-' : `${countReviews} / ${totalReviews}`; + }, + }, + { + title: 'Average grade', + render: (rowData) => { + const avgGrade = average( + getGradesFromReviews( + getReviewsFromAssignments(rowData.assignments ?? []) + ) + ); + + return avgGrade === 0 ? '-' : `${avgGrade}`; + }, + customSort: (a, b) => + average( + getGradesFromReviews(getReviewsFromAssignments(a.assignments ?? [])) + ) - + average( + getGradesFromReviews(getReviewsFromAssignments(b.assignments ?? [])) + ), + }, + { + title: 'Deviation', + field: 'deviation', + render: (rowData) => { + const stdDeviation = standardDeviation( + getGradesFromReviews( + getReviewsFromAssignments(rowData.assignments ?? []) + ) + ); + + return isNaN(stdDeviation) ? '-' : `${stdDeviation}`; + }, + customSort: (a, b) => + standardDeviation( + getGradesFromReviews(getReviewsFromAssignments(a.assignments ?? [])) + ) - + standardDeviation( + getGradesFromReviews(getReviewsFromAssignments(b.assignments ?? [])) + ), + }, + { + title: 'Instrument', + field: 'instrument.name', + }, +]; + +const FapLegacyProposalsTable = ({ + data, + onAssignmentsUpdate, + fapProposals, +}: FapLegacyProposalsTableProps) => { + const [searchParams, setSearchParams] = useSearchParams(); + const reviewModal = searchParams.get('reviewModal'); + + const { loadingFapProposals, FapProposalsData, setFapProposalsData } = + fapProposals; + const { api } = useDataApiWithFeedback(); + const downloadPDFProposal = useDownloadPDFProposal(); + const { toFormattedDateTime } = useFormattedDateTime({ + settingsFormatToUse: SettingsId.DATE_FORMAT, + }); + const { t } = useTranslation(); + + const translatedColumns = FapProposalColumns.map((column) => + column.title === 'Instrument' + ? { ...column, title: t('instrument') } + : column + ); + + const page = searchParams.get('page'); + const pageSize = searchParams.get('pageSize'); + const selection = searchParams.getAll('selection'); + /** + * NOTE: Custom action buttons are here because when we have them inside actions on the material-table + * and selection flag is true they are not working properly. + */ + const RowActionButtons = (rowData: FapProposalType) => ( + <> + + { + setSearchParams((searchParams) => { + searchParams.set('reviewModal', rowData.proposalPk.toString()); + + return searchParams; + }); + }} + > + + + + + ); + + const handleBulkDownloadClick = ( + event: React.MouseEventHandler, + rowData: FapProposalType | FapProposalType[] + ) => { + if (!Array.isArray(rowData)) { + return; + } + + downloadPDFProposal( + rowData.map((row) => row.proposalPk), + rowData[0].proposal.title + ); + }; + + const initialValues: FapProposalType[] = FapProposalsData; + const tableActions: Action[] = []; + + tableActions.push({ + icon: () => , + tooltip: 'Download proposals', + onClick: handleBulkDownloadClick, + position: 'toolbarOnSelect', + }); + + const ReviewersTable = React.useCallback( + ({ rowData }: Record<'rowData', FapProposalType>) => { + const removeAssignedReviewer = async ( + assignedReviewer: FapProposalAssignmentType, + proposalPk: number + ): Promise => { + await api({ + toastSuccessMessage: 'Reviewer removed', + }).removeMemberFromFapProposal({ + proposalPk, + fapId: data.id, + memberId: assignedReviewer.fapMemberUserId as number, + }); + + setFapProposalsData((fapProposalData) => + fapProposalData.map((proposalItem) => { + if (proposalItem.proposalPk === proposalPk) { + const newAssignments = + proposalItem.assignments?.filter( + (oldAssignment) => + oldAssignment.fapMemberUserId !== + assignedReviewer.fapMemberUserId + ) || []; + + return { + ...proposalItem, + assignments: newAssignments, + }; + } else { + return proposalItem; + } + }) + ); + + onAssignmentsUpdate({ + ...data, + fapChairsCurrentProposalCounts: + data.fapChairsCurrentProposalCounts.map((value) => { + return { + userId: value.userId, + count: + assignedReviewer.fapMemberUserId === value.userId + ? value.count - 1 + : value.count, + }; + }), + fapSecretariesCurrentProposalCounts: + data.fapSecretariesCurrentProposalCounts.map((value) => { + return { + userId: value.userId, + count: + assignedReviewer.fapMemberUserId === value.userId + ? value.count - 1 + : value.count, + }; + }), + }); + }; + + const loadFapProposal = async (proposalPk: number) => { + return api() + .getFapProposal({ fapId: data.id, proposalPk }) + .then((data) => { + return data.fapProposal; + }); + }; + + const updateFapProposalAssignmentsView = async (proposalPk: number) => { + const refreshedFapProposal = await loadFapProposal(proposalPk); + + if (refreshedFapProposal) { + setFapProposalsData((fapProposalsData) => { + return fapProposalsData.map((fapProposal) => ({ + ...fapProposal, + proposal: { + ...fapProposal.proposal, + status: + refreshedFapProposal.proposalPk === fapProposal.proposalPk + ? refreshedFapProposal.proposal.status + : fapProposal.proposal.status, + }, + assignments: + refreshedFapProposal.proposalPk === fapProposal.proposalPk + ? refreshedFapProposal.assignments + : fapProposal.assignments, + })); + }); + } + }; + + return ( + user.id)} + removeAssignedReviewer={removeAssignedReviewer} + updateView={updateFapProposalAssignmentsView} + editable={false} + /> + ); + }, + [setFapProposalsData, data, onAssignmentsUpdate, api] + ); + + const FapProposalsWitIdAndFormattedDate = initialValues.map((fapProposal) => + Object.assign(fapProposal, { + id: fapProposal.proposalPk, + rowActionButtons: RowActionButtons(fapProposal), + dateAssignedFormatted: toFormattedDateTime(fapProposal.dateAssigned), + tableData: { + checked: selection.includes(fapProposal.proposalPk.toString()), + }, + }) + ); + + const maxPageLength = FapProposalsWitIdAndFormattedDate.length; + + const pageSizeOptions = [5, 10, 20, maxPageLength] + .sort((a, b) => a - b) + .filter((n) => n <= maxPageLength); + + return ( + <> + { + setSearchParams((searchParams) => { + searchParams.delete('reviewModal'); + + return searchParams; + }); + }} + > + user.id)} + /> + +
+ + {`${data.code} - ${t('Fap')} Proposals`} + + } + data={FapProposalsWitIdAndFormattedDate} + isLoading={loadingFapProposals} + localization={{ + toolbar: { + nRowsSelected: '{0} proposal(s) selected', + }, + }} + detailPanel={[ + { + tooltip: 'Show Reviewers', + render: ReviewersTable, + }, + ]} + actions={tableActions} + options={{ + search: true, + selection: true, + pageSize: pageSize ? +pageSize : Math.min(10, maxPageLength), + initialPage: page ? +page : 0, + pageSizeOptions: pageSizeOptions, + headerSelectionProps: { + inputProps: { + 'aria-label': 'Select all rows', + id: 'select-all-table-rows', + }, + }, + }} + onPageChange={(page) => { + setSearchParams((searchParams) => { + searchParams.set('page', page.toString()); + + return searchParams; + }); + }} + onRowsPerPageChange={(pageSize) => { + setSearchParams((searchParams) => { + searchParams.set('pageSize', pageSize.toString()); + + return searchParams; + }); + }} + onSelectionChange={(selectedItems) => { + const selectedProposalPks = selectedItems.map( + (item) => item.proposalPk + ); + + setSearchParams((searchParams) => { + searchParams.delete('selection'); + selectedProposalPks.forEach((pk) => + searchParams.append('selection', pk.toString()) + ); + + return searchParams; + }); + }} + /> +
+ + ); +}; + +export default withConfirm(FapLegacyProposalsTable); diff --git a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsTable.tsx b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsTable.tsx index 130421a21a..f44fcc1582 100644 --- a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsTable.tsx +++ b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsTable.tsx @@ -51,7 +51,7 @@ type ProposalReview = { id: number; userID: number; comment: string | null; - grade: number | null; + grade: string | null; status: ReviewStatus; fapID: number; proposalPk?: number; @@ -311,7 +311,11 @@ const FapProposalsAndAssignmentsTable = ({ (assignment) => assignment.assignments ); - const proposalAssignments: { memberId: number; proposalPk: number }[] = []; + const proposalAssignments: { + memberId: number; + proposalPk: number; + rank?: number | null; + }[] = []; const updatedMembers = new Set(); for (const proposalPk of proposalPks) { @@ -322,7 +326,11 @@ const FapProposalsAndAssignmentsTable = ({ proposalPk === existingProposalAssignment.proposalPk ); if (!isExistingAssignment) { - proposalAssignments.push({ memberId: assignedMember.id, proposalPk }); + proposalAssignments.push({ + memberId: assignedMember.id, + proposalPk, + rank: assignedMember.rank ?? null, + }); updatedMembers.add(assignedMember); } } @@ -406,7 +414,7 @@ const FapProposalsAndAssignmentsTable = ({ dateAssigned: DateTime.now(), user, role, - rank: null, + rank: user.rank ?? null, review: allProposalReviews.find( (review) => diff --git a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx index 9443d7df6e..5ab7de7258 100644 --- a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx +++ b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx @@ -1,5 +1,5 @@ import Grid from '@mui/material/Grid'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import CallFilter from 'components/common/proposalFilters/CallFilter'; @@ -23,17 +23,33 @@ const FapProposalsAndAssignments = ({ onFapUpdate, fapProposals, }: FapProposalsAndAssignmentsProps) => { - const { loadingCalls, calls } = useCallsData({ fapIds: [fapData.id] }); + const { loadingCalls, calls } = useCallsData({ + fapIds: [fapData.id], + isFapReviewEnded: false, + }); // NOTE: Default null means load all calls if nothing is selected const { loadingInstruments, instruments } = useFapInstruments( fapData.id, null ); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const call = searchParams.get('call'); const instrument = searchParams.get('instrument'); + useEffect(() => { + const currentCall = call ? parseInt(call) : null; + + // If the selected call is not valid, remove it from the URL + if (call && !loadingCalls && !calls.find((c) => c.id === currentCall)) { + setSearchParams((searchParams) => { + searchParams.delete('call'); + + return searchParams; + }); + } + }, [call, calls, loadingCalls, searchParams, setSearchParams]); + return ( <> diff --git a/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx b/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx new file mode 100644 index 0000000000..561992fe3c --- /dev/null +++ b/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx @@ -0,0 +1,95 @@ +import Grid from '@mui/material/Grid'; +import React, { useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import CallFilter from 'components/common/proposalFilters/CallFilter'; +import InstrumentFilter from 'components/common/proposalFilters/InstrumentFilter'; +import { Fap } from 'generated/sdk'; +import { useCallsData } from 'hooks/call/useCallsData'; +import { useFapProposalsData } from 'hooks/fap/useFapProposalsData'; +import { useFapInstruments } from 'hooks/instrument/useFapInstruments'; + +import FapLegacyProposalsTable from './FapLegacyProposalsTable'; + +type LegacyFapProposalsProps = { + /** Id of the Fap we are assigning members to */ + data: Fap; + onFapUpdate: (fap: Fap) => void; +}; + +const LegacyFapProposals = ({ + data: fapData, + onFapUpdate, +}: LegacyFapProposalsProps) => { + const { loadingCalls, calls } = useCallsData({ + fapIds: [fapData.id], + isFapReviewEnded: true, + }); + // NOTE: Default null means load all calls if nothing is selected + const { loadingInstruments, instruments } = useFapInstruments( + fapData.id, + null + ); + const [searchParams, setSearchParams] = useSearchParams(); + const call = searchParams.get('call'); + const instrument = searchParams.get('instrument'); + + useEffect(() => { + const currentCall = call ? parseInt(call) : null; + + // If the selected call is not valid, remove it from the URL + if (call && !loadingCalls && !calls.find((c) => c.id === currentCall)) { + setSearchParams((searchParams) => { + searchParams.delete('call'); + + return searchParams; + }); + } + }, [call, calls, loadingCalls, searchParams, setSearchParams]); + + // Refetch fap proposals too keep more the commenly used current fap proposals in memory + const { loadingFapProposals, FapProposalsData, setFapProposalsData } = + useFapProposalsData( + fapData.id, + call ? parseInt(call) : null, + instrument ? parseInt(instrument) : null, + true // legacy flag set to true + ); + + return ( + <> + + + + + + + + + + + ); +}; + +export default LegacyFapProposals; diff --git a/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx b/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx new file mode 100644 index 0000000000..2ea628b1b8 --- /dev/null +++ b/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx @@ -0,0 +1,178 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import React, { ChangeEvent } from 'react'; + +import { BasicUserDetails } from 'generated/sdk'; + +import { FapAssignedMember } from './AssignFapMemberToProposalModal'; + +type MultiRankAssignmentDialogProps = { + users: BasicUserDetails[]; + open: boolean; + setOpen: (open: boolean) => void; + assign: (usersWithRank: FapAssignedMember[]) => void; +}; + +type BasicUserDetailsWithRank = BasicUserDetails & { + rank: number | null; // Rank can be null if not assigned +}; + +export const MultiRankAssignmentDialog = ({ + users, + open, + setOpen, + assign, +}: MultiRankAssignmentDialogProps) => { + const theme = useTheme(); + + const [usersWithRank, setUsersWithRank] = React.useState< + BasicUserDetailsWithRank[] + >( + users.map((user) => ({ + ...user, + rank: null, + })) + ); + + return ( + {}} + maxWidth="sm" + fullWidth + data-cy="multi-rank-assignment-dialog" + > + Mass Assignments with Ranking + + + Please assign a unique rank to each user, Ranks must be whole numbers + and greater than 1. + + + + + + First Name + Surname + Rank + + + + {usersWithRank.map((row) => ( + + user.id !== row.id && + user.rank === row.rank && + row.rank !== null + ) + ? theme.palette.error.light + : 'inherit', + }} + > + + {row.firstname} + + {row.lastname} + + + user.id !== row.id && + user.rank === row.rank && + row.rank !== null + ) || + (row.rank !== null && row.rank < 1) || + (row.rank !== null && isNaN(row.rank)) + } + sx={{ width: 100 }} + onChange={(e: ChangeEvent) => { + const rank = Number(e.target.value); // Non numeric values become 0 + + setUsersWithRank((prev) => + prev.map((user) => + user.id === row.id + ? { + ...user, + rank: !Number.isInteger(rank) ? 0 : rank, + } // If integer, set to 0 (invalid) + : user + ) + ); + }} + required + fullWidth + margin="none" + /> + + + ))} + +
+
+
+ + + + +
+ ); +}; diff --git a/apps/frontend/src/components/menu/MenuItems.tsx b/apps/frontend/src/components/menu/MenuItems.tsx index d925c09301..e60c5e4141 100644 --- a/apps/frontend/src/components/menu/MenuItems.tsx +++ b/apps/frontend/src/components/menu/MenuItems.tsx @@ -8,7 +8,6 @@ import GroupWorkIcon from '@mui/icons-material/GroupWork'; import Help from '@mui/icons-material/Help'; import NoteAdd from '@mui/icons-material/NoteAdd'; import People from '@mui/icons-material/People'; -import SettingsApplications from '@mui/icons-material/SettingsApplications'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; @@ -27,6 +26,7 @@ import SettingsMenuListItem from './SettingsMenuListItem'; import { StatusActionLogsMenuListItem } from './StatusActionLogsMenuListItem'; import { TemplateMenuListItem } from './TemplateMenuListItem'; import CommentQuestionIcon from '../common/icons/CommentQuestionIcon'; +import ProposalWorkflowIcon from '../common/icons/ProposalWorkflowIcon'; import ScienceIcon from '../common/icons/ScienceIcon'; type MenuItemsProps = { @@ -124,6 +124,14 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { const userOfficer = (
+ + + + + + + + @@ -152,22 +160,13 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { )} - - - - - - - - - - {isUserManagementEnabled && ( - - + {isFapEnabled && ( + + - + - + )} @@ -181,6 +180,7 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { )} + @@ -199,24 +199,22 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { )} - {isFapEnabled && ( - - - - - - - - - )} - - + + - + - + + {isUserManagementEnabled && ( @@ -228,6 +226,17 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { )} + + {isUserManagementEnabled && ( + + + + + + + + + )} diff --git a/apps/frontend/src/components/menu/ProposalMenuListItem.tsx b/apps/frontend/src/components/menu/ProposalMenuListItem.tsx new file mode 100644 index 0000000000..97a13cf68c --- /dev/null +++ b/apps/frontend/src/components/menu/ProposalMenuListItem.tsx @@ -0,0 +1,84 @@ +import { Topic } from '@mui/icons-material'; +import ExpandLess from '@mui/icons-material/ExpandLess'; +import ExpandMore from '@mui/icons-material/ExpandMore'; +import FolderCopyIcon from '@mui/icons-material/FolderCopy'; +import FolderOpen from '@mui/icons-material/FolderOpen'; +import { ListItemButton } from '@mui/material'; +import Collapse from '@mui/material/Collapse'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { NavLink } from 'react-router-dom'; + +import Tooltip from 'components/common/MenuTooltip'; +import { UserRole } from 'generated/sdk'; +import { CallsDataQuantity, useCallsData } from 'hooks/call/useCallsData'; +import { useTechniqueProposalAccess } from 'hooks/common/useTechniqueProposalAccess'; + +export function ProposalMenuListItem() { + const isTechniqueProposalsEnabled = useTechniqueProposalAccess([ + UserRole.USER_OFFICER, + UserRole.INSTRUMENT_SCIENTIST, + ]); + + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = useState(false); + + const calls = useCallsData( + { + proposalStatusShortCode: 'QUICK_REVIEW', + }, + CallsDataQuantity.MINIMAL + ).calls; + const openCall = calls?.find((call) => call.isActive); + const techniqueProposalUrl = + openCall && openCall.id + ? `/TechniqueProposals?call=${openCall?.id}` + : '/TechniqueProposals'; + + function toggleExpand() { + setIsExpanded(!isExpanded); + } + + // Single menu item + + return ( + <> + + + + + {isExpanded ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + {isTechniqueProposalsEnabled && ( + + + + + + + + + )} + + + ); +} diff --git a/apps/frontend/src/components/menu/SettingsMenuListItem.tsx b/apps/frontend/src/components/menu/SettingsMenuListItem.tsx index b3454e7baf..e323a3cc23 100644 --- a/apps/frontend/src/components/menu/SettingsMenuListItem.tsx +++ b/apps/frontend/src/components/menu/SettingsMenuListItem.tsx @@ -4,6 +4,7 @@ import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import FunctionsIcon from '@mui/icons-material/Functions'; import Settings from '@mui/icons-material/Settings'; +import SettingsApplications from '@mui/icons-material/SettingsApplications'; import ViewModuleIcon from '@mui/icons-material/ViewModule'; import VpnKey from '@mui/icons-material/VpnKey'; import { ListItemButton } from '@mui/material'; @@ -18,12 +19,10 @@ import { FeatureContext } from 'context/FeatureContextProvider'; import { FeatureId } from 'generated/sdk'; import ProposalSettingsIcon from '../common/icons/ProposalSettingsIcon'; -import ProposalWorkflowIcon from '../common/icons/ProposalWorkflowIcon'; const menuMap = { Units: '/Units', ProposalStatuses: '/ProposalStatuses', - ProposalWorkflows: '/ProposalWorkflows', ExperimentWorkflows: '/ExperimentWorkflows', ApiAccessTokens: '/ApiAccessTokens', Features: '/Features', @@ -51,7 +50,7 @@ const SettingsMenuListItem = () => { return ( <> - + {isExpanded ? ( @@ -66,7 +65,7 @@ const SettingsMenuListItem = () => { )} - + @@ -79,6 +78,14 @@ const SettingsMenuListItem = () => { + + + + + + + + { - - - - - - - - - {isExperimentSafetyEnabled && ( & { + setPrincipalInvestigator?: (user: BasicUserDetails) => void; +}; + +const CoProposers = ({ + setPrincipalInvestigator, + ...props +}: CoProposersProps) => { + const handleUserAction = (action: string, user: BasicUserDetails) => { + if (action === 'setPrincipalInvestigator' && setPrincipalInvestigator) { + setPrincipalInvestigator(user); + } + }; + + const { state } = useContext(QuestionaryContext) as ProposalContextType; + if (!state) { + throw new Error(createMissingContextErrorMessage()); + } + const { proposer, users, coProposerInvites } = state.proposal; + + return ( + + ); +}; + +export default CoProposers; diff --git a/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx b/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx index fcca03fb8d..60df81b840 100644 --- a/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx +++ b/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx @@ -6,7 +6,9 @@ import { ActionButtonContainer } from 'components/common/ActionButtonContainer'; import StyledDialog from 'components/common/StyledDialog'; import UserManagementTable from 'components/common/UserManagementTable'; import { BasicUserDetails } from 'generated/sdk'; +import { useProposalData } from 'hooks/proposal/useProposalData'; import { useDataAccessUsersData } from 'hooks/remoteUser/useDataAccessUsersData'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; type DataAccessUsersModalProps = { open: boolean; @@ -21,7 +23,9 @@ const DataAccessUsersModal = ({ }: DataAccessUsersModalProps) => { const { dataAccessUsers, loadingDataAccessUsers } = useDataAccessUsersData(proposalPk); + const { proposalData } = useProposalData(proposalPk); const [managedUsers, setManagedUsers] = useState([]); + const { api, isExecutingCall } = useDataApiWithFeedback(); // Update managed users when data access users data changes React.useEffect(() => { @@ -30,6 +34,39 @@ const DataAccessUsersModal = ({ } }, [dataAccessUsers, loadingDataAccessUsers]); + // Calculate excludeUserIds from proposal data (proposer + co-proposers) + const excludeUserIds = React.useMemo(() => { + if (!proposalData) { + return []; + } + + const proposerId = proposalData.proposer?.id; + const coProposerIds = proposalData.users?.map((user) => user.id) || []; + + return proposerId ? [proposerId, ...coProposerIds] : coProposerIds; + }, [proposalData]); + + const handleUpdateDataAccessUsers = async () => { + if (!proposalPk) { + return; + } + + const userIds = managedUsers.map((user) => user.id); + + try { + await api({ + toastSuccessMessage: 'Data access users updated successfully!', + }).updateDataAccessUsers({ + proposalPk, + userIds, + }); + onClose(); + } catch (error) { + // Error handling is done by useDataApiWithFeedback + console.error('Failed to update data access users:', error); + } + }; + return ( {}} title="Data access users" addButtonLabel="Add Data Access User" + excludeUserIds={excludeUserIds} /> + + )} + + + ); +}; + +export default NotifyProposal; diff --git a/apps/frontend/src/components/proposal/InviteUser.tsx b/apps/frontend/src/components/proposal/ParticipantSelector.tsx similarity index 79% rename from apps/frontend/src/components/proposal/InviteUser.tsx rename to apps/frontend/src/components/proposal/ParticipantSelector.tsx index 9ae2125682..d19dbc0587 100644 --- a/apps/frontend/src/components/proposal/InviteUser.tsx +++ b/apps/frontend/src/components/proposal/ParticipantSelector.tsx @@ -31,8 +31,15 @@ import NoOptionsText from './NoOptionsText'; type UserOrEmail = BasicUserDetails | ValidEmailAddress; -interface InviteUserProps { +const keyOf = (u: UserOrEmail) => + typeof u === 'string' ? `email:${u.toLowerCase()}` : `id:${String(u.id)}`; + +const isSameParticipants = (a: UserOrEmail[], b: UserOrEmail[]): boolean => + a.length === b.length && a.every((v, i) => keyOf(v) === keyOf(b[i])); + +interface ParticipantSelectorProps { modalOpen: boolean; + title?: string; onClose?: () => void; onAddParticipants?: (data: { users: BasicUserDetails[]; @@ -40,6 +47,9 @@ interface InviteUserProps { }) => void; excludeUserIds?: number[]; excludeEmails?: string[]; + preset?: UserOrEmail[]; + multiple?: boolean; + allowInviteByEmail?: boolean; } const categorizeSelectedItems = (items: UserOrEmail[]) => ({ @@ -53,14 +63,19 @@ const categorizeSelectedItems = (items: UserOrEmail[]) => ({ const MIN_SEARCH_LENGTH = 3; -function InviteUser({ +function ParticipantSelector({ modalOpen, + title, onClose, onAddParticipants, excludeUserIds, excludeEmails, + allowInviteByEmail = false, confirm, -}: InviteUserProps & WithConfirmProps) { + preset = [], + multiple = true, +}: ParticipantSelectorProps & WithConfirmProps) { + // multiple selection enabled by default const api = useDataApi(); const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); @@ -68,7 +83,7 @@ function InviteUser({ const [exactEmailMatch, setExactEmailMatch] = useState< BasicUserDetails | undefined >(); - const [selectedItems, setSelectedItems] = useState([]); + const [selectedItems, setSelectedItems] = useState(preset); const isPendingSearch = useRef(false); const featureContext = useContext(FeatureContext); const isEmailSearchOnly = !!featureContext.featuresMap.get( @@ -124,9 +139,7 @@ function InviteUser({ .includes(basicUserDetailsByEmail.id); if (userAlreadyExists === false) { - setExactEmailMatch( - basicUserDetailsByEmail ? basicUserDetailsByEmail : undefined - ); + setExactEmailMatch(basicUserDetailsByEmail || undefined); } } else { if (isEmailSearchOnly) { @@ -166,7 +179,7 @@ function InviteUser({ }, [query, fetchUserSearchResults]); const addToSelectedItems = (user: UserOrEmail) => { - setSelectedItems((prev) => [...prev, user]); + setSelectedItems((prev) => (multiple ? [...prev, user] : [user])); setExactEmailMatch(undefined); setOptions([]); @@ -217,7 +230,7 @@ function InviteUser({ }; const handleClose = () => { - if (selectedItems.length > 0) { + if (isSameParticipants(selectedItems, preset || []) === false) { confirm( async () => { onClose?.(); @@ -227,7 +240,7 @@ function InviteUser({ { title: 'Please confirm', description: - 'User(s) have not yet been added to the proposal. Are you sure you want to close the dialog?', + 'Are you sure you want to close the dialog? Your changes will be lost.', } )(); @@ -240,6 +253,13 @@ function InviteUser({ }; const handleKeyDown = (event: React.KeyboardEvent) => { + if (!multiple && selectedItems.length === 1) { + // Prevent further input when single selection is made + event.preventDefault(); + + return; + } + if (event.key === 'Enter' || event.key === ' ' || event.key === ',') { event.preventDefault(); @@ -249,12 +269,11 @@ function InviteUser({ if (exactEmailMatch) { addToSelectedItems(exactEmailMatch); - } else { - if (options.length === 1) { - addToSelectedItems(options[0]); - } else if (isValidEmail(query) && !isEmailSearchOnly) { - addValidEmailToSelection(query); - } + setExactEmailMatch(undefined); + } else if (options.length === 1) { + addToSelectedItems(options[0]); + } else if (isValidEmail(query) && !isEmailSearchOnly) { + addValidEmailToSelection(query); } } }; @@ -275,7 +294,7 @@ function InviteUser({ fullWidth maxWidth="md" onClose={handleClose} - title="Add co-proposers to proposal" + title={title || (multiple ? 'Add Participant(s)' : 'Select Participant')} tooltip={ isEmailSearchOnly && ( setSelectedItems(newValue)} + value={ + multiple + ? selectedItems + : selectedItems.length + ? selectedItems[0] + : undefined + } + onChange={(_, newValue) => + setSelectedItems( + newValue + ? multiple + ? (newValue as UserOrEmail[]) + : [newValue as UserOrEmail] + : [] + ) + } filterSelectedOptions onInputChange={(_, newValue) => setQuery(newValue)} onKeyDown={handleKeyDown} @@ -342,10 +375,16 @@ function InviteUser({ !isLoading ? ( addValidEmailToSelection(email)} + onAddEmail={(email) => + allowInviteByEmail + ? addValidEmailToSelection(email) + : undefined + } exactEmailMatch={exactEmailMatch} onAddUser={(user) => { - addToSelectedItems(user); + multiple + ? addToSelectedItems(user) + : setSelectedItems([user]); }} excludeEmails={ excludeEmails?.concat( @@ -353,6 +392,7 @@ function InviteUser({ ) || [] } isEmailSearchOnly={isEmailSearchOnly} + allowInviteByEmail={allowInviteByEmail} /> ) : null } @@ -363,14 +403,17 @@ function InviteUser({ onClick={handleSubmit} sx={{ margin: '16px 0 8px 0' }} startIcon={} - disabled={!selectedItems.length} + disabled={ + !selectedItems.length || + isSameParticipants(selectedItems, preset || []) + } data-cy="invite-user-submit-button" > - Add + {multiple ? 'Add' : 'Select'} ); } -export default withConfirm(InviteUser); +export default withConfirm(ParticipantSelector); diff --git a/apps/frontend/src/components/proposal/ProposalParticipants.tsx b/apps/frontend/src/components/proposal/Participants.tsx similarity index 74% rename from apps/frontend/src/components/proposal/ProposalParticipants.tsx rename to apps/frontend/src/components/proposal/Participants.tsx index ea2e6657c7..c472d7fc64 100644 --- a/apps/frontend/src/components/proposal/ProposalParticipants.tsx +++ b/apps/frontend/src/components/proposal/Participants.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useContext } from 'react'; import UserManagementTable, { UserManagementTableProps, } from 'components/common/UserManagementTable'; -import { BasicUserDetails } from 'generated/sdk'; +import { FeatureContext } from 'context/FeatureContextProvider'; +import { BasicUserDetails, FeatureId } from 'generated/sdk'; import { BasicUserData } from 'hooks/user/useUserData'; type ProposalParticipantsProps = Omit< @@ -27,12 +28,18 @@ const ProposalParticipants = ({ } }; + const featureContext = useContext(FeatureContext); + const allowInviteByEmail = !!featureContext.featuresMap.get( + FeatureId.EMAIL_INVITE + )?.isEnabled; + return ( ); }; diff --git a/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx new file mode 100644 index 0000000000..8a6922f76e --- /dev/null +++ b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx @@ -0,0 +1,90 @@ +import EditIcon from '@mui/icons-material/Edit'; +import Box from '@mui/material/Box'; +import FormControl from '@mui/material/FormControl'; +import IconButton from '@mui/material/IconButton'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import React, { useContext, useState } from 'react'; + +import { + QuestionaryContext, + createMissingContextErrorMessage, +} from 'components/questionary/QuestionaryContext'; +import { BasicUserDetails } from 'generated/sdk'; +import { getFullUserNameWithInstitution } from 'utils/user'; + +import ParticipantSelector from './ParticipantSelector'; +import { ProposalContextType } from './ProposalContainer'; + +interface PrincipalInvestigatorProps { + setPrincipalInvestigator: (user: BasicUserDetails) => void; + disabled?: boolean; +} + +export default function PrincipalInvestigator( + props: PrincipalInvestigatorProps +) { + const [isPickerOpen, setIsPickerOpen] = useState(false); + + const { state } = useContext(QuestionaryContext) as ProposalContextType; + if (!state) { + throw new Error(createMissingContextErrorMessage()); + } + const { proposer, users } = state.proposal; + + return ( + + {isPickerOpen && ( + setIsPickerOpen(false)} + onAddParticipants={(participants) => { + props.setPrincipalInvestigator(participants.users[0]); + setIsPickerOpen(false); + }} + excludeUserIds={[users.map((u) => u.id)].flat()} + preset={proposer ? [proposer] : []} + multiple={false} + allowInviteByEmail={false} + /> + )} + + + + + + + setIsPickerOpen(true)} + sx={(theme) => ({ + padding: '5px', + marginLeft: theme.spacing(1), + })} + disabled={props.disabled} + > + + + + + + + ); +} diff --git a/apps/frontend/src/components/proposal/ProposalContainer.tsx b/apps/frontend/src/components/proposal/ProposalContainer.tsx index 25b3eb706e..d20f77caaa 100644 --- a/apps/frontend/src/components/proposal/ProposalContainer.tsx +++ b/apps/frontend/src/components/proposal/ProposalContainer.tsx @@ -129,6 +129,14 @@ export default function ProposalContainer(props: ProposalContainerProps) { } draftState.isDirty = true; break; + + case 'ITEM_WITH_QUESTIONARY_SUBMITTED': + draftState.proposal = { + ...draftState.proposal, + ...action.itemWithQuestionary, + }; + + break; } return draftState; diff --git a/apps/frontend/src/components/proposal/ProposalParticipant.tsx b/apps/frontend/src/components/proposal/ProposalParticipantLegacy.tsx similarity index 97% rename from apps/frontend/src/components/proposal/ProposalParticipant.tsx rename to apps/frontend/src/components/proposal/ProposalParticipantLegacy.tsx index 206a0169ed..7cb8832af1 100644 --- a/apps/frontend/src/components/proposal/ProposalParticipant.tsx +++ b/apps/frontend/src/components/proposal/ProposalParticipantLegacy.tsx @@ -13,7 +13,7 @@ import { getFullUserNameWithInstitution } from 'utils/user'; import ParticipantModal from './ParticipantModal'; -export default function ProposalParticipant(props: { +export default function ProposalParticipantLegacy(props: { principalInvestigator: BasicUserData | null | undefined; setPrincipalInvestigator: (user: BasicUserDetails) => void; sx?: SxProps; diff --git a/apps/frontend/src/components/proposal/ProposalSummary.tsx b/apps/frontend/src/components/proposal/ProposalSummary.tsx index 41523baeb3..1213f67e9d 100644 --- a/apps/frontend/src/components/proposal/ProposalSummary.tsx +++ b/apps/frontend/src/components/proposal/ProposalSummary.tsx @@ -35,6 +35,7 @@ function ProposalReview({ confirm }: ProposalSummaryProps) { const { api } = useDataApiWithFeedback(); const isUserOfficer = useCheckAccess([UserRole.USER_OFFICER]); + const isUser = useCheckAccess([UserRole.USER]); const { isInternalUser } = useContext(UserContext); const callHasEnded = isCallEnded( state.proposal.call?.startCall, @@ -94,7 +95,7 @@ function ProposalReview({ confirm }: ProposalSummaryProps) { const { call } = await api().getCallSubmissionDetails({ callId: proposal.callId, }); - const connections = call?.proposalWorkflow?.workflowConnectionGroups; + const connections = call?.proposalWorkflow?.workflowConnections; const currentStatusId = proposal.status?.id; @@ -105,14 +106,12 @@ function ProposalReview({ confirm }: ProposalSummaryProps) { ]; const hasUpcomingEditableStatus = connections && - connections.some((group) => - group.connections.find( - (conn) => - conn.prevStatusId && - currentStatusId && - conn.prevStatusId === currentStatusId && - editableStatusesShortCodes?.includes(conn.status.shortCode) - ) + connections.some( + (conn) => + conn.prevStatusId && + currentStatusId && + conn.prevStatusId === currentStatusId && + editableStatusesShortCodes?.includes(conn.status.shortCode) ); if (proposal.status != null && hasUpcomingEditableStatus) { @@ -165,13 +164,13 @@ function ProposalReview({ confirm }: ProposalSummaryProps) { proposalPk: state.proposal.primaryKey, }); - dispatch({ - type: 'ITEM_WITH_QUESTIONARY_MODIFIED', - itemWithQuestionary: submitProposal, - }); + const { proposal } = await api().getProposal({ + primaryKey: submitProposal.primaryKey, + }); // refetching proposal after event handling is done in backend + dispatch({ type: 'ITEM_WITH_QUESTIONARY_SUBMITTED', - itemWithQuestionary: submitProposal, + itemWithQuestionary: proposal!, }); } finally { setSubmitDisabled(true); @@ -194,7 +193,9 @@ function ProposalReview({ confirm }: ProposalSummaryProps) { onClick={() => downloadPDFProposal([proposal.primaryKey], proposal.title) } - disabled={!allStepsComplete || isSubmitting} + disabled={ + !allStepsComplete || isSubmitting || (!proposal.submitted && isUser) + } color="secondary" > Download PDF diff --git a/apps/frontend/src/components/proposal/ProposalTable.tsx b/apps/frontend/src/components/proposal/ProposalTable.tsx index 5c3b8938e3..c1b9de5e06 100644 --- a/apps/frontend/src/components/proposal/ProposalTable.tsx +++ b/apps/frontend/src/components/proposal/ProposalTable.tsx @@ -271,11 +271,14 @@ const ProposalTable = ({ }, }, (rowData) => { + const isPI = rowData.proposerId === userContext.user.id; + return { icon: PeopleIcon, tooltip: 'View data access users', hidden: - isDataAccessUsersEnabled !== true || + isDataAccessUsersEnabled === false || + isPI === false || rowData.publicStatus !== ProposalPublicStatus.ACCEPTED, onClick: (_event, rowData) => { setSelectedProposalPk( @@ -285,14 +288,22 @@ const ProposalTable = ({ }, }; }, - { - icon: GetAppIcon, - tooltip: 'Download proposal', - onClick: (event, rowData) => - downloadPDFProposal( - [(rowData as PartialProposalsDataType).primaryKey], - (rowData as PartialProposalsDataType).title - ), + (rowData) => { + const isSubmitted = rowData.submitted; + + return { + icon: GetAppIcon, + tooltip: !isSubmitted + ? 'Only submitted proposals can be downloaded' + : 'Download Proposal', + disabled: !isSubmitted, + + onClick: (event, rowData) => + downloadPDFProposal( + [(rowData as PartialProposalsDataType).primaryKey], + (rowData as PartialProposalsDataType).title + ), + }; }, (rowData) => { const isPI = rowData.proposerId === userContext.user.id; diff --git a/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx b/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx index dd1d01d175..7779c17dbc 100644 --- a/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx +++ b/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx @@ -48,12 +48,12 @@ import TechnicalBulkReassignModal, { import { FeatureContext } from 'context/FeatureContextProvider'; import { Call, - ProposalsFilter, - InstrumentMinimalFragment, - FeatureId, - FapInstrumentInput, FapInstrument, + FapInstrumentInput, + FeatureId, + InstrumentMinimalFragment, ProposalViewInstrument, + ProposalsFilter, Status, } from 'generated/sdk'; import { useLocalStorage } from 'hooks/common/useLocalStorage'; @@ -75,6 +75,7 @@ import withConfirm, { WithConfirmType } from 'utils/withConfirm'; import CallSelectModalOnProposalsClone from './CallSelectModalOnProposalClone'; import ChangeProposalStatus from './ChangeProposalStatus'; +import NotifyProposal from './NotifyProposal'; import ProposalAttachmentDownload from './ProposalAttachmentDownload'; import TableActionsDropdownMenu, { DownloadMenuOption, @@ -332,6 +333,7 @@ const ProposalTableOfficer = ({ useState(false); const [openChangeProposalStatus, setOpenChangeProposalStatus] = useState(false); + const [openNotifiyProposal, setOpenNotifyProposal] = useState(false); const [tableData, setTableData] = useState([]); const [totalCount, setTotalCount] = useState(0); const [openCallSelection, setOpenCallSelection] = useState(false); @@ -477,12 +479,13 @@ const ProposalTableOfficer = ({ }; // TODO: Maybe it will be good to make notifyProposal and deleteProposal bulk functions where we can sent array of proposal ids. - const emailProposals = (): void => { + const emailProposals = (ignoreNotifiedFlag: boolean): void => { getSelectedProposalPks().forEach(async (proposalPk) => { await api({ toastSuccessMessage: 'Notification sent successfully', }).notifyProposal({ proposalPk, + ignoreNotifiedFlag, }); refreshTableData(); @@ -833,16 +836,7 @@ const ProposalTableOfficer = ({ icon: EmailIcon, tooltip: 'Notify users final result', onClick: () => { - confirm( - () => { - emailProposals(); - }, - { - title: 'Notify results', - description: - 'This action will trigger emails to be sent to principal investigators.', - } - )(); + setOpenNotifyProposal(true); }, position: 'toolbarOnSelect', }, @@ -975,6 +969,21 @@ const ProposalTableOfficer = ({ /> + setOpenNotifyProposal(false)} + fullWidth + > + + setOpenNotifyProposal(false)} + ignoreNotifiedFlag={emailProposals} + /> + + ( + event.visit?.registrationInvites || [] + ); - const visit = event.visit; + const { visit } = event; const initialValues = { team: visit?.registrations.map((registration) => registration.user!) || [], teamLeadUserId: visit?.teamLead.id || null, + inviteEmails: visit?.registrationInvites || [], }; + const featureContext = useContext(FeatureContext); + const allowInviteByEmail = !!featureContext.featuresMap.get( + FeatureId.EMAIL_INVITE + )?.isEnabled; + return ( => { if (visit) { @@ -53,6 +64,7 @@ function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { visitId: visit.id, team: values.team.map((user) => user.id), teamLeadUserId: values.teamLeadUserId, + inviteEmails: visitInvites.map((invite) => invite.email), }) .then(({ updateVisit }) => { if (updateVisit) { @@ -81,12 +93,13 @@ function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { {}} - invites={[]} + setInvites={setVisitInvites} + invites={visitInvites} setUsers={(team: BasicUserDetails[]) => { setFieldValue('team', team); }} users={values.team || []} + allowInviteByEmail={allowInviteByEmail} /> diff --git a/apps/frontend/src/components/questionary/NavigationFragment.tsx b/apps/frontend/src/components/questionary/NavigationFragment.tsx index 1659312e38..85644487a8 100644 --- a/apps/frontend/src/components/questionary/NavigationFragment.tsx +++ b/apps/frontend/src/components/questionary/NavigationFragment.tsx @@ -19,6 +19,7 @@ const NavigationFragment = (props: { marginTop={3} spacing={1} alignItems="center" + data-cy="navigation-fragment" > {props.isLoading && } <>{props.children} diff --git a/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionFapReviewBasisForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionFapReviewBasisForm.tsx index e6f6387dcf..0f07cf187e 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionFapReviewBasisForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionFapReviewBasisForm.tsx @@ -2,12 +2,16 @@ import { Field } from 'formik'; import React from 'react'; import * as Yup from 'yup'; +import FormikUICustomTable from 'components/common/FormikUICustomTable'; import TextField from 'components/common/FormikUITextField'; +import TitledContainer from 'components/common/TitledContainer'; import { QuestionFormProps } from 'components/questionary/QuestionaryComponentRegistry'; import { useNaturalKeySchema } from 'utils/userFieldValidationSchema'; import { QuestionFormShell } from '../QuestionFormShell'; +const columns = [{ title: 'Answer', field: 'answer' }]; + export const QuestionFapReviewBasisForm = (props: QuestionFormProps) => { const field = props.question; const naturalKeySchema = useNaturalKeySchema(field.naturalKey); @@ -42,6 +46,53 @@ export const QuestionFapReviewBasisForm = (props: QuestionFormProps) => { fullWidth inputProps={{ 'data-cy': 'question' }} /> + + + + + { + return options.map((option) => { + return { answer: option }; + }); + }, + fromTable: (rows: Record[]) => { + return rows.map((row) => row.answer); + }, + }} + fullWidth + data-cy="options" + /> + )} diff --git a/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionTemplateRelationFapReviewBasisForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionTemplateRelationFapReviewBasisForm.tsx index 3afcc89169..e9d585b3dc 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionTemplateRelationFapReviewBasisForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionTemplateRelationFapReviewBasisForm.tsx @@ -1,18 +1,81 @@ +import { Field } from 'formik'; import React from 'react'; +import * as Yup from 'yup'; +import FormikUICustomTable from 'components/common/FormikUICustomTable'; +import TextField from 'components/common/FormikUITextField'; +import TitledContainer from 'components/common/TitledContainer'; import { QuestionTemplateRelationFormProps } from 'components/questionary/QuestionaryComponentRegistry'; import { QuestionExcerpt } from '../QuestionExcerpt'; import { QuestionTemplateRelationFormShell } from '../QuestionTemplateRelationFormShell'; +const columns = [{ title: 'Answer', field: 'answer' }]; + export const QuestionTemplateRelationFapReviewBasisForm = ( props: QuestionTemplateRelationFormProps ) => { return ( - + {() => ( <> + + + + + { + return options.map((option) => { + return { answer: option }; + }); + }, + fromTable: (rows: Record[]) => { + return rows.map((row) => row.answer); + }, + }} + fullWidth + data-cy="options" + /> + )} diff --git a/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionaryComponentFapReviewBasis.tsx b/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionaryComponentFapReviewBasis.tsx index 9308f04d8e..d8be124006 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionaryComponentFapReviewBasis.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/QuestionaryComponentFapReviewBasis.tsx @@ -3,6 +3,8 @@ import { CssBaseline, FormHelperText, InputLabel, + ToggleButton, + ToggleButtonGroup, useTheme, } from '@mui/material'; import { ErrorMessage, Field } from 'formik'; @@ -12,14 +14,14 @@ import { Editor as TinyMCEEditor } from 'tinymce'; import Select from 'components/common/FormikUISelect'; import TextField from 'components/common/FormikUITextField'; import Editor from 'components/common/TinyEditor'; +import TitledContainer from 'components/common/TitledContainer'; import { BasicComponentProps } from 'components/proposal/IBasicComponentProps'; import { createMissingContextErrorMessage, QuestionaryContext, } from 'components/questionary/QuestionaryContext'; import { ReviewContextType } from 'components/review/ReviewQuestionary'; -import { SettingsContext } from 'context/SettingsContextProvider'; -import { SettingsId } from 'generated/sdk'; +import { FapReviewBasisConfig } from 'generated/sdk'; import { SubmitActionDependencyContainer } from 'hooks/questionary/useSubmitActions'; import { FapReviewSubmissionState } from 'models/questionary/fapReview/FapReviewSubmissionState'; @@ -30,6 +32,8 @@ function QuestionaryComponentFapReviewBasis(props: BasicComponentProps) { }, } = props; + const config = props.answer.config as FapReviewBasisConfig; + const theme = useTheme(); const { state, dispatch } = useContext( QuestionaryContext @@ -44,6 +48,13 @@ function QuestionaryComponentFapReviewBasis(props: BasicComponentProps) { state?.fapReview.grade || undefined ); + const [gradeType, setGradeType] = useState<'Number' | 'Classification'>( + config.nonNumericOptions.length > 0 && + config.nonNumericOptions.find((v) => v === state?.fapReview.grade) + ? 'Classification' + : 'Number' + ); + useEffect(() => { setLocalGrade(state?.fapReview.grade || undefined); }, [state]); @@ -52,12 +63,6 @@ function QuestionaryComponentFapReviewBasis(props: BasicComponentProps) { throw new Error(createMissingContextErrorMessage()); } - const { settingsMap } = useContext(SettingsContext); - - const gradeDecimalPoints = parseFloat( - settingsMap.get(SettingsId.GRADE_PRECISION)?.settingsValue?.valueOf() ?? '1' - ); - const handleCharacterCount = (editor: TinyMCEEditor) => { const wordCount = editor.plugins.wordcount; setNumberOfChars(wordCount.body.getCharacterCount()); @@ -97,61 +102,95 @@ function QuestionaryComponentFapReviewBasis(props: BasicComponentProps) { } setLocalComment(content); }} - onBlur={() => - dispatch({ + onBlur={() => { + return dispatch({ type: 'ITEM_WITH_QUESTIONARY_MODIFIED', itemWithQuestionary: { comment: localComment }, - }) - } + }); + }} /> Characters: {numberOfChars} / {6000} - - ) => { - dispatch({ - type: 'ITEM_WITH_QUESTIONARY_MODIFIED', - itemWithQuestionary: { grade: +event.target.value }, - }); - }} - formControl={{ - fullWidth: true, - required: true, - margin: 'normal', - }} - inputProps={ - gradeDecimalPoints === 1 - ? { - id: 'grade-proposal', - } - : { - id: 'grade-proposal', - step: gradeDecimalPoints, - inputMode: 'decimal', - type: 'number', - min: '1', - max: '10', - } - } - data-cy="grade-proposal" - labelId="grade-proposal-label" - options={ - gradeDecimalPoints === 1 - ? [...Array(10)].map((e, i) => ({ - text: (i + 1).toString(), - value: i + 1, - })) - : undefined - } - /> - + + {config.nonNumericOptions.length > 0 && ( + { + setGradeType(v); + setLocalGrade(undefined); + dispatch({ + type: 'ITEM_WITH_QUESTIONARY_MODIFIED', + itemWithQuestionary: { grade: undefined }, + }); + }} + title="Select grade type" + size="small" + > + Number + Classification + + )} + + + ) => { + dispatch({ + type: 'ITEM_WITH_QUESTIONARY_MODIFIED', + itemWithQuestionary: { grade: event.target.value }, + }); + }} + formControl={{ + fullWidth: true, + required: true, + margin: 'normal', + }} + inputProps={ + gradeType === 'Classification' || config.decimalPoints === 0 + ? { + id: 'grade-proposal', + } + : { + id: 'grade-proposal', + step: Math.pow(10, -config.decimalPoints).toString(), + inputMode: 'decimal', + type: 'number', + min: '1', + max: '10', + } + } + data-cy="grade-proposal" + labelId="grade-proposal-label" + options={ + gradeType === 'Classification' + ? config.nonNumericOptions.map((option) => ({ + text: option, + value: option, + })) + : config.decimalPoints === 0 + ? [...Array(10)].map((e, i) => ({ + text: (i + 1).toString(), + value: (i + 1).toString(), + })) + : undefined + } + /> + +
); @@ -171,7 +210,7 @@ const fapReviewBasisPreSubmit = status: status, fapID: fapID, questionaryID: questionaryID, - grade: grade || 0, + grade: grade || '0', comment: comment || '', }); } diff --git a/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/createFapReviewBasisValidationSchema.ts b/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/createFapReviewBasisValidationSchema.ts index 7ce229a308..3d04eb587b 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/createFapReviewBasisValidationSchema.ts +++ b/apps/frontend/src/components/questionary/questionaryComponents/FapReviewBasis/createFapReviewBasisValidationSchema.ts @@ -2,11 +2,14 @@ import { sanitizeHtmlAndCleanText } from '@user-office-software/duo-validation/l import * as Yup from 'yup'; import { QuestionaryComponentDefinition } from 'components/questionary/QuestionaryComponentRegistry'; +import { Answer, FapReviewBasisConfig } from 'generated/sdk'; export const createFapReviewBasisValidationSchema: QuestionaryComponentDefinition['createYupValidationSchema'] = - () => { + (answer: Answer) => { const FAP_REVIEW_COMMENT_CHAR_LIMIT = 6000; + const config = answer.config as FapReviewBasisConfig; + let commentSchema = Yup.string().transform(function (value: string) { return sanitizeHtmlAndCleanText(value); }); @@ -20,9 +23,41 @@ export const createFapReviewBasisValidationSchema: QuestionaryComponentDefinitio const schema = Yup.object().shape({ comment: commentSchema, - grade: Yup.number() - .min(1, 'Lowest grade is 1') - .max(10, 'Highest grade is 10') + grade: Yup.string() + .test('min', `Lowest grade is ${config.minGrade}`, (val) => { + if (val === undefined) return false; + + const number = parseFloat(val); + + return isNaN(number) ? true : number >= config.minGrade; + }) + .test('max', `Lowest grade is ${config.maxGrade}`, (val) => { + if (val === undefined) return false; + + const number = parseFloat(val); + + return isNaN(number) ? true : number <= config.maxGrade; + }) + .test( + 'decimatePoint', + `The grade must be a most ${config.decimalPoints} dp`, + (val) => { + if (val === undefined) return false; + + const number = parseFloat(val); + + const decimalPlaces = (val.split('.')[1] || '').length; + + return isNaN(number) ? true : decimalPlaces <= config.decimalPoints; + } + ) + .test('nonNumericOptions', 'Invalid option', (val) => { + if (val === undefined) return false; + + const number = parseFloat(val); + + return isNaN(number) ? config.nonNumericOptions.includes(val) : true; + }) .required('Grade is required'), }); diff --git a/apps/frontend/src/components/questionary/questionaryComponents/Interval/IntervalDefinition.tsx b/apps/frontend/src/components/questionary/questionaryComponents/Interval/IntervalDefinition.tsx index 8ce73ce2a4..41ac1505a7 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/Interval/IntervalDefinition.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/Interval/IntervalDefinition.tsx @@ -3,7 +3,7 @@ import { intervalQuestionValidationSchema } from '@user-office-software/duo-vali import React from 'react'; import defaultRenderer from 'components/questionary/DefaultQuestionRenderer'; -import { DataType, IntervalConfig } from 'generated/sdk'; +import { DataType, IntervalConfig, NumberValueConstraint } from 'generated/sdk'; import { IntervalAnswerRenderer } from './IntervalAnswerRenderer'; import IntervalSearchCriteriaComponent from './IntervalSearchCriteriaComponent'; @@ -25,7 +25,8 @@ export const intervalDefinition: QuestionaryComponentDefinition = { answerRenderer: IntervalAnswerRenderer, questionRenderer: defaultRenderer.questionRenderer, }, - createYupValidationSchema: intervalQuestionValidationSchema, + createYupValidationSchema: (field) => + intervalQuestionValidationSchema(field, NumberValueConstraint), getYupInitialValue: ({ answer }) => answer.value || { min: '', diff --git a/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionIntervalForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionIntervalForm.tsx index fd63bafdbc..773433b58c 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionIntervalForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionIntervalForm.tsx @@ -4,12 +4,13 @@ import { Field } from 'formik'; import React, { useState } from 'react'; import * as Yup from 'yup'; +import FormikUIAutocomplete from 'components/common/FormikUIAutocomplete'; import CheckboxWithLabel from 'components/common/FormikUICheckboxWithLabel'; import TextField from 'components/common/FormikUITextField'; import TitledContainer from 'components/common/TitledContainer'; import { QuestionFormProps } from 'components/questionary/QuestionaryComponentRegistry'; import { QuestionFormShell } from 'components/questionary/questionaryComponents/QuestionFormShell'; -import { IntervalConfig } from 'generated/sdk'; +import { IntervalConfig, NumberValueConstraint } from 'generated/sdk'; import { useUnitsData } from 'hooks/settings/useUnitData'; import { useNaturalKeySchema } from 'utils/userFieldValidationSchema'; @@ -96,6 +97,33 @@ export const QuestionIntervalForm = (props: QuestionFormProps) => { value={selectedUnits ?? undefined} data-cy="units" /> + + )} diff --git a/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionTemplateRelationIntervalForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionTemplateRelationIntervalForm.tsx index 3e007cb2a3..1fe9a1006d 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionTemplateRelationIntervalForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionTemplateRelationIntervalForm.tsx @@ -1,7 +1,7 @@ /* eslint-disable import/order */ import Autocomplete from '@mui/material/Autocomplete'; import MUITextField from '@mui/material/TextField'; -import { Field } from 'formik'; +import { Field, getIn } from 'formik'; import React, { useState } from 'react'; import * as Yup from 'yup'; @@ -10,11 +10,12 @@ import TextField from 'components/common/FormikUITextField'; import TitledContainer from 'components/common/TitledContainer'; import { QuestionTemplateRelationFormProps } from 'components/questionary/QuestionaryComponentRegistry'; import { QuestionExcerpt } from 'components/questionary/questionaryComponents/QuestionExcerpt'; -import { IntervalConfig } from 'generated/sdk'; +import { IntervalConfig, NumberValueConstraint } from 'generated/sdk'; import { useUnitsData } from 'hooks/settings/useUnitData'; import QuestionDependencyList from '../QuestionDependencyList'; import { QuestionTemplateRelationFormShell } from '../QuestionTemplateRelationFormShell'; +import FormikUIAutocomplete from 'components/common/FormikUIAutocomplete'; export const QuestionTemplateRelationIntervalForm = ( props: QuestionTemplateRelationFormProps @@ -42,55 +43,91 @@ export const QuestionTemplateRelationIntervalForm = ( }), })} > - {(formikProps) => ( - <> - - - + {(formikProps) => { + if (!getIn(formikProps.values, 'config.numberValueConstraint')) { + formikProps.setFieldValue( + 'config.numberValueConstraint', + NumberValueConstraint.NONE + ); + } + + return ( + <> + + + - - `${symbol} (${unit}) - ${quantity}` - } - renderInput={(params) => ( - - )} - onChange={(_event, newValue) => { - setSelectedUnits(newValue); - formikProps.setFieldValue('config.units', newValue); - }} - value={selectedUnits ?? undefined} - data-cy="units" - /> - - - - - - )} + + `${symbol} (${unit}) - ${quantity}` + } + renderInput={(params) => ( + + )} + onChange={(_event, newValue) => { + setSelectedUnits(newValue); + formikProps.setFieldValue('config.units', newValue); + }} + value={selectedUnits ?? undefined} + data-cy="units" + /> + + + + + + + + ); + }} ); }; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionaryComponentInterval.tsx b/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionaryComponentInterval.tsx index 68f7f667b0..ecde338520 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionaryComponentInterval.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/Interval/QuestionaryComponentInterval.tsx @@ -24,7 +24,7 @@ export function QuestionaryComponentInterval(props: BasicComponentProps) { formikProps: { errors, touched }, } = props; const { - question: { id, question }, + question: { id, question, naturalKey }, } = answer; const theme = useTheme(); const config = answer.config as IntervalConfig; @@ -154,6 +154,7 @@ export function QuestionaryComponentInterval(props: BasicComponentProps) { onBlur={() => onComplete(stateValue)} value={stateValue.min} data-cy={minFieldId} + data-natural-key={`${naturalKey}-Min`} type="number" name={minFieldId} margin="dense" @@ -198,6 +199,7 @@ export function QuestionaryComponentInterval(props: BasicComponentProps) { onBlur={() => onComplete(stateValue)} value={stateValue.max} data-cy={maxFieldId} + data-natural-key={`${naturalKey}-Max`} type="number" name={maxFieldId} margin="dense" diff --git a/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx b/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx index d45ad2cc98..1d6e98c378 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx @@ -7,15 +7,17 @@ import React, { ChangeEvent, useContext, useEffect, useState } from 'react'; import ErrorMessage from 'components/common/ErrorMessage'; import TextField from 'components/common/FormikUITextField'; import withPreventSubmit from 'components/common/withPreventSubmit'; +import CoProposers from 'components/proposal/CoProposers'; import { BasicComponentProps } from 'components/proposal/IBasicComponentProps'; +import PrincipalInvestigator from 'components/proposal/PrincipalInvestigator'; import { ProposalContextType } from 'components/proposal/ProposalContainer'; -import ProposalParticipant from 'components/proposal/ProposalParticipant'; -import Participants from 'components/proposal/ProposalParticipants'; +import ProposalParticipantLegacy from 'components/proposal/ProposalParticipantLegacy'; import { createMissingContextErrorMessage, QuestionaryContext, } from 'components/questionary/QuestionaryContext'; -import { BasicUserDetails, Invite } from 'generated/sdk'; +import { FeatureContext } from 'context/FeatureContextProvider'; +import { BasicUserDetails, FeatureId, Invite } from 'generated/sdk'; import { SubmitActionDependencyContainer } from 'hooks/questionary/useSubmitActions'; import { useBasicUserData } from 'hooks/user/useUserData'; import { ProposalSubmissionState } from 'models/questionary/proposal/ProposalSubmissionState'; @@ -38,14 +40,17 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { const [localTitle, setLocalTitle] = useState(state?.proposal.title); const [localAbstract, setLocalAbstract] = useState(state?.proposal.abstract); const [hasInvalidChars, setHasInvalidChars] = useState(false); - const [textLen, setTextLen] = useState( - state?.proposal.abstract ? (state?.proposal.abstract as string).length : 0 - ); + const [textLen, setTextLen] = useState(state?.proposal.abstract ?? 0); + const { featuresMap } = useContext(FeatureContext); + const isLegacyInviteFlow = featuresMap.get( + FeatureId.EMAIL_INVITE_LEGACY + )?.isEnabled; + if (!state || !dispatch) { throw new Error(createMissingContextErrorMessage()); } - const { proposer, users, coProposerInvites } = state.proposal; + const { proposer, users } = state.proposal; const { loading, userData } = useBasicUserData(state?.proposal.proposer?.id); const [piData, setPIData] = useState(null); @@ -55,7 +60,7 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { } }, [userData]); - const coInvestigatorChanged = (users: BasicUserDetails[]) => { + const coProposersChanged = (users: BasicUserDetails[]) => { formikProps.setFieldValue( `${id}.users`, users.map((user) => user.id) @@ -82,8 +87,10 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { proposer: user, }, }); + setPIData(user); - coInvestigatorChanged( + // Remove the new PI from the co-proposers list (if present) and add the old PI (if present) to the co-proposers list + coProposersChanged( users .filter((coInvestigator) => coInvestigator.id !== user.id) .concat(proposer as BasicUserDetails) @@ -166,25 +173,23 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { > {counter} - - + ) : ( + + )} + + @@ -192,7 +197,7 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { } const proposalBasisPreSubmit = - () => + (isInvitesEnabled: boolean) => async ({ api, dispatch, state }: SubmitActionDependencyContainer) => { const proposal = (state as ProposalSubmissionState).proposal; const { primaryKey, title, abstract, users, proposer, callId } = proposal; @@ -208,19 +213,24 @@ const proposalBasisPreSubmit = proposerId: proposer?.id, }); - const invites = await api.setCoProposerInvites({ - input: { - proposalPk: result.updateProposal.primaryKey, - emails: proposal.coProposerInvites.map((invite) => invite.email), - }, - }); + let invites; + if (isInvitesEnabled) { + invites = await api.setCoProposerInvites({ + input: { + proposalPk: result.updateProposal.primaryKey, + emails: proposal.coProposerInvites.map((invite) => invite.email), + }, + }); + } + + const coProposerInvites = invites?.setCoProposerInvites ?? []; dispatch({ type: 'ITEM_WITH_QUESTIONARY_LOADED', itemWithQuestionary: { ...proposal, ...result.updateProposal, - ...{ coProposerInvites: invites.setCoProposerInvites }, + ...(isInvitesEnabled ? { coProposerInvites } : {}), }, }); } else { @@ -235,19 +245,25 @@ const proposalBasisPreSubmit = proposerId: proposer?.id, }); - const invites = await api.setCoProposerInvites({ - input: { - proposalPk: updateProposal.primaryKey, - emails: proposal.coProposerInvites.map((invite) => invite.email), - }, - }); + let invites; + if (isInvitesEnabled) { + invites = await api.setCoProposerInvites({ + input: { + proposalPk: updateProposal.primaryKey, + emails: proposal.coProposerInvites.map((invite) => invite.email), + }, + }); + } + + const coProposerInvites = invites?.setCoProposerInvites ?? []; + dispatch({ type: 'ITEM_WITH_QUESTIONARY_CREATED', itemWithQuestionary: { ...proposal, ...createProposal, ...updateProposal, - ...{ coProposerInvites: invites.setCoProposerInvites }, + ...(isInvitesEnabled ? { coProposerInvites } : {}), }, }); returnValue = createProposal.questionaryId; diff --git a/apps/frontend/src/components/review/ProposalReviewContent.tsx b/apps/frontend/src/components/review/ProposalReviewContent.tsx index acb65c9639..dd61f3db4c 100644 --- a/apps/frontend/src/components/review/ProposalReviewContent.tsx +++ b/apps/frontend/src/components/review/ProposalReviewContent.tsx @@ -26,13 +26,11 @@ import { ProposalDataTechnicalReview, useProposalData, } from 'hooks/proposal/useProposalData'; -import { TechnicalReviewWithQuestionary } from 'models/questionary/technicalReview/TechnicalReviewWithQuestionary'; import { getFullUserName } from 'utils/user'; import ProposalReviewContainer from './ProposalReviewContainer'; import ProposalTechnicalReviewerAssignment from './ProposalTechnicalReviewerAssignment'; import TechnicalReviewContainer from './TechnicalReviewContainer'; -import TechnicalReviewInformation from './TechnicalReviewInformation'; import TechnicalReviewQuestionaryReview from './TechnicalReviewQuestionaryReview'; import InternalReviews from '../internalReview/InternalReviews'; @@ -191,11 +189,9 @@ const ProposalReviewContent = ({ > - ) : !isUserOfficer && !isFapSec ? ( - ) : ( ); } @@ -293,6 +289,7 @@ const ProposalReviewContent = ({ t(name))} isInsideModal={isInsideModal} + data-cy="proposal-review-tabs" > {tabsContent} diff --git a/apps/frontend/src/components/review/ProposalTableReviewer.tsx b/apps/frontend/src/components/review/ProposalTableReviewer.tsx index 8b1da558e7..3e96bfd751 100644 --- a/apps/frontend/src/components/review/ProposalTableReviewer.tsx +++ b/apps/frontend/src/components/review/ProposalTableReviewer.tsx @@ -43,7 +43,7 @@ type UserWithReview = { proposalId: string; proposalPk: number; title: string; - grade: number | null; + grade: string | null; reviewId: number; comment: string | null; status: ReviewStatus; diff --git a/apps/frontend/src/components/review/ReviewQuestionary.tsx b/apps/frontend/src/components/review/ReviewQuestionary.tsx index 5c65c77810..a20940859a 100644 --- a/apps/frontend/src/components/review/ReviewQuestionary.tsx +++ b/apps/frontend/src/components/review/ReviewQuestionary.tsx @@ -37,7 +37,7 @@ export function createFapReviewStub( ): FapReviewWithQuestionary { return { id: 0, - grade: 0, + grade: '0', comment: '', fapID: 0, reviewer: reviewer, diff --git a/apps/frontend/src/components/review/ReviewSummary.tsx b/apps/frontend/src/components/review/ReviewSummary.tsx index ca3e9e9cd2..582449c157 100644 --- a/apps/frontend/src/components/review/ReviewSummary.tsx +++ b/apps/frontend/src/components/review/ReviewSummary.tsx @@ -161,7 +161,7 @@ function ReviewSummary({ confirm }: ReviewSummaryProps) { toastSuccessMessage: 'Updated', }).updateReview({ reviewID: state.fapReview.id, - grade: state.fapReview.grade || 0, + grade: state.fapReview.grade || '0', comment: state.fapReview.comment || '', status: state.fapReview.status, fapID: state.fapReview.fapID, @@ -196,7 +196,7 @@ function ReviewSummary({ confirm }: ReviewSummaryProps) { 'Your review has been submitted successfully.', }).updateReview({ reviewID: state.fapReview.id, - grade: state.fapReview.grade || 0, + grade: state.fapReview.grade || '0', comment: state.fapReview.comment || '', status: ReviewStatus.SUBMITTED, fapID: state.fapReview.fapID, diff --git a/apps/frontend/src/components/review/TechnicalReviewInformation.tsx b/apps/frontend/src/components/review/TechnicalReviewInformation.tsx deleted file mode 100644 index 1a7b9f53fb..0000000000 --- a/apps/frontend/src/components/review/TechnicalReviewInformation.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableRow from '@mui/material/TableRow'; -import Typography from '@mui/material/Typography'; -import { getTranslation } from '@user-office-software/duo-localisation'; -import React, { Fragment } from 'react'; - -import { TechnicalReview, UserRole } from 'generated/sdk'; -import { useCheckAccess } from 'hooks/common/useCheckAccess'; -import { getFullUserName } from 'utils/user'; - -type TechnicalReviewInformationProps = { - data: TechnicalReview | null | undefined; -}; - -const TechnicalReviewInformation = (props: TechnicalReviewInformationProps) => { - const isInstrumentScientist = useCheckAccess([UserRole.INSTRUMENT_SCIENTIST]); - - if (!props.data) { - return

Proposal has no technical review

; - } - - return ( - - ({ marginTop: theme.spacing(2) })} - gutterBottom - > - Technical Review - - - - - Status - - {props.data.status && getTranslation(props.data.status)} - - - {isInstrumentScientist && ( - - Internal Comment - - - )} - - Comment - - - - - Time Allocation({props.data.proposal?.call?.allocationTimeUnit}s) - - {props.data.timeAllocation} - - - Reviewer - {getFullUserName(props.data.reviewer)} - - - Technical review assignee - - {getFullUserName(props.data.technicalReviewAssignee)} - - - -
-
- ); -}; - -export default TechnicalReviewInformation; diff --git a/apps/frontend/src/components/review/TechnicalReviewQuestionaryReview.tsx b/apps/frontend/src/components/review/TechnicalReviewQuestionaryReview.tsx index 91282a9dd9..2f264d74b4 100644 --- a/apps/frontend/src/components/review/TechnicalReviewQuestionaryReview.tsx +++ b/apps/frontend/src/components/review/TechnicalReviewQuestionaryReview.tsx @@ -4,6 +4,8 @@ import React, { FunctionComponent } from 'react'; import { DownloadableFileList } from 'components/common/DownloadableFileList'; import UOLoader from 'components/common/UOLoader'; import { TableRowData } from 'components/questionary/QuestionaryDetails'; +import { UserRole } from 'generated/sdk'; +import { useCheckAccess } from 'hooks/common/useCheckAccess'; import stripHtml from 'utils/stripHtml'; import ReviewQuestionaryDetails from './ReviewQuestionaryDetails'; @@ -14,6 +16,13 @@ export default function TechnicalReviewQuestionaryReview( data: TechnicalReviewWithQuestionary; } & TableProps> ) { + const internalUser = useCheckAccess([ + UserRole.USER_OFFICER, + UserRole.FAP_SECRETARY, + UserRole.INSTRUMENT_SCIENTIST, + UserRole.INTERNAL_REVIEWER, + ]); + const { data, ...restProps } = props; const fileId: { id: string }[] = data.files ? JSON.parse(data.files) : []; @@ -37,17 +46,21 @@ export default function TechnicalReviewQuestionaryReview( label: `Time allocation(${data.proposal?.call?.allocationTimeUnit}s)`, value: data.timeAllocation?.toString() || '', }, - { - label: 'Comment', - value: stripHtml(data.comment || ''), - }, - { - label: 'Internal Documents', - value: file.id)} />, - }, { label: 'Public Comment', value: stripHtml(data.publicComment || '') }, ]; + internalUser && + additionalDetails.push( + { + label: 'Internal Comment', + value: stripHtml(data.comment || ''), + }, + { + label: 'Internal Documents', + value: file.id)} />, + } + ); + return ( void; - addNewWorkflowConnectionsRow: ( - numberOfColumns: number, - parentDroppableId: string - ) => void; - parentDroppableIds: string[]; -}; - -const AddNewWorkflowConnectionsRow = ({ - close, - addNewWorkflowConnectionsRow, - parentDroppableIds, -}: AddNewWorkflowConnectionsRowProps) => { - const initialValues: { - selectedParentDroppableId?: string; - numberOfColumns?: number; - } = { - selectedParentDroppableId: undefined, - numberOfColumns: undefined, - }; - - return ( - - => { - if (values.selectedParentDroppableId) { - addNewWorkflowConnectionsRow( - values.numberOfColumns as number, - values.selectedParentDroppableId - ); - close(); - } - }} - validationSchema={addNewWorkflowConnectionsRowValidationSchema} - > - {({ isSubmitting, values }): JSX.Element => ( -
- - New workflow connection row - - - - - ({ - value: parentDroppableId, - text: index - ? `Workflow droppable group ${ - parentDroppableId.split('_')[1] - }` - : 'Default droppable group', - }))} - required - data-cy="selectParentDroppableGroup" - /> - - - Select number of columns - - ({ - text: numberOfColumn.toString(), - value: numberOfColumn, - }))} - /> - - - - -
- )} -
-
- ); -}; - -AddNewWorkflowConnectionsRow.propTypes = { - close: PropTypes.func.isRequired, - addNewWorkflowConnectionsRow: PropTypes.func.isRequired, - parentDroppableIds: PropTypes.array.isRequired, -}; - -export default AddNewWorkflowConnectionsRow; diff --git a/apps/frontend/src/components/settings/workflow/AddStatusChangingEventsToConnection.tsx b/apps/frontend/src/components/settings/workflow/AddStatusChangingEventsToConnection.tsx index a52692f680..54706d8ee0 100644 --- a/apps/frontend/src/components/settings/workflow/AddStatusChangingEventsToConnection.tsx +++ b/apps/frontend/src/components/settings/workflow/AddStatusChangingEventsToConnection.tsx @@ -25,6 +25,8 @@ const addStatusChangingEventsToConnectionValidationSchema = yup.object().shape({ type AddStatusChangingEventsToConnectionProps = { addStatusChangingEventsToConnection: (statusChangingEvents: string[]) => void; + deleteWorkflowConnection: () => void; + onClose: () => void; statusChangingEvents?: Event[]; statusName?: string; isLoading: boolean; @@ -34,6 +36,8 @@ type AddStatusChangingEventsToConnectionProps = { const AddStatusChangingEventsToConnection = ({ statusChangingEvents, addStatusChangingEventsToConnection, + deleteWorkflowConnection, + onClose, statusName, isLoading, entityType, @@ -47,6 +51,11 @@ const AddStatusChangingEventsToConnection = ({ selectedStatusChangingEvents: statusChangingEvents || [], }; + const deleteClickHandler = () => { + deleteWorkflowConnection(); + onClose(); + }; + return (
+ - - {connectionGroups} - - ); -}; - -export default withConfirm(WorkflowConnectionsEditor); diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx new file mode 100644 index 0000000000..705982488d --- /dev/null +++ b/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx @@ -0,0 +1,116 @@ +import { styled } from '@mui/system'; +import React from 'react'; +import { + BaseEdge, + EdgeLabelRenderer, + EdgeProps, + getBezierPath, + getSmoothStepPath, + getStraightPath, + getSimpleBezierPath, + ConnectionLineType, +} from 'reactflow'; + +import { ConnectionStatusAction } from 'generated/sdk'; + +interface WorkflowEdgeData { + events: string[]; + sourceStatusShortCode: string; + targetStatusShortCode: string; + workflowConnectionId?: number; + statusActions: ConnectionStatusAction[]; + connectionLineType?: ConnectionLineType; +} + +const List = styled('ul')({ + padding: '0px 5px', + margin: '0', + listStyleType: 'none', + fontSize: '11px', + color: '#333', + lineHeight: '1.6', + backgroundColor: '#FFF', + textAlign: 'center', +}); + +const WorkflowEdge: React.FC> = ({ + id, + data, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + markerEnd, +}) => { + // Get the appropriate path based on edge type + const getPath = () => { + const pathProps = { + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }; + + const connectionType = + data?.connectionLineType || ConnectionLineType.Bezier; + + switch (connectionType) { + case ConnectionLineType.Straight: + return getStraightPath(pathProps); + case ConnectionLineType.Step: + case ConnectionLineType.SmoothStep: + return getSmoothStepPath(pathProps); + case ConnectionLineType.SimpleBezier: + return getSimpleBezierPath(pathProps); + case ConnectionLineType.Bezier: + default: + return getBezierPath(pathProps); + } + }; + + const [edgePath, labelX, labelY] = getPath(); + + const events = data?.events || []; + const statusActions = data?.statusActions || []; + + return ( + <> + + +
+ {events.length > 0 && ( + + {events.map((e) => ( +
  • {e}
  • + ))} +
    + )} + {statusActions.length > 0 && ( + + {statusActions.map((e) => ( +
  • {`âš¡ ${e.action.name}`}
  • + ))} +
    + )} +
    +
    + + ); +}; + +export default WorkflowEdge; diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx index efb8ad8f39..11c2a96abc 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx @@ -1,17 +1,22 @@ -import { - DragDropContext, - DraggableLocation, - DropResult, -} from '@hello-pangea/dnd'; import Grid from '@mui/material/Grid'; -import LinearProgress from '@mui/material/LinearProgress'; import { useSnackbar } from 'notistack'; -import React from 'react'; +import React, { useCallback, useRef, useState } from 'react'; +import { + addEdge, + Connection, + ConnectionLineType, + Edge, + MarkerType, + Node, + ReactFlowInstance, + useEdgesState, + useNodesState, +} from 'reactflow'; +import 'reactflow/dist/style.css'; import { + ConnectionStatusAction, WorkflowConnection, - Status, - WorkflowConnectionGroup, WorkflowType, } from 'generated/sdk'; import { usePersistWorkflowEditorModel } from 'hooks/settings/usePersistWorkflowEditorModel'; @@ -19,134 +24,219 @@ import { useStatusesData } from 'hooks/settings/useStatusesData'; import { StyledContainer, StyledPaper } from 'styles/StyledComponents'; import { FunctionType } from 'utils/utilTypes'; +import LoadingOverlay from './LoadingOverlay'; +import StatusEventsAndActionsDialog from './StatusEventsAndActionsDialog'; import StatusPicker from './StatusPicker'; -import WorkflowConnectionsEditor from './WorkflowConnectionsEditor'; +import WorkflowCanvas from './WorkflowCanvas'; import WorkflowEditorModel, { Event, EventType } from './WorkflowEditorModel'; import WorkflowMetadataEditor from './WorkflowMetadataEditor'; +interface EdgeData { + events: string[]; + sourceStatusShortCode: string; + targetStatusShortCode: string; + workflowConnectionId?: number; + statusActions: ConnectionStatusAction[]; + connectionLineType?: ConnectionLineType; + prevConnectionId: number | null; +} + +const edgeFactory = ( + edgeData: Edge & { data: EdgeData } +): Edge => { + const base = { + animated: false, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 20, + height: 20, + }, + style: { cursor: 'pointer' }, + }; + + // Ensure we have valid source and target + if (!edgeData.source || !edgeData.target) { + throw new Error('Edge must have source and target'); + } + + return { + ...base, + id: edgeData.id, + source: edgeData.source, + target: edgeData.target, + sourceHandle: edgeData.sourceHandle || null, + targetHandle: edgeData.targetHandle || null, + data: 'data' in edgeData ? edgeData.data : undefined, + ariaLabel: `Edge from ${edgeData.data.sourceStatusShortCode} to ${edgeData.data.targetStatusShortCode}`, + } as Edge; +}; + const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { const { enqueueSnackbar } = useSnackbar(); const { statuses, loadingStatuses } = useStatusesData(entityType); + const reactFlowWrapper = useRef(null); + const [reactFlowInstance, setReactFlowInstance] = + useState(null); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + // State for managing transition events dialog + const [selectedEdge, setSelectedEdge] = useState | null>(null); + const [workflowConnection, setWorkflowConnection] = + useState(null); + const reducerMiddleware = () => { return (next: FunctionType) => (action: Event) => { next(action); }; }; + const { persistModel, isLoading } = usePersistWorkflowEditorModel(); const { state, dispatch } = WorkflowEditorModel(entityType, [ persistModel, reducerMiddleware, ]); - const workflowConnectionsPartOfWorkflow: WorkflowConnection[] = []; - state.workflowConnectionGroups.forEach((workflowConnectionGroup) => - workflowConnectionsPartOfWorkflow.push( - ...workflowConnectionGroup.connections - ) - ); - const statusesInThePicker = state.id ? statuses : []; - - const getPreviousWorkflowStatus = ( - destinationIndex: number, - currentDroppableGroup: WorkflowConnectionGroup - ) => { - const parentDroppableGroup = state.workflowConnectionGroups.find( - (workflowConnectionGroup) => - workflowConnectionGroup.groupId === currentDroppableGroup.parentGroupId - ); - const isFirstInTheGroupAndHasParentGroup = - destinationIndex === 0 && currentDroppableGroup.parentGroupId; + // Effect to update edge labels when workflowConnection changes + React.useEffect(() => { + if (selectedEdge && workflowConnection) { + setEdges((eds) => + eds.map((e) => { + if (e.id === selectedEdge.id) { + // Map the status changing events to their display names + const eventIds = + workflowConnection.statusChangingEvents?.map( + (event) => event.statusChangingEvent + ) || []; - const lastWorkflowStatusInParentGroup = - parentDroppableGroup?.connections[ - parentDroppableGroup?.connections.length - 1 - ]?.status; + const statusActions = workflowConnection.statusActions || []; - const previousWorkflowStatusInCurrentGroup = - currentDroppableGroup.connections[destinationIndex - 1]?.status; + return { + ...e, + data: { + ...e.data, + events: eventIds, + statusActions: statusActions, + connectionLineType: + state.connectionLineType as ConnectionLineType, + }, + }; + } - return isFirstInTheGroupAndHasParentGroup - ? lastWorkflowStatusInParentGroup || null - : previousWorkflowStatusInCurrentGroup || null; - }; + return e; + }) + ); + } + }, [workflowConnection, selectedEdge, setEdges, state.connectionLineType]); + + // Convert workflow connections to React Flow nodes and edges when state changes + React.useEffect(() => { + if (!state.workflowConnections || state.workflowConnections.length === 0) { + setNodes([]); + setEdges([]); + + return; + } - // TODO: Check this about getting next status in the workflow. It should be similar to getting previous. - const getNextWorkflowStatuses = ( - destination: DraggableLocation, - currentDroppableGroup: WorkflowConnectionGroup - ): Status[] => { - const isLastInTheCurrentGroup = - destination.index === currentDroppableGroup.connections.length; - - const childGroups = state.workflowConnectionGroups.filter( - (workflowConnectionGroup) => - workflowConnectionGroup.parentGroupId === currentDroppableGroup.groupId + const newNodes: Node[] = []; + const newEdges: Edge[] = []; + + // Sort connections by sortOrder to maintain proper sequence + const sortedConnections = [...state.workflowConnections].sort( + (a, b) => a.sortOrder - b.sortOrder ); - const isLastInTheGroupAndHasChildGroup = - isLastInTheCurrentGroup && childGroups?.length > 0; + // Create nodes for each connection + sortedConnections.forEach((connection) => { + const statusId = connection.status.id.toString(); + const nodeId = connection.id.toString(); + // Use database coordinates if available, otherwise fall back to grid layout + const nodePositionX = connection.posX; + const nodePositionY = connection.posY; - return isLastInTheGroupAndHasChildGroup - ? childGroups.flatMap((childGroup) => - childGroup.connections.map((connection) => connection.status) - ) || [] - : [currentDroppableGroup.connections[destination.index]?.status]; - }; + // Create node for the status + const newNode = { + id: nodeId, + type: 'statusNode', + data: { + label: connection.status.name, + status: connection.status, + statusId: statusId, + onDelete: (connectionId: string) => { + // Since the node ID is the connection ID, use it directly + dispatch({ + type: EventType.DELETE_WORKFLOW_STATUS_REQUESTED, + payload: { + connectionId: Number(connectionId), + }, + }); + }, + }, + position: { x: nodePositionX, y: nodePositionY }, + }; - const onDragEnd = (result: DropResult): void => { - const { source, destination } = result; - const isWrongDrop = - destination?.droppableId === source?.droppableId && - source.index === destination.index; + newNodes.push(newNode); - if (!destination || isWrongDrop) { - return; - } - const isDragAndDropFromStatusPickerToWorkflowEditor = - source.droppableId === 'statusPicker' && - destination.droppableId.includes('WorkflowConnections'); - - const isReorderingConnectionsInsideWorkflowEditor = - source.droppableId.includes('WorkflowConnections') && - destination?.droppableId.includes('WorkflowConnections'); - - const isDragAndDropFromWorkflowEditorToStatusPicker = - source.droppableId.includes('WorkflowConnections') && - destination?.droppableId === 'statusPicker'; - if (isDragAndDropFromStatusPickerToWorkflowEditor) { - const currentDroppableGroup = state.workflowConnectionGroups.find( - (workflowConnectionGroup) => - workflowConnectionGroup.groupId === destination.droppableId - ); + // Create edge from previous connection id if it exists because node can have only one parent + if (connection.prevConnectionId) { + const prevStatusId = connection.prevStatusId!.toString(); + const prevConnection = sortedConnections.find( + (c) => c.id === connection.prevConnectionId + ); - const isDroppedBeforeVeryFirstStatus = - currentDroppableGroup?.groupId.endsWith('WorkflowConnections_0') && - destination.index === 0 && - currentDroppableGroup.connections.length > 0; - if (isDroppedBeforeVeryFirstStatus) { - enqueueSnackbar('Adding before first status is not allowed', { - variant: 'info', - className: 'snackbar-info', - }); + if (prevConnection) { + const edgeId = `edge-${prevStatusId}-${statusId}-${connection.id}`; + const edgeAlreadyExists = newEdges.some((edge) => edge.id === edgeId); + + if (!edgeAlreadyExists) { + const newEdge = edgeFactory({ + id: edgeId, // Use connection ID to ensure unique edge identification + source: connection.prevConnectionId.toString(), + target: connection.id.toString(), + type: 'workflow', // Use custom workflow edge type + data: { + events: + connection.statusChangingEvents?.map( + (e) => e.statusChangingEvent + ) || [], + sourceStatusShortCode: prevConnection.status.shortCode, + targetStatusShortCode: connection.status.shortCode, + workflowConnectionId: connection.id, // Use target connection ID (destination) + statusActions: connection.statusActions || [], + connectionLineType: + state.connectionLineType as ConnectionLineType, + prevConnectionId: connection.prevConnectionId || null, + }, + }); + newEdges.push(newEdge); + } + } + } + }); + + setNodes(newNodes); + setEdges(newEdges); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.workflowConnections, state.connectionLineType, setNodes, setEdges]); + + // Handle connecting nodes (adding transitions) + const onConnect = useCallback( + (connection: Connection) => { + // Validate connection has required properties + if (!connection.source || !connection.target) { return; } - const statusId = statusesInThePicker[source.index].id; - const nextStatusesId = getNextWorkflowStatuses( - destination, - currentDroppableGroup as WorkflowConnectionGroup - ).map((status) => status?.id); - const prevStatusId = getPreviousWorkflowStatus( - destination.index, - currentDroppableGroup as WorkflowConnectionGroup - )?.id; - - const [firstNextStatusId] = nextStatusesId; - const isRepeatingTheSameStatus = - nextStatusesId?.some((nextStatusId) => nextStatusId === statusId) || - statusId === prevStatusId; - if (isRepeatingTheSameStatus) { - enqueueSnackbar('Repeating same status is not allowed', { + + // Check if connection already exists + const connectionExists = edges.some( + (edge) => + edge.source === connection.source && + edge.target === connection.target && + !edge.id.startsWith('temp-') // Don't count temporary edges as existing connections + ); + if (connectionExists) { + enqueueSnackbar('Connection already exists', { variant: 'info', className: 'snackbar-info', }); @@ -154,78 +244,264 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { return; } + // Check if target node already has a parent (restrict to only one parent) + const targetHasParentInEdges = edges.some( + (edge) => edge.target === connection.target + ); + + if (targetHasParentInEdges) { + enqueueSnackbar( + 'Node can only have one parent connection. Try adding another status of the same type instead.', + { + variant: 'warning', + className: 'snackbar-warning', + } + ); + + return; + } + + const sourceConnection = state.workflowConnections.find( + (s) => s.id.toString() === connection.source + ); + const targetConnection = state.workflowConnections.find( + (s) => s.id.toString() === connection.target + ); + + if (!sourceConnection || !targetConnection) { + return; + } + + // Find source and target status names for the edge data + const sourceStatus = statuses.find( + (s) => s.id === sourceConnection.statusId + ); + const targetStatus = statuses.find( + (s) => s.id === targetConnection.statusId + ); + + if (!sourceStatus || !targetStatus) { + return; + } + + // Add the connection to the graph + const newEdge = edgeFactory({ + id: `temp-${connection.source}-${connection.target}`, // Temporary ID until persisted + source: connection.source, + target: connection.target, + type: 'workflow', // Use custom workflow edge type + data: { + events: [], // No events initially + sourceStatusShortCode: sourceStatus.shortCode, + targetStatusShortCode: targetStatus.shortCode, + statusActions: [], + connectionLineType: state.connectionLineType as ConnectionLineType, + prevConnectionId: sourceConnection.id, + }, + }); + + setEdges((eds) => addEdge(newEdge, eds)); + + // Dispatch update events to persist the connection in the database + // We need to update BOTH nodes: source gets nextStatusId, target gets prevStatusId and prevConnectionId + + // Update source node (A) - set its nextStatusId to target (B) dispatch({ - type: EventType.ADD_WORKFLOW_STATUS_REQUESTED, + type: EventType.WORKFLOW_STATUS_UPDATE_REQUESTED, payload: { - source, - sortOrder: destination.index, - droppableGroupId: destination.droppableId, - parentDroppableGroupId: currentDroppableGroup?.parentGroupId || null, - statusId: statusId, - status: { - ...statusesInThePicker[source.index], - }, - nextStatusId: firstNextStatusId, - prevStatusId, - workflowId: state.id, + connectionId: sourceConnection.id, // Use connection ID for persistence + nextStatusId: targetConnection.statusId, }, }); - } else if (isReorderingConnectionsInsideWorkflowEditor) { - // NOTE: For now reordering is disabled! - // dispatch({ - // type: EventType.REORDER_WORKFLOW_STATUS_REQUESTED, - // payload: { - // source, - // destination, - // }, - // }); - return; - } else if (isDragAndDropFromWorkflowEditorToStatusPicker) { + + // Update target node (B) - set its prevStatusId to source (A) and prevConnectionId to source connection ID dispatch({ - type: EventType.DELETE_WORKFLOW_STATUS_REQUESTED, + type: EventType.WORKFLOW_STATUS_UPDATE_REQUESTED, payload: { - source, - destination, + connectionId: targetConnection.id, // Use connection ID for persistence + prevStatusId: sourceConnection.statusId, + prevConnectionId: sourceConnection.id, // Store the previous connection ID }, }); - } - }; - const dataLoaded = !isLoading && !loadingStatuses && state.id; - const getContainerStyle = (): React.CSSProperties => { - return !dataLoaded - ? { - pointerEvents: 'none', - userSelect: 'none', - opacity: 0.5, - minHeight: '380px', - } - : {}; - }; + // Note: Connection is persisted by updating both source and target statuses + }, + [ + dispatch, + edges, + enqueueSnackbar, + setEdges, + statuses, + state.connectionLineType, + state.workflowConnections, + ] + ); + + // Handle edge click to open the transition event dialog + const onEdgeClick = useCallback( + (event: React.MouseEvent, edge: Edge) => { + setSelectedEdge(edge); + + const targetWorkflowConnection = state.workflowConnections.find( + (connection) => connection.id.toString() === edge.target + ); + + if (!targetWorkflowConnection) { + return; + } + + setWorkflowConnection(targetWorkflowConnection); + }, + [state.workflowConnections] + ); + + // Handle status drag from picker to flow area + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + // Handle dropping a status into the flow area + const onDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + + if (!reactFlowWrapper.current || !reactFlowInstance) return; + + const statusId = event.dataTransfer.getData('application/reactflow'); + const status = statuses.find((s) => s.id.toString() === statusId); + + if (!status) return; + + // Get drop position + const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect(); + const position = reactFlowInstance.project({ + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, + }); + position.x = Math.round(position.x); + position.y = Math.round(position.y); + + // Create new node + const newNode: Node = { + id: statusId, + type: 'statusNode', + ariaLabel: `connection_${status.shortCode}`, + data: { + label: status.name, + status, + onDelete: (connectionId: string) => { + dispatch({ + type: EventType.DELETE_WORKFLOW_STATUS_REQUESTED, + payload: { + connectionId: Number(connectionId), + }, + }); + }, + }, + position, + }; - const progressJsx = !dataLoaded ? : null; + // Add the node + setNodes((nds) => nds.concat(newNode)); + + // Dispatch action to add status to the workflow model + dispatch({ + type: EventType.ADD_WORKFLOW_STATUS_REQUESTED, + payload: { + statusId: status.id, + status, + sortOrder: 0, + workflowId: state.id, + nextStatusId: null, + prevStatusId: null, + posX: position.x, + posY: position.y, + }, + }); + }, + [reactFlowInstance, statuses, setNodes, dispatch, state.id] + ); + + // Determine if data is loaded + const dataLoaded = !isLoading && !loadingStatuses && state.id; return ( - + - - {progressJsx} - - + + + - { + // Extract statusId from node data + if (node.data && node.data.status && node.position) { + const newPosX = Math.round(node.position.x); + const newPosY = Math.round(node.position.y); + + // Find the workflow connection for this status to check current position + const workflowConnection = state.workflowConnections.find( + (connection) => connection.id === parseInt(node.id) + ); + + // Only dispatch update if position has actually changed + if ( + workflowConnection && + (workflowConnection.posX !== newPosX || + workflowConnection.posY !== newPosY) + ) { + dispatch({ + type: EventType.WORKFLOW_STATUS_UPDATE_REQUESTED, + payload: { + connectionId: workflowConnection.id, + posX: newPosX, + posY: newPosY, + }, + }); + } + } + }} + reactFlowWrapper={reactFlowWrapper} + connectionLineType={ + state.connectionLineType as ConnectionLineType + } /> - + { + /* Make status draggable to ReactFlow */ + const event = new CustomEvent('statusDragStart', { + detail: status, + }); + document.dispatchEvent(event); + }} + /> - + + {/* Status Events and Actions Dialog */} + {selectedEdge && ( + + )} ); }; diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx index 55fc2f29bd..a1954b0aae 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx @@ -2,15 +2,9 @@ import produce from 'immer'; import { Reducer, useCallback, useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { ConnectionLineType } from 'reactflow'; -import { - StatusChangingEvent, - WorkflowConnection, - ConnectionStatusAction, - Workflow, - WorkflowConnectionGroup, - WorkflowType, -} from 'generated/sdk'; +import { Workflow, WorkflowType } from 'generated/sdk'; import { useDataApi } from 'hooks/common/useDataApi'; import { useReducerWithMiddleWares, @@ -21,18 +15,18 @@ export enum EventType { READY, ADD_WORKFLOW_STATUS_REQUESTED, WORKFLOW_STATUS_ADDED, + WORKFLOW_STATUS_UPDATE_REQUESTED, WORKFLOW_STATUS_UPDATED, DELETE_WORKFLOW_STATUS_REQUESTED, WORKFLOW_STATUS_DELETED, - REORDER_WORKFLOW_STATUS_REQUESTED, - REORDER_WORKFLOW_STATUS_FAILED, UPDATE_WORKFLOW_METADATA_REQUESTED, WORKFLOW_METADATA_UPDATED, - ADD_NEW_ROW_WITH_MULTIPLE_COLUMNS, NEXT_STATUS_EVENTS_ADDED, ADD_NEXT_STATUS_EVENTS_REQUESTED, ADD_STATUS_ACTION_REQUESTED, STATUS_ACTION_ADDED, + DELETE_WORKFLOW_CONNECTION_REQUESTED, + WORKFLOW_CONNECTION_DELETED, } export interface Event { @@ -49,119 +43,9 @@ const WorkflowEditorModel = ( id: 0, name: '', description: '', - workflowConnectionGroups: [], - entityType: entityType, // NOTE: This is hardcoded for now - }; - - const findGroupIndexByGroupId = ( - workflowConnectionGroups: WorkflowConnectionGroup[], - groupId: string - ) => - workflowConnectionGroups.findIndex( - (workflowConnectionGroup) => workflowConnectionGroup.groupId === groupId - ); - - const findGroupAndAddNewStatusConnection = ( - workflowConnectionGroups: WorkflowConnectionGroup[], - newConnection: WorkflowConnection - ) => { - const groupIndexWhereStatusShouldBeAdded = findGroupIndexByGroupId( - workflowConnectionGroups, - newConnection.droppableGroupId - ); - - workflowConnectionGroups[ - groupIndexWhereStatusShouldBeAdded - ].connections.splice(newConnection.sortOrder, 0, newConnection); - - const previousConnectionInTheGroup = - workflowConnectionGroups[groupIndexWhereStatusShouldBeAdded].connections[ - newConnection.sortOrder - 1 - ]; - - if (previousConnectionInTheGroup) { - previousConnectionInTheGroup.nextStatusId = newConnection.statusId; - } - - return workflowConnectionGroups; - }; - - const moveStatusConnectionInsideWorkflow = ( - workflowConnectionGroups: WorkflowConnectionGroup[], - from: { droppableId: string; index: number }, - to: { droppableId: string; index: number } - ) => { - const sourceWorkflowConnectionsGroupIndex = findGroupIndexByGroupId( - workflowConnectionGroups, - from.droppableId - ); - - const destinationWorkflowConnectionGroupIndex = - from.droppableId === to.droppableId - ? sourceWorkflowConnectionsGroupIndex - : findGroupIndexByGroupId(workflowConnectionGroups, to.droppableId); - const workflowConnectionToMove = - workflowConnectionGroups[sourceWorkflowConnectionsGroupIndex] - ?.connections[from.index]; - - workflowConnectionGroups[ - sourceWorkflowConnectionsGroupIndex - ]?.connections.splice(from.index, 1); - - workflowConnectionToMove.droppableGroupId = - workflowConnectionGroups[destinationWorkflowConnectionGroupIndex].groupId; - - workflowConnectionGroups[ - destinationWorkflowConnectionGroupIndex - ]?.connections.splice( - to.index, - 0, - workflowConnectionToMove as WorkflowConnection - ); - - return workflowConnectionGroups; - }; - - const addStatusChangingEventsToConnection = ( - workflowConnectionGroups: WorkflowConnectionGroup[], - workflowConnection: WorkflowConnection, - statusChangingEvents: StatusChangingEvent[] - ) => { - const groupIndexWhereConnectionShouldBeUpdated = findGroupIndexByGroupId( - workflowConnectionGroups, - workflowConnection.droppableGroupId - ); - - const connectionToUpdate = workflowConnectionGroups[ - groupIndexWhereConnectionShouldBeUpdated - ].connections.find((connection) => connection.id === workflowConnection.id); - - if (connectionToUpdate) { - connectionToUpdate.statusChangingEvents = statusChangingEvents; - } - - return workflowConnectionGroups; - }; - - const addStatusActionsToConnection = ( - workflowConnectionGroups: WorkflowConnectionGroup[], - workflowConnection: WorkflowConnection, - statusActions: ConnectionStatusAction[] - ) => { - const groupIndexWhereConnectionShouldBeUpdated = findGroupIndexByGroupId( - workflowConnectionGroups, - workflowConnection.droppableGroupId - ); - - const connectionToUpdate = workflowConnectionGroups[ - groupIndexWhereConnectionShouldBeUpdated - ].connections.find((connection) => connection.id === workflowConnection.id); - - if (connectionToUpdate) { - connectionToUpdate.statusActions = statusActions; - } - - return workflowConnectionGroups; + workflowConnections: [], + connectionLineType: ConnectionLineType.Bezier, + entityType: entityType, }; function reducer(state: Workflow, action: Event): Workflow { @@ -170,115 +54,96 @@ const WorkflowEditorModel = ( case EventType.READY: return action.payload; case EventType.WORKFLOW_STATUS_ADDED: { - const { workflowConnectionGroups } = draft; - const newConnectionToAdd = action.payload; - - draft.workflowConnectionGroups = findGroupAndAddNewStatusConnection( - workflowConnectionGroups, - newConnectionToAdd - ); + // Add the new workflow connection to the state + if ( + action.payload && + action.payload.statusId && + action.payload.status + ) { + const newConnection = { + id: action.payload.id || 0, // Will be updated when API response comes back + workflowId: action.payload.workflowId, + statusId: action.payload.statusId, + status: action.payload.status, + sortOrder: action.payload.sortOrder, + prevStatusId: action.payload.prevStatusId, + nextStatusId: action.payload.nextStatusId, + posX: action.payload.posX, + posY: action.payload.posY, + prevConnectionId: action.payload.prevConnectionId || null, + statusChangingEvents: [], + statusActions: [], + }; + draft.workflowConnections.push(newConnection); + } return draft; } case EventType.WORKFLOW_STATUS_UPDATED: { - const { workflowConnectionGroups } = draft; - const connectionToUpdate = action.payload; - - const groupIndexWhereStatusShouldBeAdded = findGroupIndexByGroupId( - workflowConnectionGroups, - connectionToUpdate.droppableGroupId - ); - - workflowConnectionGroups[ - groupIndexWhereStatusShouldBeAdded - ].connections[action.payload.sortOrder].id = action.payload.id; + // If payload contains an updated connection (from middleware response), update state + if (action.payload && action.payload.id) { + const updatedConnection = action.payload; + const connectionIndex = draft.workflowConnections.findIndex( + (conn) => + conn.id === updatedConnection.id || + (conn.id === 0 && conn.statusId === updatedConnection.statusId) + ); + if (connectionIndex !== -1) { + draft.workflowConnections[connectionIndex] = { + ...draft.workflowConnections[connectionIndex], + ...updatedConnection, + }; + } + } + // Otherwise, this is just a request that will be handled by middleware return draft; } - case EventType.REORDER_WORKFLOW_STATUS_REQUESTED: - const { source, destination } = action.payload; - - draft.workflowConnectionGroups = moveStatusConnectionInsideWorkflow( - draft.workflowConnectionGroups, - source, - destination - ); - - return draft; - case EventType.REORDER_WORKFLOW_STATUS_FAILED: - // draft.proposalWorkflowConnectionGroups = moveArrayElement( - // draft.proposalWorkflowConnectionGroups, - // action.payload.source.index, - // action.payload.destination.index - // ); - - return draft; - case EventType.WORKFLOW_STATUS_DELETED: - const removingGroupIndex = findGroupIndexByGroupId( - draft.workflowConnectionGroups, - action.payload.source.droppableId - ); - - const removingGroupConnections = - draft.workflowConnectionGroups[removingGroupIndex].connections; - - const previousConnection = - removingGroupConnections[action.payload.source.index - 1]; - if (previousConnection) { - previousConnection.nextStatusId = - removingGroupConnections[ - action.payload.source.index - ].nextStatusId; + case EventType.WORKFLOW_STATUS_DELETED: { + // Remove the workflow connection by connectionId + if (action.payload.connectionId) { + draft.workflowConnections = draft.workflowConnections.filter( + (conn) => conn.id !== action.payload.connectionId + ); } - removingGroupConnections.splice(action.payload.source.index, 1); - return draft; + } case EventType.WORKFLOW_METADATA_UPDATED: { return { ...draft, ...action.payload }; } case EventType.NEXT_STATUS_EVENTS_ADDED: { - const { workflowConnectionGroups } = draft; const { workflowConnection, statusChangingEvents } = action.payload; - - draft.workflowConnectionGroups = addStatusChangingEventsToConnection( - workflowConnectionGroups, - workflowConnection, - statusChangingEvents + const connectionIndex = draft.workflowConnections.findIndex( + (conn) => conn.id === workflowConnection.id ); + if (connectionIndex !== -1) { + draft.workflowConnections[connectionIndex].statusChangingEvents = + statusChangingEvents; + } return draft; } case EventType.STATUS_ACTION_ADDED: { - const { workflowConnectionGroups } = draft; const { workflowConnection, statusActions } = action.payload; - - draft.workflowConnectionGroups = addStatusActionsToConnection( - workflowConnectionGroups, - workflowConnection, - statusActions + const connectionIndex = draft.workflowConnections.findIndex( + (conn) => conn.id === workflowConnection.id ); + if (connectionIndex !== -1) { + draft.workflowConnections[connectionIndex].statusActions = + statusActions; + } return draft; } - case EventType.ADD_NEW_ROW_WITH_MULTIPLE_COLUMNS: { - const groupsToAdd: WorkflowConnectionGroup[] = []; - const lastGroupId = parseInt( - draft.workflowConnectionGroups[ - draft.workflowConnectionGroups.length - 1 - ].groupId.split('_')[1] - ); - - for (let index = 0; index < action.payload.numberOfColumns; index++) { - groupsToAdd.push({ - groupId: `${entityType}WorkflowConnections_${lastGroupId + index + 1}`, - parentGroupId: action.payload.parentDroppableId, - connections: [], - }); + case EventType.WORKFLOW_CONNECTION_DELETED: { + // Remove the workflow connection by connectionId + if (action.payload && action.payload.connectionId) { + draft.workflowConnections = draft.workflowConnections.filter( + (conn) => conn.id !== action.payload.connectionId + ); } - draft.workflowConnectionGroups.push(...groupsToAdd); - return draft; } } @@ -304,22 +169,15 @@ const WorkflowEditorModel = ( api() .getWorkflow({ workflowId: parseInt(workflowId), + entityType: entityType, }) .then((data) => { - // NOTE: Push at least one group to have initial droppable if new proposal workflow - if (data.workflow?.workflowConnectionGroups.length === 0) { - data.workflow.workflowConnectionGroups.push({ - groupId: `${entityType}WorkflowConnections_0`, - parentGroupId: null, - connections: [], - }); - } memoizedDispatch({ type: EventType.READY, payload: data.workflow, }); }); - }, [api, memoizedDispatch, workflowId]); + }, [api, memoizedDispatch, workflowId, entityType]); return { state, dispatch }; }; diff --git a/apps/frontend/src/components/settings/workflow/WorkflowMetadataEditor.tsx b/apps/frontend/src/components/settings/workflow/WorkflowMetadataEditor.tsx index 8cde730114..2609230c2d 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowMetadataEditor.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowMetadataEditor.tsx @@ -2,17 +2,38 @@ import EditIcon from '@mui/icons-material/Edit'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Paper from '@mui/material/Paper'; +import { styled } from '@mui/system'; import { updateWorkflowValidationSchema } from '@user-office-software/duo-validation/lib/Workflow'; import { Field, Form, Formik } from 'formik'; import React, { useState } from 'react'; +import { ConnectionLineType } from 'reactflow'; import { ActionButtonContainer } from 'components/common/ActionButtonContainer'; -import TextField from 'components/common/FormikUITextField'; +import FormikUISelect from 'components/common/FormikUISelect'; +import FormikUITextField from 'components/common/FormikUITextField'; import { Workflow } from 'generated/sdk'; import { StyledButtonContainer } from 'styles/StyledComponents'; import { Event, EventType } from './WorkflowEditorModel'; +// Connection line type options for the dropdown +const CONNECTION_LINE_TYPE_OPTIONS = [ + { value: ConnectionLineType.Bezier, text: 'Bezier' }, + { value: ConnectionLineType.Straight, text: 'Straight' }, + { value: ConnectionLineType.SmoothStep, text: 'Smooth Step' }, + { value: ConnectionLineType.SimpleBezier, text: 'Simple Bezier' }, +]; + +const StyledButton = styled(Button)({ + margin: '25px 10px 0 10px', + '&:first-of-type': { + marginLeft: '0', + }, + '&:last-of-type': { + marginRight: '0', + }, +}); + const WorkflowMetadataEditor = ({ workflow, dispatch, @@ -30,23 +51,17 @@ const WorkflowMetadataEditor = ({ {workflow.description} + + Connection line style: {workflow.connectionLineType} + - + ); @@ -67,9 +82,9 @@ const WorkflowMetadataEditor = ({ + + - - + )} diff --git a/apps/frontend/src/components/template/TemplateQuestionEditor.tsx b/apps/frontend/src/components/template/TemplateQuestionEditor.tsx index dd9bfd8b6a..6156b3a6a5 100644 --- a/apps/frontend/src/components/template/TemplateQuestionEditor.tsx +++ b/apps/frontend/src/components/template/TemplateQuestionEditor.tsx @@ -59,7 +59,7 @@ export default function TemplateQuestionEditor(props: { ? '&&' : '||'; const dependencyJsx = dependencies.length ? ( -
    + <>
      {dependencies.map((dependency, i) => { @@ -99,7 +99,7 @@ export default function TemplateQuestionEditor(props: { ); })}
    -
    + ) : null; const questionDefinition = getQuestionaryComponentDefinition( props.data.dataType @@ -138,6 +138,7 @@ export default function TemplateQuestionEditor(props: { fontSize: '12px', fontWeight: 'bold', color: theme.palette.grey[800], + wordBreak: 'break-word', }} data-cy="proposal-question-id" > @@ -148,9 +149,9 @@ export default function TemplateQuestionEditor(props: { xs={2} sx={{ color: theme.palette.grey[400], - justifyItems: 'flex-end', - justifyContent: 'flex-end', display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', }} > {getTemplateFieldIcon(props.data.dataType)} @@ -159,7 +160,7 @@ export default function TemplateQuestionEditor(props: { {questionDefinition.renderers ? questionDefinition.renderers.questionRenderer(props.data) diff --git a/apps/frontend/src/context/UserContextProvider.tsx b/apps/frontend/src/context/UserContextProvider.tsx index b9a6fd406f..4bca385200 100755 --- a/apps/frontend/src/context/UserContextProvider.tsx +++ b/apps/frontend/src/context/UserContextProvider.tsx @@ -70,8 +70,13 @@ const initUserData: UserContextData = { handleRole: (value) => value, }; -export const getCurrentUser = () => - jwtDecode(localStorage.token) as DecodedTokenData | null; +export const getCurrentUser = () => { + if (!localStorage.token) { + return null; + } + + return jwtDecode(localStorage.token) as DecodedTokenData | null; +}; const checkLocalStorage = ( dispatch: React.Dispatch<{ diff --git a/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql b/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql index 5eeb6426b7..a5b0bc7a96 100644 --- a/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql +++ b/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql @@ -3,12 +3,10 @@ query getCallSubmissionDetails($callId: Int!) { proposalWorkflowId submissionMessage proposalWorkflow { - workflowConnectionGroups { - connections { - prevStatusId - status { - shortCode - } + workflowConnections { + prevStatusId + status { + shortCode } } } diff --git a/apps/frontend/src/graphql/dataAccessUser/updateDataAccessUsers.graphql b/apps/frontend/src/graphql/dataAccessUser/updateDataAccessUsers.graphql new file mode 100644 index 0000000000..7f0f8d8a86 --- /dev/null +++ b/apps/frontend/src/graphql/dataAccessUser/updateDataAccessUsers.graphql @@ -0,0 +1,8 @@ +mutation updateDataAccessUsers( + $proposalPk: Int! + $userIds: [Int!]! +) { + updateDataAccessUsers(proposalPk: $proposalPk, userIds: $userIds) { + ...basicUserDetails + } +} diff --git a/apps/frontend/src/graphql/experiment/getAllExperiments.graphql b/apps/frontend/src/graphql/experiment/getAllExperiments.graphql index 825ca6de6b..a236f88015 100644 --- a/apps/frontend/src/graphql/experiment/getAllExperiments.graphql +++ b/apps/frontend/src/graphql/experiment/getAllExperiments.graphql @@ -1,4 +1,4 @@ -query getAllExperiments( +query getExperiments( $filter: ExperimentsFilter $first: Int $offset: Int diff --git a/apps/frontend/src/graphql/fap/getFapProposals.graphql b/apps/frontend/src/graphql/fap/getFapProposals.graphql index 3458f38393..df706d1f6d 100644 --- a/apps/frontend/src/graphql/fap/getFapProposals.graphql +++ b/apps/frontend/src/graphql/fap/getFapProposals.graphql @@ -1,5 +1,5 @@ -query getFapProposals($fapId: Int!, $callId: Int, $instrumentId: Int) { - fapProposals(fapId: $fapId, callId: $callId, instrumentId: $instrumentId) { +query getFapProposals($fapId: Int!, $callId: Int, $instrumentId: Int, $legacy: Boolean) { + fapProposals(fapId: $fapId, callId: $callId, instrumentId: $instrumentId, legacy: $legacy) { proposalPk dateAssigned fapId diff --git a/apps/frontend/src/graphql/proposal/notifyProposal.graphql b/apps/frontend/src/graphql/proposal/notifyProposal.graphql index 51d965001a..6867fec2f9 100644 --- a/apps/frontend/src/graphql/proposal/notifyProposal.graphql +++ b/apps/frontend/src/graphql/proposal/notifyProposal.graphql @@ -1,5 +1,8 @@ -mutation notifyProposal($proposalPk: Int!) { - notifyProposal(proposalPk: $proposalPk) { +mutation notifyProposal($proposalPk: Int!, $ignoreNotifiedFlag: Boolean!) { + notifyProposal( + proposalPk: $proposalPk + ignoreNotifiedFlag: $ignoreNotifiedFlag + ) { primaryKey } } diff --git a/apps/frontend/src/graphql/review/updateReview.graphql b/apps/frontend/src/graphql/review/updateReview.graphql index a67ab592ad..7f98c28353 100644 --- a/apps/frontend/src/graphql/review/updateReview.graphql +++ b/apps/frontend/src/graphql/review/updateReview.graphql @@ -1,6 +1,6 @@ mutation updateReview( $reviewID: Int! - $grade: Float! + $grade: String! $comment: String! $status: ReviewStatus! $fapID: Int! diff --git a/apps/frontend/src/graphql/settings/addWorkflowStatus.graphql b/apps/frontend/src/graphql/settings/addWorkflowStatus.graphql index dfcca1d4d6..e2840e2994 100644 --- a/apps/frontend/src/graphql/settings/addWorkflowStatus.graphql +++ b/apps/frontend/src/graphql/settings/addWorkflowStatus.graphql @@ -1,21 +1,23 @@ mutation addWorkflowStatus( $workflowId: Int! $sortOrder: Int! - $droppableGroupId: String! - $parentDroppableGroupId: String $statusId: Int! + $posX: Int! + $posY: Int! $nextStatusId: Int $prevStatusId: Int + $prevConnectionId: Int ) { addWorkflowStatus( newWorkflowStatusInput: { workflowId: $workflowId sortOrder: $sortOrder - droppableGroupId: $droppableGroupId - parentDroppableGroupId: $parentDroppableGroupId statusId: $statusId nextStatusId: $nextStatusId prevStatusId: $prevStatusId + posX: $posX + posY: $posY + prevConnectionId: $prevConnectionId } ) { id diff --git a/apps/frontend/src/graphql/settings/createWorkflow.graphql b/apps/frontend/src/graphql/settings/createWorkflow.graphql index 57e72a79ea..7b0eaddf9b 100644 --- a/apps/frontend/src/graphql/settings/createWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/createWorkflow.graphql @@ -13,28 +13,27 @@ mutation createWorkflow( id name description - workflowConnectionGroups { - groupId - parentGroupId - connections { - id - sortOrder - workflowId - statusId - status { - ...status - } - nextStatusId - prevStatusId - droppableGroupId - statusChangingEvents { - statusChangingEventId - workflowConnectionId - statusChangingEvent - } - statusActions { - ...connectionStatusAction - } + connectionLineType + workflowConnections { + id + sortOrder + workflowId + statusId + status { + ...status + } + nextStatusId + prevStatusId + prevConnectionId + posX + posY + statusChangingEvents { + statusChangingEventId + workflowConnectionId + statusChangingEvent + } + statusActions { + ...connectionStatusAction } } } diff --git a/apps/frontend/src/graphql/settings/deleteWorkflow.graphql b/apps/frontend/src/graphql/settings/deleteWorkflow.graphql index 71a1bef3ad..ceec01a212 100644 --- a/apps/frontend/src/graphql/settings/deleteWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/deleteWorkflow.graphql @@ -3,5 +3,6 @@ mutation deleteWorkflow($id: Int!) { id name description + connectionLineType } } diff --git a/apps/frontend/src/graphql/settings/deleteWorkflowConnection.graphql b/apps/frontend/src/graphql/settings/deleteWorkflowConnection.graphql new file mode 100644 index 0000000000..d702ff8669 --- /dev/null +++ b/apps/frontend/src/graphql/settings/deleteWorkflowConnection.graphql @@ -0,0 +1,13 @@ +mutation deleteWorkflowConnection($connectionId: Int!) { + deleteWorkflowConnection(connectionId: $connectionId) { + id + sortOrder + workflowId + statusId + nextStatusId + prevStatusId + prevConnectionId + posX + posY + } +} diff --git a/apps/frontend/src/graphql/settings/fragment.proposalStatus.graphql b/apps/frontend/src/graphql/settings/fragment.status.graphql similarity index 100% rename from apps/frontend/src/graphql/settings/fragment.proposalStatus.graphql rename to apps/frontend/src/graphql/settings/fragment.status.graphql diff --git a/apps/frontend/src/graphql/settings/getWorkflow.graphql b/apps/frontend/src/graphql/settings/getWorkflow.graphql index 43da65de6e..ece4de8b54 100644 --- a/apps/frontend/src/graphql/settings/getWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/getWorkflow.graphql @@ -1,30 +1,29 @@ -query getWorkflow($workflowId: Int!) { - workflow(workflowId: $workflowId) { +query getWorkflow($workflowId: Int!, $entityType: WorkflowType!) { + workflow(workflowId: $workflowId, entityType: $entityType) { id name description - workflowConnectionGroups { - groupId - parentGroupId - connections { - id - sortOrder - workflowId - statusId - status { - ...status - } - nextStatusId - prevStatusId - droppableGroupId - statusChangingEvents { - statusChangingEventId - workflowConnectionId - statusChangingEvent - } - statusActions { - ...connectionStatusAction - } + connectionLineType + workflowConnections { + id + sortOrder + workflowId + statusId + status { + ...status + } + nextStatusId + prevStatusId + prevConnectionId + posX + posY + statusChangingEvents { + statusChangingEventId + workflowConnectionId + statusChangingEvent + } + statusActions { + ...connectionStatusAction } } } diff --git a/apps/frontend/src/graphql/settings/getWorkflows.graphql b/apps/frontend/src/graphql/settings/getWorkflows.graphql index 9621a37c00..bd74930763 100644 --- a/apps/frontend/src/graphql/settings/getWorkflows.graphql +++ b/apps/frontend/src/graphql/settings/getWorkflows.graphql @@ -3,11 +3,9 @@ query getWorkflows($entityType: WorkflowType!) { id name description - workflowConnectionGroups { - connections { - status { - shortCode - } + workflowConnections { + status { + shortCode } } } diff --git a/apps/frontend/src/graphql/settings/moveWorkflowStatus.graphql b/apps/frontend/src/graphql/settings/moveWorkflowStatus.graphql deleted file mode 100644 index 92adb4642f..0000000000 --- a/apps/frontend/src/graphql/settings/moveWorkflowStatus.graphql +++ /dev/null @@ -1,11 +0,0 @@ -mutation moveWorkflowStatus( - $from: IndexWithGroupId! - $to: IndexWithGroupId! - $workflowId: Int! -) { - moveWorkflowStatus( - moveWorkflowStatusInput: { from: $from, to: $to, workflowId: $workflowId } - ) { - id - } -} diff --git a/apps/frontend/src/graphql/settings/updateWorkflow.graphql b/apps/frontend/src/graphql/settings/updateWorkflow.graphql index 4810f0e6f6..19b850f23c 100644 --- a/apps/frontend/src/graphql/settings/updateWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/updateWorkflow.graphql @@ -1,31 +1,30 @@ -mutation updateWorkflow($id: Int!, $name: String!, $description: String!) { +mutation updateWorkflow($id: Int!, $name: String!, $description: String!, $connectionLineType: String) { updateWorkflow( - updatedWorkflowInput: { id: $id, name: $name, description: $description } + updatedWorkflowInput: { id: $id, name: $name, description: $description, connectionLineType: $connectionLineType } ) { id name description - workflowConnectionGroups { - groupId - parentGroupId - connections { + connectionLineType + workflowConnections { + id + sortOrder + workflowId + statusId + status { id - sortOrder - workflowId - statusId - status { - id - name - description - } - nextStatusId - prevStatusId - droppableGroupId - statusChangingEvents { - statusChangingEventId - workflowConnectionId - statusChangingEvent - } + name + description + } + nextStatusId + prevStatusId + prevConnectionId + posX + posY + statusChangingEvents { + statusChangingEventId + workflowConnectionId + statusChangingEvent } } } diff --git a/apps/frontend/src/graphql/settings/updateWorkflowStatus.graphql b/apps/frontend/src/graphql/settings/updateWorkflowStatus.graphql new file mode 100644 index 0000000000..fc2a5a185e --- /dev/null +++ b/apps/frontend/src/graphql/settings/updateWorkflowStatus.graphql @@ -0,0 +1,32 @@ +mutation updateWorkflowStatus( + $id: Int! + $posX: Int + $posY: Int + $prevStatusId: Int + $nextStatusId: Int + $prevConnectionId: Int +) { + updateWorkflowStatus( + updateWorkflowStatusInput: { + id: $id + posX: $posX + posY: $posY + prevStatusId: $prevStatusId + nextStatusId: $nextStatusId + prevConnectionId: $prevConnectionId + } + ) { + id + posX + posY + workflowId + statusId + sortOrder + nextStatusId + prevStatusId + prevConnectionId + status { + ...status + } + } +} diff --git a/apps/frontend/src/graphql/template/fragment.fieldConfig.graphql b/apps/frontend/src/graphql/template/fragment.fieldConfig.graphql index da214c22c1..f3ad627188 100644 --- a/apps/frontend/src/graphql/template/fragment.fieldConfig.graphql +++ b/apps/frontend/src/graphql/template/fragment.fieldConfig.graphql @@ -36,6 +36,7 @@ fragment fieldConfig on FieldConfig { units { ...unit } + numberValueConstraint small_label required tooltip @@ -166,6 +167,10 @@ fragment fieldConfig on FieldConfig { required tooltip readPermissions + minGrade + maxGrade + decimalPoints + nonNumericOptions } ... on TechnicalReviewBasisConfig { diff --git a/apps/frontend/src/graphql/user/getUserExperiments.graphql b/apps/frontend/src/graphql/user/getUserExperiments.graphql index 6d4ee53eb1..5aa37e8e32 100644 --- a/apps/frontend/src/graphql/user/getUserExperiments.graphql +++ b/apps/frontend/src/graphql/user/getUserExperiments.graphql @@ -40,6 +40,9 @@ query getUserExperiments($filter: UserExperimentsFilter) { ...basicUserDetails } } + registrationInvites { + ...invite + } } experimentSafety { ...experimentSafety diff --git a/apps/frontend/src/graphql/visit/createVisit.graphql b/apps/frontend/src/graphql/visit/createVisit.graphql index 93cd910007..81cdfd4a24 100644 --- a/apps/frontend/src/graphql/visit/createVisit.graphql +++ b/apps/frontend/src/graphql/visit/createVisit.graphql @@ -2,11 +2,13 @@ mutation createVisit( $experimentPk: Int! $team: [Int!]! $teamLeadUserId: Int! + $inviteEmails: [String!] ) { createVisit( experimentPk: $experimentPk team: $team teamLeadUserId: $teamLeadUserId + inviteEmails: $inviteEmails ) { ...visit @@ -20,6 +22,11 @@ mutation createVisit( ...basicUserDetails } } + + registrationInvites { + ...invite + } + proposal { ...proposal instruments { diff --git a/apps/frontend/src/graphql/visit/updateVisit.graphql b/apps/frontend/src/graphql/visit/updateVisit.graphql index a9f44f97a9..e976b770bb 100644 --- a/apps/frontend/src/graphql/visit/updateVisit.graphql +++ b/apps/frontend/src/graphql/visit/updateVisit.graphql @@ -2,11 +2,13 @@ mutation updateVisit( $visitId: Int! $team: [Int!] $teamLeadUserId: Int + $inviteEmails: [String!] ) { updateVisit( visitId: $visitId team: $team teamLeadUserId: $teamLeadUserId + inviteEmails: $inviteEmails ) { ...visit @@ -21,6 +23,10 @@ mutation updateVisit( } } + registrationInvites { + ...invite + } + proposal { ...proposal instruments { diff --git a/apps/frontend/src/hooks/experiment/useAllExperiments.ts b/apps/frontend/src/hooks/experiment/useAllExperiments.ts index b7d8ca539a..5d97e2bc43 100644 --- a/apps/frontend/src/hooks/experiment/useAllExperiments.ts +++ b/apps/frontend/src/hooks/experiment/useAllExperiments.ts @@ -26,7 +26,7 @@ export function useAllExperiments( async (componentController?: { unmounted: boolean }) => { setLoading(true); api() - .getAllExperiments({ + .getExperiments({ filter: { callId, }, diff --git a/apps/frontend/src/hooks/fap/useFapProposalsData.ts b/apps/frontend/src/hooks/fap/useFapProposalsData.ts index 043e0f846c..760b2c958c 100644 --- a/apps/frontend/src/hooks/fap/useFapProposalsData.ts +++ b/apps/frontend/src/hooks/fap/useFapProposalsData.ts @@ -16,12 +16,14 @@ export type FapProposals = { loadingFapProposals: boolean; FapProposalsData: FapProposalType[]; setFapProposalsData: Dispatch>; + legacy?: boolean; }; export function useFapProposalsData( fapId: number, callId: number | null, - instrumentId: number | null + instrumentId: number | null, + legacy: boolean = false ): FapProposals { const api = useDataApi(); const [FapProposalsData, setFapProposalsData] = useState( @@ -32,7 +34,7 @@ export function useFapProposalsData( let cancelled = false; setLoadingFapProposals(true); api() - .getFapProposals({ fapId, callId, instrumentId }) + .getFapProposals({ fapId, callId, instrumentId, legacy }) .then((data) => { if (cancelled) { return; diff --git a/apps/frontend/src/hooks/questionary/useSubmitActions.ts b/apps/frontend/src/hooks/questionary/useSubmitActions.ts index 3811ee6f96..8c4f0b3f8a 100644 --- a/apps/frontend/src/hooks/questionary/useSubmitActions.ts +++ b/apps/frontend/src/hooks/questionary/useSubmitActions.ts @@ -1,3 +1,5 @@ +import React, { useContext } from 'react'; + import { fapReviewBasisPreSubmit } from 'components/questionary/questionaryComponents/FapReviewBasis/QuestionaryComponentFapReviewBasis'; import { feedbackBasisPreSubmit } from 'components/questionary/questionaryComponents/FeedbackBasis/QuestionaryComponentFeedbackBasis'; import { genericTemplateBasisPreSubmit } from 'components/questionary/questionaryComponents/GenericTemplateBasis/QuestionaryComponentGenericTemplateBasis'; @@ -6,7 +8,8 @@ import { sampleBasisPreSubmit } from 'components/questionary/questionaryComponen import { shipmentBasisPreSubmit } from 'components/questionary/questionaryComponents/ShipmentBasis/QuestionaryComponentShipmentBasis'; import { technicalReviewBasisPreSubmit } from 'components/questionary/questionaryComponents/TechnicalReviewBasis/QuestionaryComponentTechnicalReviewBasis'; import { visitBasisPreSubmit } from 'components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis'; -import { Answer, DataType, Sdk } from 'generated/sdk'; +import { FeatureContext } from 'context/FeatureContextProvider'; +import { Answer, DataType, FeatureId, Sdk } from 'generated/sdk'; import { Event, QuestionarySubmissionState, @@ -22,13 +25,17 @@ export type SubmitAction = ( ) => Promise; export function usePreSubmitActions() { + const featureContext = useContext(FeatureContext); + const isInvitesEnabled = + featureContext.featuresMap.get(FeatureId.EMAIL_INVITE)?.isEnabled ?? false; + return (answers: Answer[]): SubmitAction[] => { const actions = answers.flatMap((answer) => { switch (answer.question.dataType) { case DataType.SAMPLE_BASIS: return sampleBasisPreSubmit(); case DataType.PROPOSAL_BASIS: - return proposalBasisPreSubmit(); + return proposalBasisPreSubmit(isInvitesEnabled); case DataType.SHIPMENT_BASIS: return shipmentBasisPreSubmit(); case DataType.VISIT_BASIS: diff --git a/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts index 30998dc4a3..05c723d4b6 100644 --- a/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts +++ b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts @@ -6,7 +6,6 @@ import { } from 'components/settings/workflow/WorkflowEditorModel'; import { ConnectionHasActionsInput, - IndexWithGroupId, WorkflowConnection, Workflow, } from 'generated/sdk'; @@ -22,7 +21,8 @@ export function usePersistWorkflowEditorModel() { const updateWorkflowMetadata = async ( id: number, name: string, - description: string + description: string, + connectionLineType: string ) => { return api({ toastSuccessMessage: 'Workflow updated successfully!', @@ -31,6 +31,7 @@ export function usePersistWorkflowEditorModel() { id, name, description, + connectionLineType, }) .then((data) => data.updateWorkflow); }; @@ -55,57 +56,25 @@ export function usePersistWorkflowEditorModel() { const insertNewStatusInWorkflow = async ( workflowId: number, sortOrder: number, - droppableGroupId: string, - parentDroppableGroupId: string, statusId: number, nextStatusId: number, - prevStatusId: number + prevStatusId: number, + posX: number, + posY: number ) => { return api({ toastSuccessMessage: 'Workflow status added successfully' }) .addWorkflowStatus({ workflowId, sortOrder, - droppableGroupId, - parentDroppableGroupId, statusId, nextStatusId, prevStatusId, + posY, + posX, }) .then((data) => data.addWorkflowStatus); }; - const reorderStatusesInWorkflow = async ( - from: IndexWithGroupId, - to: IndexWithGroupId, - workflowId: number - ) => { - return api({ - toastSuccessMessage: 'Workflow statuses reordered successfully', - }) - .moveWorkflowStatus({ - from, - to, - workflowId, - }) - .then((data) => data.moveWorkflowStatus); - }; - - const deleteWorkflowStatus = async ( - statusId: number, - workflowId: number, - sortOrder: number - ) => { - return api({ - toastSuccessMessage: 'Workflow status removed successfully', - }) - .deleteWorkflowStatus({ - statusId, - workflowId, - sortOrder, - }) - .then((data) => data.deleteWorkflowStatus); - }; - const addStatusChangingEventsToConnection = async ( workflowConnectionId: number, statusChangingEvents: string[] @@ -120,6 +89,16 @@ export function usePersistWorkflowEditorModel() { .then((data) => data.addStatusChangingEventsToConnection); }; + const deleteWorkflowConnection = async (connectionId: number) => { + return api({ + toastSuccessMessage: 'Workflow status removed successfully', + }) + .deleteWorkflowConnection({ + connectionId, + }) + .then((data) => data.deleteWorkflowConnection); + }; + const addStatusActionToConnection = async ( statusActions: ConnectionHasActionsInput[], workflowConnection: WorkflowConnection @@ -143,10 +122,15 @@ export function usePersistWorkflowEditorModel() { switch (action.type) { case EventType.UPDATE_WORKFLOW_METADATA_REQUESTED: { - const { id, name, description } = action.payload; + const { id, name, description, connectionLineType } = action.payload; return executeAndMonitorCall(async () => { - const result = await updateWorkflowMetadata(id, name, description); + const result = await updateWorkflowMetadata( + id, + name, + description, + connectionLineType + ); if (result) { dispatch({ @@ -158,65 +142,69 @@ export function usePersistWorkflowEditorModel() { return result; }); } - case EventType.REORDER_WORKFLOW_STATUS_REQUESTED: - const { source, destination } = action.payload; + case EventType.DELETE_WORKFLOW_STATUS_REQUESTED: + // Find the workflow connection to remove based on connectionId + const workflowConnectionToRemove = state.workflowConnections.find( + (connection) => connection.id === action.payload.connectionId + ); - return executeAndMonitorCall(async () => { - try { - const result = await reorderStatusesInWorkflow( - source, - destination, - state.id + if (workflowConnectionToRemove) { + return executeAndMonitorCall(async () => { + const result = await deleteWorkflowConnection( + workflowConnectionToRemove.id ); - return result; - } catch (error) { dispatch({ - type: EventType.REORDER_WORKFLOW_STATUS_FAILED, - payload: { - source: destination, - destination: source, - }, + type: EventType.WORKFLOW_STATUS_DELETED, + payload: action.payload, }); - } - }); - case EventType.DELETE_WORKFLOW_STATUS_REQUESTED: - dispatch({ - type: EventType.WORKFLOW_STATUS_DELETED, - payload: action.payload, - }); - const groupToRemoveFrom = state.workflowConnectionGroups.find( - (workflowConnectionGroup) => - workflowConnectionGroup.groupId === - action.payload.source.droppableId - ); - const workflowConnectionToRemove = - groupToRemoveFrom?.connections[action.payload.source.index]; + return result; + }); + } - if (workflowConnectionToRemove) { - return executeAndMonitorCall(async () => { - try { - const result = await deleteWorkflowStatus( - workflowConnectionToRemove.statusId, - workflowConnectionToRemove.workflowId, - workflowConnectionToRemove.sortOrder - ); - - return result; - } catch (error) { + break; + case EventType.WORKFLOW_STATUS_UPDATE_REQUESTED: { + const { + connectionId, + posX, + posY, + prevStatusId, + nextStatusId, + prevConnectionId, + } = action.payload; + + return executeAndMonitorCall(async () => { + try { + const result = await api({ + toastErrorMessage: 'Failed to update workflow status', + }) + .updateWorkflowStatus({ + id: connectionId, + posX, + posY, + prevStatusId, + nextStatusId, + prevConnectionId, + }) + .then((data) => data.updateWorkflowStatus); + + if (result) { + // Dispatch the result to update the state dispatch({ - type: EventType.WORKFLOW_STATUS_ADDED, - payload: { - ...workflowConnectionToRemove, - source: action.payload.source, - }, + type: EventType.WORKFLOW_STATUS_UPDATED, + payload: result, }); } - }); - } + + return result; + } catch (error) { + console.error('Failed to update workflow status:', error); + } + }); break; + } case EventType.ADD_WORKFLOW_STATUS_REQUESTED: { const { workflowId, @@ -224,10 +212,11 @@ export function usePersistWorkflowEditorModel() { statusId, nextStatusId, prevStatusId, - parentDroppableGroupId, - droppableGroupId, + posX, + posY, } = action.payload; + // Immediately add to state so it shows up in the UI dispatch({ type: EventType.WORKFLOW_STATUS_ADDED, payload: { @@ -240,29 +229,27 @@ export function usePersistWorkflowEditorModel() { const result = await insertNewStatusInWorkflow( workflowId, sortOrder, - droppableGroupId, - parentDroppableGroupId, statusId, nextStatusId, - prevStatusId + prevStatusId, + posX, + posY ); + // Update the connection with the real ID from the API dispatch({ type: EventType.WORKFLOW_STATUS_UPDATED, - payload: { - ...action.payload, - id: result.id, - }, + payload: { ...action.payload, ...result }, }); return result; } catch (error) { + // Remove from state if API call failed dispatch({ type: EventType.WORKFLOW_STATUS_DELETED, - payload: { - source: { index: sortOrder, droppableId: droppableGroupId }, - }, + payload: { ...action.payload }, }); + throw error; } }); } @@ -307,6 +294,22 @@ export function usePersistWorkflowEditorModel() { return result; }); } + case EventType.DELETE_WORKFLOW_CONNECTION_REQUESTED: { + const { connectionId } = action.payload; + + return executeAndMonitorCall(async () => { + const result = await deleteWorkflowConnection(connectionId); + + dispatch({ + type: EventType.WORKFLOW_CONNECTION_DELETED, + payload: { + connectionId, + }, + }); + + return result; + }); + } default: break; } diff --git a/apps/frontend/src/utils/mathFunctions.ts b/apps/frontend/src/utils/mathFunctions.ts index 95b9462202..178d48e5e1 100644 --- a/apps/frontend/src/utils/mathFunctions.ts +++ b/apps/frontend/src/utils/mathFunctions.ts @@ -32,5 +32,5 @@ export const standardDeviation = (numbers: number[]) => { export const getGradesFromReviews = (reviews: Review[]) => reviews - .map((review) => review.grade) + .map((review) => (!!review.grade ? parseFloat(review.grade) : null)) .filter((grade): grade is number => typeof grade === 'number'); diff --git a/apps/frontend/src/utils/workflowInitialStatuses.ts b/apps/frontend/src/utils/workflowInitialStatuses.ts new file mode 100644 index 0000000000..974c56df85 --- /dev/null +++ b/apps/frontend/src/utils/workflowInitialStatuses.ts @@ -0,0 +1 @@ +export const WORKFLOW_INITIAL_STATUSES = ['DRAFT', 'AWAITING_ESF']; diff --git a/package-lock.json b/package-lock.json index a0d44794e9..b20b0fcc66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "ISC", "dependencies": { "concurrently": "^9.0.1", - "wait-on": "^8.0.0" + "wait-on": "^9.0.1" }, "devDependencies": { "husky": "^9.0.10", @@ -66,11 +66,10 @@ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" }, "node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.1.tgz", + "integrity": "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==", "dev": true, - "license": "MIT", "dependencies": { "environment": "^1.0.0" }, @@ -109,9 +108,9 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz", - "integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -173,7 +172,6 @@ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, - "license": "MIT", "dependencies": { "restore-cursor": "^5.0.0" }, @@ -185,28 +183,26 @@ } }, "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.0.tgz", + "integrity": "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==", "dev": true, - "license": "MIT", "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", - "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -214,37 +210,27 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", - "dev": true, - "license": "MIT" - }, "node_modules/cli-truncate/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", "dev": true, - "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", + "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -288,8 +274,7 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/combined-stream": { "version": "1.0.8", @@ -303,11 +288,10 @@ } }, "node_modules/commander": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", - "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", + "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", "dev": true, - "license": "MIT", "engines": { "node": ">=20" } @@ -335,24 +319,6 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -384,7 +350,6 @@ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -445,8 +410,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fill-range": { "version": "7.1.1", @@ -461,9 +425,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -511,11 +475,10 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.1.tgz", - "integrity": "sha512-R1QfovbPsKmosqTnPoRFiJ7CF9MLRgb53ChvMZm+r4p76/+8yKDy17qLL2PKInORy2RkZZekuK0efYgmzTkXyQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -662,32 +625,16 @@ "node": ">= 20" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, "node_modules/lint-staged": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.6.tgz", - "integrity": "sha512-U4kuulU3CKIytlkLlaHcGgKscNfJPNTiDF2avIUGFCv7K95/DCYQ7Ra62ydeRWmgQGg9zJYw2dzdbztwJlqrow==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.4.tgz", + "integrity": "sha512-Pkyr/wd90oAyXk98i/2KwfkIhoYQUMtss769FIT9hFM5ogYZwrk+GRE46yKXSg2ZGhcJ1p38Gf5gmI5Ohjg2yg==", "dev": true, - "license": "MIT", "dependencies": { - "chalk": "^5.6.0", - "commander": "^14.0.0", - "debug": "^4.4.1", - "lilconfig": "^3.1.3", - "listr2": "^9.0.3", + "commander": "^14.0.1", + "listr2": "^9.0.4", "micromatch": "^4.0.8", - "nano-spawn": "^1.0.2", + "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" @@ -702,27 +649,13 @@ "url": "https://opencollective.com/lint-staged" } }, - "node_modules/lint-staged/node_modules/chalk": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", - "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/listr2": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.3.tgz", - "integrity": "sha512-0aeh5HHHgmq1KRdMMDHfhMWQmIT/m7nRDTlxlFqni2Sp0had9baqsjJRvDGdlvgd6NmPE0nPloOipiQJGFtTHQ==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.4.tgz", + "integrity": "sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==", "dev": true, - "license": "MIT", "dependencies": { - "cli-truncate": "^4.0.0", + "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", @@ -734,11 +667,10 @@ } }, "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", - "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -747,11 +679,10 @@ } }, "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -763,15 +694,13 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/listr2/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, - "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -785,11 +714,10 @@ } }, "node_modules/listr2/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -801,11 +729,10 @@ } }, "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -828,7 +755,6 @@ "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, - "license": "MIT", "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", @@ -844,11 +770,10 @@ } }, "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", - "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -857,11 +782,10 @@ } }, "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -873,48 +797,13 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } + "dev": true }, "node_modules/log-update/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, - "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -928,11 +817,10 @@ } }, "node_modules/log-update/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -944,11 +832,10 @@ } }, "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -1006,7 +893,6 @@ "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -1022,19 +908,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/nano-spawn": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", - "integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", + "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", "dev": true, - "license": "MIT", "engines": { "node": ">=20.17" }, @@ -1047,7 +925,6 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, - "license": "MIT", "dependencies": { "mimic-function": "^5.0.0" }, @@ -1100,7 +977,6 @@ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, - "license": "MIT", "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" @@ -1116,8 +992,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/rxjs": { "version": "7.8.2", @@ -1144,7 +1019,6 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "ISC", "engines": { "node": ">=14" }, @@ -1153,28 +1027,26 @@ } }, "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -1183,13 +1055,15 @@ } }, "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, - "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -1268,11 +1142,11 @@ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" }, "node_modules/wait-on": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", - "integrity": "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.1.tgz", + "integrity": "sha512-noeCAI+XbqWMXY23sKril0BSURhuLYarkVXwJv1uUWwoojZJE7pmX3vJ7kh7SZaNgPGzfsCSQIZM/AGvu0Q9pA==", "dependencies": { - "axios": "^1.12.1", + "axios": "^1.12.2", "joi": "^18.0.1", "lodash": "^4.17.21", "minimist": "^1.2.8", @@ -1282,7 +1156,7 @@ "wait-on": "bin/wait-on" }, "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" } }, "node_modules/wrap-ansi": { @@ -1390,9 +1264,9 @@ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" }, "ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.1.tgz", + "integrity": "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==", "dev": true, "requires": { "environment": "^1.0.0" @@ -1417,9 +1291,9 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "axios": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz", - "integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "requires": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -1473,42 +1347,35 @@ } }, "cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.0.tgz", + "integrity": "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==", "dev": true, "requires": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" }, "dependencies": { "ansi-regex": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", - "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", - "dev": true - }, - "emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true }, "string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", "dev": true, "requires": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", + "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "requires": { "ansi-regex": "^6.0.1" @@ -1554,9 +1421,9 @@ } }, "commander": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", - "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", + "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", "dev": true }, "concurrently": { @@ -1572,15 +1439,6 @@ "yargs": "17.7.2" } }, - "debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "requires": { - "ms": "^2.1.3" - } - }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1657,9 +1515,9 @@ } }, "follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==" + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" }, "form-data": { "version": "4.0.4", @@ -1684,9 +1542,9 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, "get-east-asian-width": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.1.tgz", - "integrity": "sha512-R1QfovbPsKmosqTnPoRFiJ7CF9MLRgb53ChvMZm+r4p76/+8yKDy17qLL2PKInORy2RkZZekuK0efYgmzTkXyQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", "dev": true }, "get-intrinsic": { @@ -1777,45 +1635,28 @@ "@standard-schema/spec": "^1.0.0" } }, - "lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true - }, "lint-staged": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.6.tgz", - "integrity": "sha512-U4kuulU3CKIytlkLlaHcGgKscNfJPNTiDF2avIUGFCv7K95/DCYQ7Ra62ydeRWmgQGg9zJYw2dzdbztwJlqrow==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.4.tgz", + "integrity": "sha512-Pkyr/wd90oAyXk98i/2KwfkIhoYQUMtss769FIT9hFM5ogYZwrk+GRE46yKXSg2ZGhcJ1p38Gf5gmI5Ohjg2yg==", "dev": true, "requires": { - "chalk": "^5.6.0", - "commander": "^14.0.0", - "debug": "^4.4.1", - "lilconfig": "^3.1.3", - "listr2": "^9.0.3", + "commander": "^14.0.1", + "listr2": "^9.0.4", "micromatch": "^4.0.8", - "nano-spawn": "^1.0.2", + "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" - }, - "dependencies": { - "chalk": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", - "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", - "dev": true - } } }, "listr2": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.3.tgz", - "integrity": "sha512-0aeh5HHHgmq1KRdMMDHfhMWQmIT/m7nRDTlxlFqni2Sp0had9baqsjJRvDGdlvgd6NmPE0nPloOipiQJGFtTHQ==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.4.tgz", + "integrity": "sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==", "dev": true, "requires": { - "cli-truncate": "^4.0.0", + "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", @@ -1824,15 +1665,15 @@ }, "dependencies": { "ansi-regex": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", - "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true }, "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true }, "emoji-regex": { @@ -1853,18 +1694,18 @@ } }, "strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "requires": { "ansi-regex": "^6.0.1" } }, "wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "requires": { "ansi-styles": "^6.2.1", @@ -1893,15 +1734,15 @@ }, "dependencies": { "ansi-regex": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", - "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true }, "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true }, "emoji-regex": { @@ -1910,25 +1751,6 @@ "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "dev": true }, - "is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "requires": { - "get-east-asian-width": "^1.3.1" - } - }, - "slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dev": true, - "requires": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - } - }, "string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -1941,18 +1763,18 @@ } }, "strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "requires": { "ansi-regex": "^6.0.1" } }, "wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "requires": { "ansi-styles": "^6.2.1", @@ -2001,16 +1823,10 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, "nano-spawn": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", - "integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", + "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", "dev": true }, "onetime": { @@ -2080,26 +1896,29 @@ "dev": true }, "slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "requires": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "dependencies": { "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true }, "is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "requires": { + "get-east-asian-width": "^1.3.1" + } } } }, @@ -2155,11 +1974,11 @@ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" }, "wait-on": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", - "integrity": "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.1.tgz", + "integrity": "sha512-noeCAI+XbqWMXY23sKril0BSURhuLYarkVXwJv1uUWwoojZJE7pmX3vJ7kh7SZaNgPGzfsCSQIZM/AGvu0Q9pA==", "requires": { - "axios": "^1.12.1", + "axios": "^1.12.2", "joi": "^18.0.1", "lodash": "^4.17.21", "minimist": "^1.2.8", diff --git a/package.json b/package.json index 7ec91447d3..6d92f11afa 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ ], "dependencies": { "concurrently": "^9.0.1", - "wait-on": "^8.0.0" + "wait-on": "^9.0.1" }, "devDependencies": { "husky": "^9.0.10", From 687989c42f0297aa607d94c942cb03b4153737f7 Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Thu, 20 Nov 2025 11:35:14 +0000 Subject: [PATCH 2/2] fix: stfc message broker getProposalMessageData node error --- apps/backend/src/datasources/stfc/StfcUserDataSource.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts index 82031914b1..300185b29a 100644 --- a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts @@ -779,6 +779,8 @@ export class StfcUserDataSource implements UserDataSource { async getApprovedProposalVisitorsWithInstitution( proposalPk: number ): Promise<{ user: User; institution: Institution; country: Country }[]> { - throw new Error('Method not implemented.'); + return await postgresUserDataSource.getApprovedProposalVisitorsWithInstitution( + proposalPk + ); } }