diff --git a/backend/api/GQL/Mutation.cs b/backend/api/GQL/Mutation.cs index 6182a95d..973901a0 100644 --- a/backend/api/GQL/Mutation.cs +++ b/backend/api/GQL/Mutation.cs @@ -303,9 +303,16 @@ public ProjectCategory CopyProjectCategory(string newName, string projectCategor } [Authorize(Roles = new[] { adminRole })] - public QuestionTemplate CreateQuestionTemplate(Barrier barrier, Organization organization, string text, string supportNotes, string[] projectCategoryIds) + public QuestionTemplate CreateQuestionTemplate( + Barrier barrier, + Organization organization, + string text, + string supportNotes, + string[] projectCategoryIds, + int newOrder = 0 + ) { - var qt = _questionTemplateService.Create(barrier, organization, text, supportNotes); + var qt = _questionTemplateService.Create(barrier, organization, text, supportNotes, newOrder); foreach (var projectCategoryId in projectCategoryIds) { diff --git a/backend/api/Services/QuestionTemplateService.cs b/backend/api/Services/QuestionTemplateService.cs index ee1a39a2..0791cfe4 100644 --- a/backend/api/Services/QuestionTemplateService.cs +++ b/backend/api/Services/QuestionTemplateService.cs @@ -39,7 +39,8 @@ public QuestionTemplate Create( Barrier barrier, Organization organization, string text, - string supportNotes + string supportNotes, + int newOrder = 0 ) { DateTimeOffset createDate = DateTimeOffset.UtcNow; @@ -48,11 +49,16 @@ string supportNotes .Max(qt => qt.Order) + 1 ; - int newOrder = _context.QuestionTemplates - .Where(qt => qt.Status == Status.Active) - .Where(qt => qt.Barrier == barrier) - .Max(qt => qt.Order) + 1 - ; + // If newOrder == 0, we want to place the new + // question template as the last one in the barrier + if (newOrder == 0) + { + newOrder = _context.QuestionTemplates + .Where(qt => qt.Status == Status.Active) + .Where(qt => qt.Barrier == barrier) + .Max(qt => qt.Order) + 1 + ; + } QuestionTemplate newQuestionTemplate = new QuestionTemplate { @@ -105,10 +111,10 @@ Status status public QuestionTemplate Delete(QuestionTemplate questionTemplate) { - /* ReorderQuestionTemplate gives the question template - * that should be deleted the highest order, and gives the + /* ReorderQuestionTemplate gives the question template + * that should be deleted the highest order, and gives the * remaining question templates the correct order. The - * consquence is that all active question templates are + * consquence is that all active question templates are * ordered correctly. */ ReorderQuestionTemplate(questionTemplate); diff --git a/backend/tests/Services/QuestionTemplateService.cs b/backend/tests/Services/QuestionTemplateService.cs index 60644c3b..71edc9d5 100644 --- a/backend/tests/Services/QuestionTemplateService.cs +++ b/backend/tests/Services/QuestionTemplateService.cs @@ -115,6 +115,28 @@ public void EditQuestionTemplate() Assert.True(updatedQT.ProjectCategories.Count() == 1); } + [Fact] + public void CopyUsingCreate() + { + var service = new QuestionTemplateService(fixture.context); + var questionTemplateToCopy = service.Create( + barrier: Randomize.Barrier(), + organization: Randomize.Organization(), + text: Randomize.String(), + supportNotes: Randomize.String() + ); + + var newQuestionTemplate = service.Create( + barrier: questionTemplateToCopy.Barrier, + organization: questionTemplateToCopy.Organization, + text: questionTemplateToCopy.Text, + supportNotes: questionTemplateToCopy.SupportNotes, + newOrder: questionTemplateToCopy.Order + 1 + ); + + Assert.Equal(questionTemplateToCopy.Order + 1, newQuestionTemplate.Order); + } + [Fact] public void DeleteQuestionTemplate() { diff --git a/frontend/src/views/Admin/AdminQuestionItem.tsx b/frontend/src/views/Admin/AdminQuestionItem.tsx index 96073a17..fa8f0822 100644 --- a/frontend/src/views/Admin/AdminQuestionItem.tsx +++ b/frontend/src/views/Admin/AdminQuestionItem.tsx @@ -16,6 +16,9 @@ interface Props { refetchQuestionTemplates: () => void sortedBarrierQuestions: QuestionTemplate[] projectCategoryQuestions: QuestionTemplate[] + setQuestionTemplateToCopy: (original: QuestionTemplate) => void + setIsAddingQuestion: (val: boolean) => void + questionToScrollIntoView: string } const AdminQuestionItem = ({ @@ -27,6 +30,9 @@ const AdminQuestionItem = ({ refetchQuestionTemplates, sortedBarrierQuestions, projectCategoryQuestions, + setQuestionTemplateToCopy, + setIsAddingQuestion, + questionToScrollIntoView, }: Props) => { const [isInEditmode, setIsInEditmode] = React.useState(false) @@ -63,6 +69,9 @@ const AdminQuestionItem = ({ refetchQuestionTemplates={refetchQuestionTemplates} sortedBarrierQuestions={sortedBarrierQuestions} projectCategoryQuestions={projectCategoryQuestions} + setQuestionTemplateToCopy={setQuestionTemplateToCopy} + setIsAddingQuestion={setIsAddingQuestion} + questionToScrollIntoView={questionToScrollIntoView} /> )} diff --git a/frontend/src/views/Admin/AdminView.tsx b/frontend/src/views/Admin/AdminView.tsx index b2ff9a67..b90614a9 100644 --- a/frontend/src/views/Admin/AdminView.tsx +++ b/frontend/src/views/Admin/AdminView.tsx @@ -1,13 +1,13 @@ import React, { useRef, useState } from 'react' import { Divider, Typography } from '@equinor/eds-core-react' -import { ApolloError, gql, useQuery } from '@apollo/client' +import { ApolloError, gql, useMutation, useQuery } from '@apollo/client' import { TextArea } from '@equinor/fusion-components' import { Box } from '@material-ui/core' import { PROJECT_CATEGORY_FIELDS_FRAGMENT, QUESTIONTEMPLATE_FIELDS_FRAGMENT } from '../../api/fragments' import { Barrier, Organization, ProjectCategory, QuestionTemplate, Status } from '../../api/models' import { barrierToString } from '../../utils/EnumToString' -import { useFilter } from '../../utils/hooks' +import { useEffectNotOnMount, useFilter } from '../../utils/hooks' import { hasOrganization } from '../../utils/QuestionAndAnswerUtils' import { apiErrorMessage } from '../../api/error' @@ -25,17 +25,42 @@ const AdminView = () => { refetch: refetchProjectCategories, } = useProjectCategoriesQuery() const { questions, loading, error, refetch: refetchQuestionTemplates } = useQuestionTemplatesQuery() + const { + createQuestionTemplate, + createdQuestionTemplate, + loading: isCreateQuestionTemplateSaving, + error: createQuestionTemplateSaveError, + } = useCreateQuestionTemplateMutation() const { filter: organizationFilter, onFilterToggled: onOrganizationFilterToggled } = useFilter() + const [prevQuestionsCount, setPrevQuestionsCount] = useState(questions ? questions.length : 0) const [selectedBarrier, setSelectedBarrier] = useState(Barrier.Gm) const [selectedProjectCategory, setSelectedProjectCategory] = useState('all') const [isInAddCategoryMode, setIsInAddCategoryMode] = useState(false) const [isInReorderMode, setIsInReorderMode] = useState(false) const [isAddingQuestion, setIsAddingQuestion] = useState(false) + const [questionTemplateToCopy, setQuestionTemplateToCopy] = useState() const headerRef = useRef(null) const questionTitleRef = useRef(null) + useEffectNotOnMount(() => { + if (!isCreateQuestionTemplateSaving && createQuestionTemplateSaveError === undefined) { + setIsAddingQuestion(false) + } + }, [isCreateQuestionTemplateSaving]) + + useEffectNotOnMount(() => { + if (questions) { + if (questions.length > prevQuestionsCount && questionTitleRef !== null && questionTitleRef.current) { + questionTitleRef.current.scrollIntoView() + setQuestionTemplateToCopy(undefined) + } + + setPrevQuestionsCount(questions.length) + } + }, [questions]) + if (isFetchingProjectCategories) { return <>Loading... } @@ -76,6 +101,11 @@ const AdminView = () => { } } + const onAddNewQuestionClick = () => { + setQuestionTemplateToCopy(undefined) + setIsAddingQuestion(true) + } + const projectCategoryQuestions = questions.filter( q => q.projectCategories @@ -116,7 +146,7 @@ const AdminView = () => { headerRef={headerRef} title={barrierToString(selectedBarrier)} barrierQuestions={barrierQuestions} - setIsAddingQuestion={setIsAddingQuestion} + onAddNewQuestionClick={onAddNewQuestionClick} isInAddCategoryMode={isInAddCategoryMode} setIsInAddCategoryMode={setIsInAddCategoryMode} isInReorderMode={isInReorderMode} @@ -128,10 +158,13 @@ const AdminView = () => { <> )} @@ -149,6 +182,13 @@ const AdminView = () => { refetchQuestionTemplates={refetchQuestionTemplates} sortedBarrierQuestions={sortedBarrierQuestions} projectCategoryQuestions={projectCategoryQuestions} + setQuestionTemplateToCopy={setQuestionTemplateToCopy} + setIsAddingQuestion={setIsAddingQuestion} + questionToScrollIntoView={ + questionTemplateToCopy + ? questionTemplateToCopy.id + : createdQuestionTemplate && createdQuestionTemplate.id + } /> ) })} @@ -224,3 +264,74 @@ const useQuestionTemplatesQuery = (): QuestionTemplatesQueryProps => { refetch, } } + +export interface DataToCreateQuestionTemplate { + barrier: Barrier + organization: Organization + text: string + supportNotes: string + projectCategoryIds: string[] + newOrder: number +} + +interface createQuestionTemplateMutationProps { + createQuestionTemplate: (data: DataToCreateQuestionTemplate) => void + createdQuestionTemplate: QuestionTemplate + loading: boolean + error: ApolloError | undefined +} + +const useCreateQuestionTemplateMutation = (): createQuestionTemplateMutationProps => { + const CREATE_QUESTION_TEMPLATE = gql` + mutation CreateQuestionTemplate( + $barrier: Barrier! + $organization: Organization! + $text: String! + $supportNotes: String! + $projectCategoryIds: [String] + $newOrder: Int! + ) { + createQuestionTemplate( + barrier: $barrier + organization: $organization + text: $text + supportNotes: $supportNotes + projectCategoryIds: $projectCategoryIds + newOrder: $newOrder + ) { + ...QuestionTemplateFields + } + } + ${QUESTIONTEMPLATE_FIELDS_FRAGMENT} + ` + + const [createQuestionTemplateApolloFunc, { loading, data, error }] = useMutation(CREATE_QUESTION_TEMPLATE, { + update(cache, { data: { createQuestionTemplate } }) { + cache.modify({ + fields: { + questionTemplates(existingQuestionTemplates = []) { + const newQuestionTemplateRef = cache.writeFragment({ + id: 'QuestionTemplate:' + createQuestionTemplate.id, + data: createQuestionTemplate, + fragment: QUESTIONTEMPLATE_FIELDS_FRAGMENT, + }) + return [...existingQuestionTemplates, newQuestionTemplateRef] + }, + }, + }) + }, + }) + + const createQuestionTemplate = (data: DataToCreateQuestionTemplate) => { + createQuestionTemplateApolloFunc({ + variables: { ...data }, + }) + } + + return { + createQuestionTemplate, + createdQuestionTemplate: data?.createQuestionTemplate, + loading, + error, + } +} diff --git a/frontend/src/views/Admin/BarrierHeader.tsx b/frontend/src/views/Admin/BarrierHeader.tsx index 444d40a5..40c9715d 100644 --- a/frontend/src/views/Admin/BarrierHeader.tsx +++ b/frontend/src/views/Admin/BarrierHeader.tsx @@ -11,7 +11,7 @@ interface Props { headerRef: RefObject title: string barrierQuestions: QuestionTemplate[] - setIsAddingQuestion: (val: boolean) => void + onAddNewQuestionClick: () => void isInAddCategoryMode: boolean setIsInAddCategoryMode: (val: boolean) => void isInReorderMode: boolean @@ -24,7 +24,7 @@ const BarrierHeader = ({ headerRef, title, barrierQuestions, - setIsAddingQuestion, + onAddNewQuestionClick, isInAddCategoryMode, setIsInAddCategoryMode, isInReorderMode, @@ -53,7 +53,7 @@ const BarrierHeader = ({ + + + + + + + + + ) +} + +export default QuestionTemplateButtons diff --git a/frontend/src/views/Admin/CreateQuestionItem.tsx b/frontend/src/views/Admin/CreateQuestionItem.tsx index 3cdaa2e6..490b1184 100644 --- a/frontend/src/views/Admin/CreateQuestionItem.tsx +++ b/frontend/src/views/Admin/CreateQuestionItem.tsx @@ -1,28 +1,46 @@ -import React, { RefObject } from 'react' +import React, { useState } from 'react' import { MarkdownEditor, SearchableDropdown } from '@equinor/fusion-components' -import { TextField } from '@equinor/eds-core-react' +import { TextField, Typography } from '@equinor/eds-core-react' import { Box } from '@material-ui/core' +import { ApolloError } from '@apollo/client' -import { Barrier, Organization } from '../../api/models' +import { Barrier, Organization, QuestionTemplate } from '../../api/models' import { ErrorIcon, TextFieldChangeEvent } from '../../components/Action/utils' -import { ApolloError, gql, useMutation } from '@apollo/client' import { getOrganizationOptionsForDropdown } from '../helpers' -import { useValidityCheck } from '../../utils/hooks' -import CancelOrSaveQuestion from './Components/CancelOrSaveQuestion' -import { QUESTIONTEMPLATE_FIELDS_FRAGMENT } from '../../api/fragments' +import { useEffectNotOnMount, useValidityCheck } from '../../utils/hooks' +import { DataToCreateQuestionTemplate } from './AdminView' import ErrorMessage from './Components/ErrorMessage' +import CancelOrSaveQuestion from './Components/CancelOrSaveQuestion' interface Props { - setIsAddingQuestion: (isAddingQuestion: boolean) => void barrier: Barrier - questionTitleRef: RefObject - selectedProjectCategory: string + setIsInAddMode: (isAddingQuestion: boolean) => void + createQuestionTemplate: (data: DataToCreateQuestionTemplate) => void + isSaving: boolean + saveError: ApolloError | undefined + selectedProjectCategory?: string + questionTemplateToCopy?: QuestionTemplate } -const CreateQuestionItem = ({ setIsAddingQuestion, barrier, questionTitleRef, selectedProjectCategory }: Props) => { - const [text, setText] = React.useState('') - const [organization, setOrganization] = React.useState(Organization.All) - const [supportNotes, setSupportNotes] = React.useState('') +const CreateQuestionItem = ({ + setIsInAddMode, + barrier, + createQuestionTemplate, + isSaving, + saveError, + selectedProjectCategory, + questionTemplateToCopy, +}: Props) => { + const [text, setText] = React.useState(questionTemplateToCopy?.text || '') + const [organization, setOrganization] = React.useState(questionTemplateToCopy?.organization || Organization.All) + const [supportNotes, setSupportNotes] = React.useState(questionTemplateToCopy?.supportNotes || '') + const [showErrorMessage, setShowErrorMessage] = useState(false) + + useEffectNotOnMount(() => { + if (saveError !== undefined) { + setShowErrorMessage(true) + } + }, [saveError]) const isTextfieldValid = () => { return text.length > 0 @@ -30,11 +48,16 @@ const CreateQuestionItem = ({ setIsAddingQuestion, barrier, questionTitleRef, se const { valueValidity } = useValidityCheck(text, isTextfieldValid) - const { - createQuestionTemplate, - loading: isCreateQuestionTemplateSaving, - error: createQuestionTemplateSaveError, - } = useCreateQuestionTemplateMutation() + const assignProjectCategories = () => { + if (questionTemplateToCopy) { + return questionTemplateToCopy.projectCategories.map(pc => { + return pc.id + }) + } else if (selectedProjectCategory && selectedProjectCategory !== 'all') { + return [selectedProjectCategory] + } + return [] + } const createQuestion = () => { const newQuestion: DataToCreateQuestionTemplate = { @@ -42,18 +65,24 @@ const CreateQuestionItem = ({ setIsAddingQuestion, barrier, questionTitleRef, se organization, text, supportNotes, - projectCategoryIds: selectedProjectCategory !== 'all' ? [selectedProjectCategory] : [], + projectCategoryIds: assignProjectCategories(), + newOrder: questionTemplateToCopy ? questionTemplateToCopy.order + 1 : 0, } - createQuestionTemplate(newQuestion) - setIsAddingQuestion(false) - if (questionTitleRef !== null && questionTitleRef.current !== null) { - questionTitleRef.current.scrollIntoView({ behavior: 'smooth' }) - } + } + + const onCancelCreate = () => { + setIsInAddMode(false) + setShowErrorMessage(false) } return ( + {questionTemplateToCopy && ( + + Copy question: "{questionTemplateToCopy.text}" + + )} @@ -90,14 +119,14 @@ const CreateQuestionItem = ({ setIsAddingQuestion, barrier, questionTitleRef, se /> - {createQuestionTemplateSaveError && ( + {showErrorMessage && ( @@ -107,69 +136,3 @@ const CreateQuestionItem = ({ setIsAddingQuestion, barrier, questionTitleRef, se } export default CreateQuestionItem - -export interface DataToCreateQuestionTemplate { - barrier: Barrier - organization: Organization - text: string - supportNotes: string - projectCategoryIds: string[] -} - -interface createQuestionTemplateMutationProps { - createQuestionTemplate: (data: DataToCreateQuestionTemplate) => void - loading: boolean - error: ApolloError | undefined -} - -const useCreateQuestionTemplateMutation = (): createQuestionTemplateMutationProps => { - const CREATE_QUESTION_TEMPLATE = gql` - mutation CreateQuestionTemplate( - $barrier: Barrier! - $organization: Organization! - $text: String! - $supportNotes: String! - $projectCategoryIds: [String] - ) { - createQuestionTemplate( - barrier: $barrier - organization: $organization - text: $text - supportNotes: $supportNotes - projectCategoryIds: $projectCategoryIds - ) { - ...QuestionTemplateFields - } - } - ${QUESTIONTEMPLATE_FIELDS_FRAGMENT} - ` - - const [createQuestionTemplateApolloFunc, { loading, data, error }] = useMutation(CREATE_QUESTION_TEMPLATE, { - update(cache, { data: { createQuestionTemplate } }) { - cache.modify({ - fields: { - questionTemplates(existingQuestionTemplates = []) { - const newQuestionTemplateRef = cache.writeFragment({ - id: createQuestionTemplate.id, - data: createQuestionTemplate, - fragment: QUESTIONTEMPLATE_FIELDS_FRAGMENT, - }) - return [...existingQuestionTemplates, newQuestionTemplateRef] - }, - }, - }) - }, - }) - - const createQuestionTemplate = (data: DataToCreateQuestionTemplate) => { - createQuestionTemplateApolloFunc({ - variables: { ...data }, - }) - } - - return { - createQuestionTemplate, - loading, - error, - } -} diff --git a/frontend/src/views/Admin/EditableQuestionItem.tsx b/frontend/src/views/Admin/EditableQuestionItem.tsx index ab47bbe7..98c6ad5f 100644 --- a/frontend/src/views/Admin/EditableQuestionItem.tsx +++ b/frontend/src/views/Admin/EditableQuestionItem.tsx @@ -102,8 +102,8 @@ const EditableQuestionItem = ({ setIsInEditmode(false)} questionTitle={text} /> diff --git a/frontend/src/views/Admin/StaticQuestionItem.tsx b/frontend/src/views/Admin/StaticQuestionItem.tsx index 9f046728..07753085 100644 --- a/frontend/src/views/Admin/StaticQuestionItem.tsx +++ b/frontend/src/views/Admin/StaticQuestionItem.tsx @@ -3,7 +3,7 @@ import { tokens } from '@equinor/eds-tokens' import { MarkdownViewer } from '@equinor/fusion-components' import { Button, Chip, Icon, MultiSelect, Tooltip, Typography } from '@equinor/eds-core-react' import { Box } from '@material-ui/core' -import { arrow_down, arrow_up, delete_to_trash, edit, platform, work } from '@equinor/eds-icons' +import { arrow_down, arrow_up, platform, work } from '@equinor/eds-icons' import { ProjectCategory, QuestionTemplate } from '../../api/models' import { organizationToString } from '../../utils/EnumToString' @@ -15,6 +15,33 @@ import { deriveNewSavingState, getNextNextQuestion, getNextQuestion, getPrevQues import { useEffectNotOnMount } from '../../utils/hooks' import ConfirmationDialog from '../../components/ConfirmationDialog' import ErrorMessage from './Components/ErrorMessage' +import QuestionTemplateButtons from './Components/QuestionTemplateButtons' +import styled from 'styled-components' + +const StyledDiv = styled.div<{ isNew: boolean }>` + display: flex; + flex-direction: column; + -webkit-animation: ${({ isNew }) => (isNew ? 'fadein 1s;' : '')}; + animation: ${({ isNew }) => (isNew ? 'fadein 1s;' : '')}; + + @keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @-webkit-keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +` interface Props { question: QuestionTemplate @@ -26,6 +53,9 @@ interface Props { refetchQuestionTemplates: () => void sortedBarrierQuestions: QuestionTemplate[] projectCategoryQuestions: QuestionTemplate[] + setQuestionTemplateToCopy: (original: QuestionTemplate) => void + setIsAddingQuestion: (val: boolean) => void + questionToScrollIntoView: string } const StaticQuestionItem = ({ @@ -38,6 +68,9 @@ const StaticQuestionItem = ({ refetchQuestionTemplates, sortedBarrierQuestions, projectCategoryQuestions, + setQuestionTemplateToCopy, + setIsAddingQuestion, + questionToScrollIntoView, }: Props) => { const [savingState, setSavingState] = useState(SavingState.None) const [isInConfirmDeleteMode, setIsInConfirmDeleteMode] = useState(false) @@ -165,7 +198,7 @@ const StaticQuestionItem = ({ return ( <> - + @@ -174,7 +207,11 @@ const StaticQuestionItem = ({ - + {question.text} @@ -218,24 +255,13 @@ const StaticQuestionItem = ({ - - - - + {isInReorderMode && (