diff --git a/apps/user-office-frontend-e2e/cypress/integration/generic_templates.ts b/apps/user-office-frontend-e2e/cypress/integration/generic_templates.ts index a5c3d7e02b..32a0dbf773 100644 --- a/apps/user-office-frontend-e2e/cypress/integration/generic_templates.ts +++ b/apps/user-office-frontend-e2e/cypress/integration/generic_templates.ts @@ -22,6 +22,12 @@ context('GenericTemplates tests', () => { const addButtonLabel = twoFakes(2); const genericTemplateTitle = faker.lorem.words(3); const genericTemplateQuestionaryQuestion = twoFakes(3); + const genericTemplateTitleAnswers = [ + faker.lorem.words(3), + faker.lorem.words(3), + faker.lorem.words(3), + faker.lorem.words(3), + ]; const proposalWorkflow = { name: faker.random.words(3), description: faker.random.words(5), @@ -150,6 +156,106 @@ context('GenericTemplates tests', () => { } }); }; + const createGenericTemplates = (count: number) => { + const genericTemaplates: number[] = []; + for (let index = 0; index <= count; index++) + cy.createTemplate({ + name: faker.lorem.word(5), + groupId: TemplateGroupId.GENERIC_TEMPLATE, + }).then((result) => { + if (result.createTemplate.template) { + const genericTemplateID = result.createTemplate.template.templateId; + const topicId = + result.createTemplate.template.steps[ + result.createTemplate.template.steps.length - 1 + ].topic.id; + cy.createQuestion({ + categoryId: TemplateCategoryId.GENERIC_TEMPLATE, + dataType: DataType.TEXT_INPUT, + }).then((questionResult) => { + const createdQuestion = questionResult.createQuestion.question; + if (createdQuestion) { + cy.updateQuestion({ + id: createdQuestion.id, + question: faker.lorem.words(5), + naturalKey: faker.lorem.word(5), + config: `{"required":true,"multiline":false}`, + }); + cy.createQuestionTemplateRelation({ + questionId: createdQuestion.id, + templateId: genericTemplateID, + sortOrder: 1, + topicId: topicId, + }); + } + }); + + genericTemaplates.push(genericTemplateID); + } + }); + + return genericTemaplates; + }; + const createProposalTemplateWithSubTemplate = ( + genericSubTemplateIds: number[] + ) => { + cy.createTemplate({ + name: faker.lorem.words(3), + groupId: TemplateGroupId.PROPOSAL, + }).then((result) => { + if (result.createTemplate.template) { + const proposalTemplateId = result.createTemplate.template.templateId; + for (let index = 0; index < genericSubTemplateIds.length - 1; index++) { + cy.createTopic({ + templateId: proposalTemplateId, + sortOrder: index + 1, + }).then((topicResult) => { + if (!topicResult.createTopic.template) { + throw new Error('Can not create topic'); + } + const topicId = + topicResult.createTopic.template.steps[ + topicResult.createTopic.template.steps.length - 1 + ].topic.id; + cy.updateTopic({ + title: faker.lorem.words(4), + templateId: proposalTemplateId, + sortOrder: index + 1, + topicId, + }); + cy.createQuestion({ + categoryId: TemplateCategoryId.PROPOSAL_QUESTIONARY, + dataType: DataType.GENERIC_TEMPLATE, + }).then((questionResult) => { + if (questionResult.createQuestion.question) { + const createdQuestion1Id = + questionResult.createQuestion.question.id; + + cy.updateQuestion({ + id: createdQuestion1Id, + question: genericTemplateQuestion[index], + config: `{"addEntryButtonLabel":"${addButtonLabel[index]}","minEntries":"1","maxEntries":"2","templateId":${genericSubTemplateIds[index]},"templateCategory":"GENERIC_TEMPLATE","required":false,"small_label":""}`, + }); + + cy.createQuestionTemplateRelation({ + questionId: createdQuestion1Id, + templateId: proposalTemplateId, + sortOrder: index + 1, + topicId: topicId, + }); + } + }); + }); + } + cy.updateCall({ + id: initialDBData.call.id, + ...updatedCall, + templateId: proposalTemplateId, + proposalWorkflowId: workflowId, + }); + } + }); + }; beforeEach(() => { // NOTE: Stop the web application and clearly separate the end-to-end tests by visiting the blank about page before each test. @@ -564,4 +670,149 @@ context('GenericTemplates tests', () => { cy.contains(proposalTitle[1]).should('not.exist'); }); }); + + describe('Generic template cloning tests', () => { + beforeEach(() => { + cy.createProposalWorkflow(proposalWorkflow).then((result) => { + if (result.createProposalWorkflow.proposalWorkflow) { + workflowId = result.createProposalWorkflow.proposalWorkflow.id; + const genericTemplates = createGenericTemplates(2); + createProposalTemplateWithSubTemplate(genericTemplates); + cy.createProposal({ callId: initialDBData.call.id }).then( + (result) => { + if (result.createProposal.proposal) { + const proposalPK = result.createProposal.proposal.primaryKey; + const questionarySteps = + result.createProposal.proposal.questionary.steps; + const proposal = result.createProposal.proposal; + cy.updateProposal({ + proposalPk: result.createProposal.proposal.primaryKey, + title: proposalTitle[1], + abstract: faker.lorem.words(3), + proposerId: initialDBData.users.user1.id, + }); + + for (let index = 1; index < questionarySteps.length; index++) { + cy.createGenericTemplate({ + proposalPk: result.createProposal.proposal.primaryKey, + title: genericTemplateTitleAnswers[index - 1], + questionId: + result.createProposal.proposal.questionary.steps[index] + .fields[0].question.id, + templateId: genericTemplates[index - 1], + }).then((templateResult) => { + if ( + templateResult.createGenericTemplate.genericTemplate + ?.questionaryId + ) { + cy.answerTopic({ + isPartialSave: false, + questionaryId: + templateResult.createGenericTemplate.genericTemplate + .questionaryId, + topicId: + templateResult.createGenericTemplate.genericTemplate + .questionary.steps[0].topic.id, + answers: [ + { + questionId: + templateResult.createGenericTemplate + .genericTemplate.questionary.steps[0].fields[1] + .question.id, + value: '{"value":"answer"}', + }, + ], + }); + } + }); + cy.answerTopic({ + questionaryId: proposal.questionaryId, + topicId: questionarySteps[index].topic.id, + isPartialSave: false, + answers: [], + }); + } + cy.cloneProposals({ + callId: initialDBData.call.id, + proposalsToClonePk: [proposalPK], + }); + } + } + ); + } else { + throw new Error('Workflow creation failed'); + } + }); + }); + it('User should be able to modify and submit cloned proposal with generic templates', () => { + cy.login('user1'); + cy.visit('/'); + + cy.finishedLoading(); + + cy.contains(`Copy of ${proposalTitle[1]}`) + .parent() + .find('[aria-label="Edit proposal"]') + .click(); + + cy.finishedLoading(); + + cy.contains('New proposal', { matchCase: true }).click(); + + cy.get('[data-cy=title] input').clear().type(faker.lorem.word(5)); + + cy.get('[data-cy=abstract] textarea').first().type(faker.lorem.words(2)); + + cy.contains('Save and continue').click(); + + cy.finishedLoading(); + + cy.contains(genericTemplateTitleAnswers[0]).click(); + + cy.get('[data-cy=title-input] textarea') + .first() + .clear() + .type(genericTemplateTitleAnswers[2]) + .should('have.value', genericTemplateTitleAnswers[2]) + .blur(); + + cy.get( + '[data-cy=genericTemplate-declaration-modal] [data-cy=save-and-continue-button]' + ).click(); + + cy.finishedLoading(); + + cy.contains(genericTemplateTitleAnswers[2]); + + cy.contains('Save and continue').click(); + + cy.finishedLoading(); + + cy.contains(genericTemplateTitleAnswers[1]).click(); + + cy.get('[data-cy=title-input] textarea') + .first() + .clear() + .type(genericTemplateTitleAnswers[3]) + .should('have.value', genericTemplateTitleAnswers[3]) + .blur(); + + cy.get( + '[data-cy=genericTemplate-declaration-modal] [data-cy=save-and-continue-button]' + ).click(); + + cy.finishedLoading(); + + cy.contains(genericTemplateTitleAnswers[3]); + + cy.contains('Save and continue').click(); + + cy.contains('Submit').click(); + + cy.contains('OK').click(); + + cy.contains(genericTemplateTitleAnswers[2]); + cy.contains(genericTemplateTitleAnswers[3]); + }); + }); }); diff --git a/apps/user-office-frontend-e2e/cypress/support/template.ts b/apps/user-office-frontend-e2e/cypress/support/template.ts index 360cb29f4e..7f1c2d6a8e 100644 --- a/apps/user-office-frontend-e2e/cypress/support/template.ts +++ b/apps/user-office-frontend-e2e/cypress/support/template.ts @@ -14,11 +14,13 @@ import { CreateTemplateMutation, CreateTemplateMutationVariables, CreateTopicMutation, + UpdateTopicMutation, CreateTopicMutationVariables, UpdateQuestionMutation, UpdateQuestionMutationVariables, UpdateQuestionTemplateRelationSettingsMutation, UpdateQuestionTemplateRelationSettingsMutationVariables, + UpdateTopicMutationVariables, } from '@user-office-software-libs/shared-types'; import { getE2EApi } from './utils'; @@ -43,6 +45,15 @@ const createTopic = ( return cy.wrap(request); }; +const updateTopic = ( + updateTopicInput: UpdateTopicMutationVariables +): Cypress.Chainable => { + const api = getE2EApi(); + const request = api.updateTopic(updateTopicInput); + + return cy.wrap(request); +}; + const answerTopic = ( answerTopicInput: AnswerTopicMutationVariables ): Cypress.Chainable => { @@ -549,6 +560,7 @@ Cypress.Commands.add('createGenericTemplate', createGenericTemplate); Cypress.Commands.add('navigateToTemplatesSubmenu', navigateToTemplatesSubmenu); Cypress.Commands.add('createTopic', createTopic); +Cypress.Commands.add('updateTopic', updateTopic); Cypress.Commands.add('answerTopic', answerTopic); Cypress.Commands.add('createQuestion', createQuestion); diff --git a/apps/user-office-frontend-e2e/cypress/types/template.d.ts b/apps/user-office-frontend-e2e/cypress/types/template.d.ts index ae28a3a526..cf32a456b0 100644 --- a/apps/user-office-frontend-e2e/cypress/types/template.d.ts +++ b/apps/user-office-frontend-e2e/cypress/types/template.d.ts @@ -9,6 +9,7 @@ import { CreateQuestionTemplateRelationMutation, CreateQuestionTemplateRelationMutationVariables, CreateTopicMutation, + UpdateTopicMutation, CreateGenericTemplateMutationVariables, CreateGenericTemplateMutation, AnswerTopicMutationVariables, @@ -279,6 +280,18 @@ declare global { createTopicInput: CreateTopicMutationVariables ) => Cypress.Chainable; + /** + * Updates topic in template + * + * @returns {typeof updateTopic} + * @memberof Chainable + * @example + * cy.updateTopic(updateTopicInput: UpdateTopicMutationVariables) + */ + updateTopic: ( + updateTopicInput: UpdateTopicMutationVariables + ) => Cypress.Chainable; + /** * Answers topic in proposal template * diff --git a/apps/user-office-frontend/src/components/proposal/ProposalContainer.tsx b/apps/user-office-frontend/src/components/proposal/ProposalContainer.tsx index a3c0f4da3b..7ff678dfad 100644 --- a/apps/user-office-frontend/src/components/proposal/ProposalContainer.tsx +++ b/apps/user-office-frontend/src/components/proposal/ProposalContainer.tsx @@ -14,6 +14,7 @@ import { ProposalSubmissionState } from 'models/questionary/proposal/ProposalSub import { ProposalWithQuestionary } from 'models/questionary/proposal/ProposalWithQuestionary'; import { Event, + GENERIC_TEMPLATE_EVENT, QuestionarySubmissionModel, } from 'models/questionary/QuestionarySubmissionState'; import useEventHandlers from 'models/questionary/useEventHandlers'; @@ -61,8 +62,35 @@ export default function ProposalContainer(props: ProposalContainerProps) { draftState.proposal.samples = action.newItems; draftState.isDirty = true; break; - case 'GENERIC_TEMPLATE_ITEMS_MODIFIED': - draftState.proposal.genericTemplates = action.newItems; + case GENERIC_TEMPLATE_EVENT.ITEMS_MODIFIED: + if (action.newItems) { + if (state.proposal.genericTemplates) { + const questionIds = action.newItems.map((value) => value.id); + draftState.proposal.genericTemplates = [ + ...state.proposal.genericTemplates.filter( + (value) => !questionIds.some((id) => id === value.id) + ), + ...action.newItems, + ]; + } else { + draftState.proposal.genericTemplates = action.newItems; + } + } + draftState.isDirty = true; + break; + + case GENERIC_TEMPLATE_EVENT.ITEMS_DELETED: + if (action.newItems) { + if (state.proposal.genericTemplates) { + const questionIds = action.newItems.map((value) => value.id); + draftState.proposal.genericTemplates = [ + ...state.proposal.genericTemplates.filter( + (value) => !questionIds.some((id) => id === value.id) + ), + ]; + action.newItems = []; + } + } draftState.isDirty = true; break; } diff --git a/apps/user-office-frontend/src/components/questionary/questionaryComponents/GenericTemplate/QuestionaryComponentGenericTemplate.tsx b/apps/user-office-frontend/src/components/questionary/questionaryComponents/GenericTemplate/QuestionaryComponentGenericTemplate.tsx index 2d5f6941a3..bf810d507a 100644 --- a/apps/user-office-frontend/src/components/questionary/questionaryComponents/GenericTemplate/QuestionaryComponentGenericTemplate.tsx +++ b/apps/user-office-frontend/src/components/questionary/questionaryComponents/GenericTemplate/QuestionaryComponentGenericTemplate.tsx @@ -14,6 +14,7 @@ import { import { QuestionaryStep, SubTemplateConfig } from 'generated/sdk'; import { GenericTemplateCore } from 'models/questionary/genericTemplate/GenericTemplateCore'; import { GenericTemplateWithQuestionary } from 'models/questionary/genericTemplate/GenericTemplateWithQuestionary'; +import { GENERIC_TEMPLATE_EVENT } from 'models/questionary/QuestionarySubmissionState'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; import withConfirm, { WithConfirmType } from 'utils/withConfirm'; import withPrompt, { WithPromptType } from 'utils/withPrompt'; @@ -92,16 +93,65 @@ function QuestionaryComponentGenericTemplate( {({ field, form }: FieldProps) => { const updateFieldValueAndState = ( - updatedItems: GenericTemplateCore[] | null + updatedItems: GenericTemplateCore[] | null, + dispatchType: GENERIC_TEMPLATE_EVENT ) => { - form.setFieldValue(answerId, updatedItems); + if ( + dispatchType === GENERIC_TEMPLATE_EVENT.ITEMS_DELETED && + updatedItems + ) + form.setFieldValue( + answerId, + field.value.filter((genericTemplate) => { + return !updatedItems.some((value) => { + return value.id === genericTemplate.id; + }); + }) + ); + else { + form.setFieldValue(answerId, updatedItems); + } dispatch({ - type: 'GENERIC_TEMPLATE_ITEMS_MODIFIED', + type: dispatchType, id: answerId, newItems: updatedItems, }); }; + const createGenericTemplate = () => { + if (!state) { + throw new Error( + 'GenericTemplate Declaration is missing proposal context' + ); + } + const proposalPk = state.proposal.primaryKey; + const questionId = props.answer.question.id; + if (proposalPk <= 0 || !questionId) { + throw new Error( + 'GenericTemplate is missing proposal id and/or question id' + ); + } + const templateId = config.templateId; + + if (!templateId) { + throw new Error('GenericTemplate is missing templateId'); + } + + api() + .getBlankQuestionarySteps({ templateId }) + .then((result) => { + const blankSteps = result.blankQuestionarySteps; + if (blankSteps) { + const genericTemplateStub = createGenericTemplateStub( + templateId, + blankSteps, + proposalPk, + questionId + ); + setSelectedGenericTemplate(genericTemplateStub); + } + }); + }; const copyGenericTemplate = (id: number, title: string) => api() .cloneGenericTemplate({ genericTemplateId: id, title: title }) @@ -111,7 +161,10 @@ function QuestionaryComponentGenericTemplate( if (clonedGenericTemplate) { const newStateItems = [...field.value, clonedGenericTemplate]; - updateFieldValueAndState(newStateItems); + updateFieldValueAndState( + newStateItems, + GENERIC_TEMPLATE_EVENT.ITEMS_MODIFIED + ); } }); @@ -120,11 +173,14 @@ function QuestionaryComponentGenericTemplate( .deleteGenericTemplate({ genericTemplateId: id }) .then((response) => { if (!response.deleteGenericTemplate.rejection) { - const newStateItems = field.value.filter( - (genericTemplate) => genericTemplate.id !== id + const deletedStateItems = field.value.filter( + (genericTemplate) => genericTemplate.id === id ); - updateFieldValueAndState(newStateItems); + updateFieldValueAndState( + deletedStateItems, + GENERIC_TEMPLATE_EVENT.ITEMS_DELETED + ); } }); @@ -159,42 +215,7 @@ function QuestionaryComponentGenericTemplate( prefilledAnswer: `Copy of ${item.label}`, })(); }} - onAddNewClick={() => { - // TODO move this into a function like copyGenericTemplate - if (!state) { - throw new Error( - 'GenericTemplate Declaration is missing proposal context' - ); - } - - const proposalPk = state.proposal.primaryKey; - const questionId = props.answer.question.id; - if (proposalPk <= 0 || !questionId) { - throw new Error( - 'GenericTemplate is missing proposal id and/or question id' - ); - } - const templateId = config.templateId; - - if (!templateId) { - throw new Error('GenericTemplate is missing templateId'); - } - - api() - .getBlankQuestionarySteps({ templateId }) - .then((result) => { - const blankSteps = result.blankQuestionarySteps; - if (blankSteps) { - const genericTemplateStub = createGenericTemplateStub( - templateId, - blankSteps, - proposalPk, - questionId - ); - setSelectedGenericTemplate(genericTemplateStub); - } - }); - }} + onAddNewClick={() => createGenericTemplate()} {...props} /> @@ -215,12 +236,18 @@ function QuestionaryComponentGenericTemplate( : genericTemplate ); - updateFieldValueAndState(newStateItems); + updateFieldValueAndState( + newStateItems, + GENERIC_TEMPLATE_EVENT.ITEMS_MODIFIED + ); }} genericTemplateCreated={(newGenericTemplate) => { const newStateItems = [...field.value, newGenericTemplate]; - updateFieldValueAndState(newStateItems); + updateFieldValueAndState( + newStateItems, + GENERIC_TEMPLATE_EVENT.ITEMS_MODIFIED + ); }} genericTemplateEditDone={() => { // refresh all genericTemplates @@ -232,7 +259,10 @@ function QuestionaryComponentGenericTemplate( }, }) .then((result) => { - updateFieldValueAndState(result.genericTemplates); + updateFieldValueAndState( + result.genericTemplates, + GENERIC_TEMPLATE_EVENT.ITEMS_MODIFIED + ); }); setSelectedGenericTemplate(null); diff --git a/apps/user-office-frontend/src/models/questionary/QuestionarySubmissionState.ts b/apps/user-office-frontend/src/models/questionary/QuestionarySubmissionState.ts index d4955b46d9..9976752ecc 100644 --- a/apps/user-office-frontend/src/models/questionary/QuestionarySubmissionState.ts +++ b/apps/user-office-frontend/src/models/questionary/QuestionarySubmissionState.ts @@ -18,6 +18,10 @@ import { getFieldById } from './QuestionaryFunctions'; import { SampleEsiWithQuestionary } from './sampleEsi/SampleEsiWithQuestionary'; import { StepType } from './StepType'; +export enum GENERIC_TEMPLATE_EVENT { + ITEMS_MODIFIED = 'ITEMS_MODIFIED', + ITEMS_DELETED = 'ITEMS_DELETED', +} export type Event = | { type: 'FIELD_CHANGED'; id: string; newValue: any } | { type: 'BACK_CLICKED' } @@ -65,7 +69,7 @@ export type Event = >; } | { - type: 'GENERIC_TEMPLATE_ITEMS_MODIFIED'; + type: GENERIC_TEMPLATE_EVENT; id: string; newItems: Maybe< (GenericTemplateFragment & {