diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6ef99..dc47d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ### Updated ======= +- Updated app/template/[templateId]/section/create page to use Remirror text editors, and checkboxes with info popovers [#187] +- Updated DMPEditor to use a skeleton while the text editors are loading, since it can be slow [#187] - Updated app/template/[templateId]/section/new page to hook it up to backend data, handle errors, and add translations [#189] - Updated app/template/[templateId] page to hook it up to the backend and handle errors and translations[#206] - Updated app/[locale]/template page to hook it up to backend and handle errors and translations[#82] diff --git a/app/[locale]/styleguide/page.tsx b/app/[locale]/styleguide/page.tsx index 3bf5ca3..ea50aca 100644 --- a/app/[locale]/styleguide/page.tsx +++ b/app/[locale]/styleguide/page.tsx @@ -687,6 +687,7 @@ function Page() { + @@ -1073,7 +1074,7 @@ function Page() {

Layout Container

The standard {``} wraps content containers to provide - some common container within the layout container.

+ some common container within the layout container.


@@ -1207,7 +1208,7 @@ function Page() {
                 TODO: Write about this layout here
               
 
-               setDrawerOpen(false) }>
+               setDrawerOpen(false)}>
                 

This is the Drawer Content

@@ -1593,7 +1594,7 @@ function Page() { diff --git a/app/[locale]/template/[templateId]/section/create/__tests__/page.spec.tsx b/app/[locale]/template/[templateId]/section/create/__tests__/page.spec.tsx new file mode 100644 index 0000000..0e78673 --- /dev/null +++ b/app/[locale]/template/[templateId]/section/create/__tests__/page.spec.tsx @@ -0,0 +1,194 @@ +import React from "react"; +import { render, screen, act, fireEvent } from '@/utils/test-utils'; +import { + useAddSectionMutation, + useTagsQuery, +} from '@/generated/graphql'; + +import { axe, toHaveNoViolations } from 'jest-axe'; +import { useParams } from 'next/navigation'; +import { useTranslations as OriginalUseTranslations } from 'next-intl'; +import CreateSectionPage from '../page'; +expect.extend(toHaveNoViolations); + +// Mock the useTemplateQuery hook +jest.mock("@/generated/graphql", () => ({ + useAddSectionMutation: jest.fn(), + useTagsQuery: jest.fn() +})); + +jest.mock('next/navigation', () => ({ + useParams: jest.fn(), +})) + +// Create a mock for scrollIntoView and focus +const mockScrollIntoView = jest.fn(); + +type UseTranslationsType = ReturnType; + +// Mock useTranslations from next-intl +jest.mock('next-intl', () => ({ + useTranslations: jest.fn(() => { + const mockUseTranslations: UseTranslationsType = ((key: string) => key) as UseTranslationsType; + + /*eslint-disable @typescript-eslint/no-explicit-any */ + mockUseTranslations.rich = ( + key: string, + values?: Record + ) => { + // Handle rich text formatting + if (values?.p) { + return values.p(key); // Simulate rendering the `p` tag function + } + return key; + }; + + return mockUseTranslations; + }), +})); + + +const mockTagsData = { + "tags": [ + { + "id": 1, + "description": "The types of data that will be collected along with their formats and estimated volumes.", + "name": "Data description" + }, + { + "id": 2, + "description": "Descriptions naming conventions, metadata standards that will be used along with data dictionaries and glossaries", + "name": "Data organization & documentation" + }, + { + "id": 3, + "description": "Who will have access to the data and how that access will be controlled, how the data will be encrypted and relevant compliance with regulations or standards (e.g. HIPAA, GDPR)", + "name": "Security & privacy" + }, + { + "id": 4, + "description": "Ethical considerations during data collection, use or sharing and how informed consent will be obtained from participants", + "name": "Ethical considerations" + }, + { + "id": 5, + "description": "Training that will be provided to team members on data management practices and support for data issues", + "name": "Training & support" + }, + { + "id": 6, + "description": "Policies and procedures for how the data will be shared with collaborators and/or the public, restrictions to access and the licenses and permissions used", + "name": "Data sharing" + }, + { + "id": 7, + "description": "Where the data will be stored, the backup strategy and frequency and how long it will be retained", + "name": "Data storage & backup" + }, + { + "id": 8, + "description": "Methods used to ensure data quality and integrity and any procedures used for validation", + "name": "Data quality & integrity" + }, + { + "id": 9, + "description": "Desriptions of the project team members and their roles", + "name": "Roles & responsibilities" + }, + { + "id": 10, + "description": "Description of the budget available for data collection, use and preservation including software licensing, personnel and storage costs", + "name": "Budget" + }, + { + "id": 11, + "description": "How the data will be collected or generated, primary and secondary sources that will be used and any instruments that will be used", + "name": "Data collection" + } + ] +}; + +describe("CreateSectionPage", () => { + beforeEach(() => { + HTMLElement.prototype.scrollIntoView = mockScrollIntoView; + window.scrollTo = jest.fn(); // Called by the wrapping PageHeader + const mockTemplateId = 123; + const mockUseParams = useParams as jest.Mock; + + // Mock the return value of useParams + mockUseParams.mockReturnValue({ templateId: `${mockTemplateId}` }); + (useTagsQuery as jest.Mock).mockReturnValue({ + data: mockTagsData, + loading: true, + error: null, + }); + }); + + it("should render correct fields", async () => { + (useAddSectionMutation as jest.Mock).mockReturnValue([ + jest.fn().mockResolvedValueOnce({ data: { key: 'value' } }), // Correct way to mock a resolved promise + { loading: false, error: undefined }, + ]); + + await act(async () => { + render( + + ); + }); + + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toHaveTextContent('title'); + const editQuestionTab = screen.getByRole('tab', { name: 'tabs.editSection' }); + expect(editQuestionTab).toBeInTheDocument(); + const editOptionsTab = screen.getByRole('tab', { name: 'tabs.options' }); + expect(editOptionsTab).toBeInTheDocument(); + const editLogicTab = screen.getByRole('tab', { name: 'tabs.logic' }); + expect(editLogicTab).toBeInTheDocument(); + const sectionNameEditor = screen.getByRole('textbox', { name: /sectionName/i }); + expect(sectionNameEditor).toBeInTheDocument(); + const sectionIntroductionEditor = screen.getByRole('textbox', { name: /sectionIntroduction/i }); + expect(sectionIntroductionEditor).toBeInTheDocument(); + const sectionRequirementsEditor = screen.getByRole('textbox', { name: /sectionRequirements/i }); + expect(sectionRequirementsEditor).toBeInTheDocument(); + const sectionGuidanceEditor = screen.getByRole('textbox', { name: /sectionGuidance/i }); + expect(sectionGuidanceEditor).toBeInTheDocument(); + const tagsHeader = screen.getByText('labels.bestPracticeTags'); + expect(tagsHeader).toBeInTheDocument(); + const checkboxLabels = screen.getAllByTestId('checkboxLabel'); + expect(checkboxLabels).toHaveLength(11); + }); + + it('should display error when no value is entered in section name field', async () => { + (useAddSectionMutation as jest.Mock).mockReturnValue([ + jest.fn().mockResolvedValueOnce({ data: { key: 'value' } }), // Correct way to mock a resolved promise + { loading: false, error: undefined }, + ]); + + await act(async () => { + render( + + ); + }); + + const searchButton = screen.getByRole('button', { name: /button.createSection/i }); + fireEvent.click(searchButton); + + const errorMessage = screen.getByRole('alert'); + expect(errorMessage).toBeInTheDocument(); + expect(errorMessage).toHaveTextContent('messages.fieldLengthValidation'); + }) + + it('should pass axe accessibility test', async () => { + (useAddSectionMutation as jest.Mock).mockReturnValue([ + jest.fn().mockResolvedValueOnce({ data: { key: 'value' } }), + ]); + + const { container } = render( + + ); + await act(async () => { + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); +}); \ No newline at end of file diff --git a/app/[locale]/template/[templateId]/section/create/page.tsx b/app/[locale]/template/[templateId]/section/create/page.tsx new file mode 100644 index 0000000..4671cde --- /dev/null +++ b/app/[locale]/template/[templateId]/section/create/page.tsx @@ -0,0 +1,417 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import { ApolloError } from '@apollo/client'; +import { useParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { + Breadcrumb, + Breadcrumbs, + Button, + Checkbox, + CheckboxGroup, + Form, + Label, + Link, + Tab, + TabList, + TabPanel, + Tabs, + DialogTrigger, + OverlayArrow, + Popover, + Dialog, +} from "react-aria-components"; +// GraphQL queries and mutations +import { + useAddSectionMutation, + useTagsQuery, +} from '@/generated/graphql'; + +//Components +import { + LayoutContainer, + ContentContainer, +} from '@/components/Container'; +import { DmpIcon } from "@/components/Icons"; +import PageHeader from "@/components/PageHeader"; +import { DmpEditor } from "@/components/Editor"; + +import styles from './sectionCreate.module.scss'; +interface FormInterface { + sectionName: string; + sectionIntroduction: string; + sectionRequirements: string; + sectionGuidance: string; + sectionTags?: TagsInterface[]; +} + +interface FormErrorsInterface { + sectionName: string; +} + +interface TagsInterface { + id?: number | null; + name: string; + description?: string | null; +} + +const CreateSectionPage: React.FC = () => { + + // Get templateId param + const params = useParams(); + const { templateId } = params; // From route /template/:templateId/section/create + + //For scrolling to error in page + const errorRef = useRef(null); + + //For scrolling to top of page + const topRef = useRef(null); + + //Set initial Rich Text Editor field values + const [sectionNameContent, setSectionNameContent] = useState(''); + const [sectionIntroductionContent, setSectionIntroductionContent] = useState(''); + const [sectionRequirementsContent, setSectionRequirementsContent] = useState(''); + const [sectionGuidanceContent, setSectionGuidanceContent] = useState(''); + + //Keep form field values in state + const [formData, setFormData] = useState({ + sectionName: '', + sectionIntroduction: '', + sectionRequirements: '', + sectionGuidance: '', + sectionTags: [] + }) + + // Keep track of which checkboxes have been selected + const [selectedTags, setSelectedTags] = useState([]); + + // Save errors in state to display on page + const [errors, setErrors] = useState([]); + const [successMessage, setSuccessMessage] = useState(''); + const [fieldErrors, setFieldErrors] = useState({ + sectionName: '', + sectionIntroduction: '', + sectionRequirements: '', + sectionGuidance: '', + }); + + // localization keys + const Global = useTranslations('Global'); + const CreateSectionPage = useTranslations('CreateSectionPage'); + + //Store selection of tags in state + const [tags, setTags] = useState([]); + + // Initialize user addSection mutation + const [addSectionMutation] = useAddSectionMutation(); + + // Query for all tags + const { data: tagsData } = useTagsQuery(); + + // Client-side validation of fields + const validateField = (name: string, value: string | string[] | undefined) => { + let error = ''; + switch (name) { + case 'sectionName': + if (!value || value.length <= 2) { + error = CreateSectionPage('messages.fieldLengthValidation') + } + break; + } + + setFieldErrors(prevErrors => ({ + ...prevErrors, + [name]: error + })); + if (error.length > 1) { + setErrors(prev => [...prev, error]); + } + + return error; + } + + // Check whether form is valid before submitting + const isFormValid = (): boolean => { + // Initialize a flag for form validity + let isValid = true; + let errors: FormInterface = { + sectionName: '', + sectionIntroduction: '', + sectionRequirements: '', + sectionGuidance: '', + }; + + // Iterate over formData to validate each field + Object.keys(formData).forEach((key) => { + const name = key as keyof FormErrorsInterface; + const value = formData[name]; + + // Call validateField to update errors for each field + const error = validateField(name, value); + if (error) { + isValid = false; + errors[name] = error; + } + }); + return isValid; + }; + + const clearAllFieldErrors = () => { + //Remove all field errors + setFieldErrors({ + sectionName: '', + sectionIntroduction: '', + sectionRequirements: '', + sectionGuidance: '', + sectionTags: [] + }); + } + + const scrollToTop = (ref: React.MutableRefObject) => { + if (ref.current) { + ref.current.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + } + + // Make GraphQL mutation request to create section + const createSection = async () => { + try { + await addSectionMutation({ + variables: { + input: { + templateId: Number(templateId), + name: sectionNameContent, + introduction: sectionIntroductionContent, + requirements: sectionRequirementsContent, + guidance: sectionGuidanceContent, + displayOrder: 1, + tags: selectedTags + } + } + }) + } catch (error) { + if (error instanceof ApolloError) { + setErrors(prevErrors => [...prevErrors, error.message]); + } else { + setErrors(prevErrors => [...prevErrors, CreateSectionPage('messages.errorCreatingSection')]); + } + } + }; + + // Handle changes to tag checkbox selection + const handleCheckboxChange = (tag: TagsInterface) => { + setSelectedTags((prevTags) => { + // Check if the tag is already selected + const isAlreadySelected = prevTags.some((selectedTag) => selectedTag.id === tag.id); + + if (isAlreadySelected) { + // If already selected, remove it + return prevTags.filter((selectedTag) => selectedTag.id !== tag.id); + } else { + // If not selected, add it + return [...prevTags, tag]; + } + }); + }; + + // Handle form submit + const handleFormSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + setSuccessMessage(''); + setFormData({ + sectionName: sectionNameContent, + sectionIntroduction: '', + sectionRequirements: '', + sectionGuidance: '', + sectionTags: selectedTags + }) + + clearAllFieldErrors(); + + if (isFormValid()) { + // Create new section + await createSection(); + setErrors([]); // Clear errors on successful submit + // For now, scroll to top of page to provide some feedback that form was successfully submitted + // TODO: add flash/toast message to signal to user that form was successfully submitted + setSuccessMessage(CreateSectionPage('messages.success')) + scrollToTop(topRef); + } + }; + + useEffect(() => { + if (tagsData?.tags) { + // Remove __typename field from the tags selection + /*eslint-disable @typescript-eslint/no-unused-vars*/ + const cleanedData = tagsData.tags.map(({ __typename, ...fields }) => fields); + setTags(cleanedData); + } + }, [tagsData]) + + // If errors when submitting publish form, scroll them into view + useEffect(() => { + if (errors.length > 0) { + scrollToTop(errorRef); + } + }, [errors]); + + useEffect(() => { + setFormData({ + ...formData, + sectionName: sectionNameContent, + sectionIntroduction: sectionIntroductionContent, + sectionRequirements: sectionRequirementsContent, + sectionGuidance: sectionGuidanceContent + }); + }, [sectionNameContent, sectionIntroductionContent, sectionRequirementsContent, sectionGuidanceContent]) + + + return ( + <> + + {Global('breadcrumbs.home')} + {Global('breadcrumbs.templates')} + {Global('breadcrumbs.template')} + {Global('breadcrumbs.editSection')} + + } + actions={null} + className="" + /> + + + +
+
+ + {errors && errors.length > 0 && +
+ {errors.map((error, index) => ( +

{error}

+ ))} +
+ } + + {successMessage && ( +
+

{successMessage}

+
+ )} + + + {CreateSectionPage('tabs.editSection')} + {CreateSectionPage('tabs.options')} + {CreateSectionPage('tabs.logic')} + + +
+ + + + + + + + + + + + + + + {CreateSectionPage('helpText.bestPracticeTagsDesc')} +
+ {tags && tags.map(tag => { + const id = (tag.id)?.toString(); + return ( + handleCheckboxChange(tag)} + > +
+ +
+ +
+
{tag.name}
+ + + + + + + + + +
+ {tag.description} +
+
+
+
+
+
+
+ ) + })} +
+
+ + + +
+ +

{CreateSectionPage('tabs.options')}

+
+ +

{CreateSectionPage('tabs.logic')}

+
+
+
+
+
+
+ + ); +} + +export default CreateSectionPage; diff --git a/app/[locale]/template/[templateId]/section/create/sectionCreate.module.scss b/app/[locale]/template/[templateId]/section/create/sectionCreate.module.scss new file mode 100644 index 0000000..4f1236c --- /dev/null +++ b/app/[locale]/template/[templateId]/section/create/sectionCreate.module.scss @@ -0,0 +1,47 @@ +.checkboxGroup { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; /* Add spacing between checkboxes */ + margin-bottom: 2rem; + font-size: var(--fs-base); + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + + .checkboxLabel { + font-size: var(--fs-base); + } + .icon { + svg { + width: 16px; + height:16px; + fill: var(--brand-primary); + } + } + + .popoverBtn { + display: inline-block; + + background:none; + padding: 0 0 0 0.03rem; + + &:hover { + background: none; + } + } + + label { + margin-bottom: 0; + } +} + +.checkboxWrapper { + display: inline; +} + +.checkboxWrapper > div:first-child { + display: inline; +} + + diff --git a/components/Editor/Editor.scss b/components/Editor/Editor.scss deleted file mode 100644 index 31a88b6..0000000 --- a/components/Editor/Editor.scss +++ /dev/null @@ -1,20 +0,0 @@ - -.dmp-editor { - position: relative; - - .react-aria-Toolbar { - padding: 0.5em 0.5em; - background-color: white; - border-radius:5px 5px 0 0; - border: 2px solid var(--gray-100); - } - - .remirror-editor.ProseMirror { - padding: 0.5em 1.5em 0.5em 1em; - background-color: white; - max-height: 400px; - overflow-y: auto; - border-radius: 0 0 5px 5px; - border: 2px solid var(--gray-100); - } -} diff --git a/components/Editor/EditorSkeleton.tsx b/components/Editor/EditorSkeleton.tsx new file mode 100644 index 0000000..aaf4029 --- /dev/null +++ b/components/Editor/EditorSkeleton.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import styles from './editor.module.scss'; + +export const EditorSkeleton: React.FC = () => { + return ( +
+
+
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/components/Editor/editor.module.scss b/components/Editor/editor.module.scss new file mode 100644 index 0000000..e015264 --- /dev/null +++ b/components/Editor/editor.module.scss @@ -0,0 +1,53 @@ + +.dmpEditor { + position: relative; + margin-bottom: 2rem; + + .dmpEditorToolbar { + padding: 0.5em 0.5em; + background-color: white; + border-radius:5px 5px 0 0; + border: 2px solid var(--gray-100); + } + + .Prosemirror, + .editorProsemirror { + padding: 0.5em 1.5em 0.5em 1em; + background-color: white; + max-height: 400px; + overflow-y: auto; + border-radius: 0 0 5px 5px; + border: 2px solid var(--gray-100); + } +} + +.skeleton { + display: flex; + flex-direction: column; + gap: 8px; + background: #f0f0f0; + border: 1px solid #ccc; + border-radius: 4px; + padding: 16px; + min-height: 122px; + margin-bottom: 2rem; +} + +.skeletonToolbar { + display: flex; + gap: 8px; +} + +.skeletonButton { + width: 32px; + height: 32px; + background: #ddd; + border-radius: 4px; +} + +.skeletonEditor { + flex-grow: 1; + background: #eaeaea; + border: 1px solid #ccc; + border-radius: 4px; +} diff --git a/components/Editor/index.tsx b/components/Editor/index.tsx index d7cfc0e..4231320 100644 --- a/components/Editor/index.tsx +++ b/components/Editor/index.tsx @@ -1,6 +1,7 @@ 'use client' -import React, { useEffect, useState } from 'react'; +import React, { memo, useEffect, useState, useMemo } from 'react'; +import { EditorSkeleton } from './EditorSkeleton'; import { EditorComponent, @@ -31,7 +32,7 @@ import { } from 'remirror/extensions'; import 'remirror/styles/all.css'; -import './Editor.scss'; +import styles from './editor.module.scss'; import { DmpIcon } from '@/components/Icons'; import { @@ -108,7 +109,7 @@ const EditorToolbar = () => { const active = useActive(); return ( - + { interface DmpEditorProps { content: string; setContent: (newContent: string) => void; + id?: string; + error?: string; + labelId?: string; } -export function DmpEditor({ content, setContent }: DmpEditorProps) { +const MemoizedEditorToolbar = memo(EditorToolbar); + +export const DmpEditor = memo(({ content, setContent, error, id, labelId }: DmpEditorProps) => { const [isMounted, setIsMounted] = useState(false); + const extensions = useMemo(() => () => [ + new BoldExtension({}), + new ItalicExtension(), + new UnderlineExtension({}), + new LinkExtension({ autoLink: true }), + new BulletListExtension({}), + new OrderedListExtension(), + new TableExtension({}), + new AnnotationExtension({}), + ], []); + + const { manager, state, setState } = useRemirror({ - extensions: () => [ - new BoldExtension({}), - new ItalicExtension(), - new UnderlineExtension({}), - new LinkExtension({ autoLink: true }), - new BulletListExtension({}), - new OrderedListExtension(), - new TableExtension({}), - new AnnotationExtension({}), - ], + extensions, content, @@ -223,27 +232,37 @@ export function DmpEditor({ content, setContent }: DmpEditorProps) { setIsMounted(true) }, []) - const handleChange = (newState: EditorState) => { - const html = prosemirrorNodeToHtml(newState.doc); + const handleChange = ((newState: EditorState) => { + //prosemirror was adding an empty

when no data was passed, so we need to remove it here + const html = prosemirrorNodeToHtml(newState.doc).replaceAll('

', ''); setContent(html); setState(newState); - } + }); + if (!isMounted) { - return null; // or a loading indicator + // Show the skeleton loader because loading of RTE can be slow and cause shifting on page without it + return ; } return ( -
+
handleChange(state)} + attributes={{ + 'aria-label': id ?? 'Editor input area', + 'aria-labelledby': labelId ?? '', + 'class': styles.editorProsemirror, + 'id': id ?? '' + }} > - + +
{error}
) -} +}) diff --git a/generated/graphql.tsx b/generated/graphql.tsx index 4dae6dc..43eff78 100644 --- a/generated/graphql.tsx +++ b/generated/graphql.tsx @@ -23,6 +23,38 @@ export type Scalars = { URL: { input: any; output: any; } }; +export type AddProjectContributorInput = { + /** The contributor's affiliation URI */ + affiliationId?: InputMaybe; + /** The contributor's email address */ + email?: InputMaybe; + /** The contributor's first/given name */ + givenName?: InputMaybe; + /** The contributor's ORCID */ + orcid?: InputMaybe; + /** The research project */ + projectId: Scalars['Int']['input']; + /** The roles the contributor has on the research project */ + roles?: InputMaybe>; + /** The contributor's last/sur name */ + surname?: InputMaybe; +}; + +export type AddProjectFunderInput = { + /** The funder URI */ + funder: Scalars['String']['input']; + /** The funder's unique id/url for the call for submissions to apply for a grant */ + funderOpportunityNumber?: InputMaybe; + /** The funder's unique id/url for the research project (normally assigned after the grant has been awarded) */ + funderProjectNumber?: InputMaybe; + /** The funder's unique id/url for the award/grant (normally assigned after the grant has been awarded) */ + grantId?: InputMaybe; + /** The project */ + projectId: Scalars['Int']['input']; + /** The status of the funding resquest */ + status?: InputMaybe; +}; + /** Input for adding a new QuestionCondition */ export type AddQuestionConditionInput = { /** The action to take on a QuestionCondition */ @@ -254,13 +286,64 @@ export enum AffiliationType { Other = 'OTHER' } -export type Contributor = Person & { - __typename?: 'Contributor'; - contributorId?: Maybe; - dmproadmap_affiliation?: Maybe; - mbox?: Maybe; - name: Scalars['String']['output']; - role: Array; +/** An answer to a question on a Data Managament Plan (DMP) */ +export type Answer = { + __typename?: 'Answer'; + /** The answer to the question */ + answerText?: Maybe; + /** The timestamp when the Object was created */ + created?: Maybe; + /** The user who created the Object */ + createdById?: Maybe; + /** Errors associated with the Object */ + errors?: Maybe>; + /** The unique identifer for the Object */ + id?: Maybe; + /** The timestamp when the Object was last modifed */ + modified?: Maybe; + /** The user who last modified the Object */ + modifiedById?: Maybe; + /** The DMP that the answer belongs to */ + plan: Plan; + /** The question in the template the answer is for */ + versionedQuestion: VersionedQuestion; + /** The question in the template the answer is for */ + versionedSection: VersionedSection; +}; + +export type AnswerComment = { + __typename?: 'AnswerComment'; + /** The answer the comment is associated with */ + answer: Answer; + /** The comment */ + commentText: Scalars['String']['output']; + /** The timestamp when the Object was created */ + created?: Maybe; + /** The user who created the Object */ + createdById?: Maybe; + /** Errors associated with the Object */ + errors?: Maybe>; + /** The unique identifer for the Object */ + id?: Maybe; + /** The timestamp when the Object was last modifed */ + modified?: Maybe; + /** The user who last modified the Object */ + modifiedById?: Maybe; +}; + +/** The result of the findCollaborator query */ +export type CollaboratorSearchResult = { + __typename?: 'CollaboratorSearchResult'; + /** The collaborator's affiliation */ + affiliation?: Maybe; + /** The collaborator's first/given name */ + givenName?: Maybe; + /** The unique identifer for the Object */ + id?: Maybe; + /** The collaborator's ORCID */ + orcid?: Maybe; + /** The collaborator's last/sur name */ + surName?: Maybe; }; export type ContributorRole = { @@ -302,40 +385,34 @@ export type ContributorRoleMutationResponse = { success: Scalars['Boolean']['output']; }; -export type DmpRoadmapAffiliation = { - __typename?: 'DmpRoadmapAffiliation'; - affiliation_id?: Maybe; - name: Scalars['String']['output']; -}; - -export type Dmsp = { - __typename?: 'Dmsp'; - contact: PrimaryContact; - contributor?: Maybe>>; - created?: Maybe; - description?: Maybe; - dmp_id: DmspIdentifier; - dmproadmap_featured?: Maybe; - dmproadmap_related_identifiers?: Maybe>>; - dmproadmap_visibility?: Maybe; - ethical_issues_description?: Maybe; - ethical_issues_exist: YesNoUnknown; - ethical_issues_report?: Maybe; - language?: Maybe; - modified?: Maybe; - title: Scalars['String']['output']; -}; - -export type DmspIdentifier = { - __typename?: 'DmspIdentifier'; - identifier: Scalars['DmspId']['output']; - type: Scalars['String']['output']; -}; - -export type Identifier = { - __typename?: 'Identifier'; - identifier: Scalars['String']['output']; - type: Scalars['String']['output']; +export type EditProjectContributorInput = { + /** The contributor's affiliation URI */ + affiliationId?: InputMaybe; + /** The contributor's email address */ + email?: InputMaybe; + /** The contributor's first/given name */ + givenName?: InputMaybe; + /** The contributor's ORCID */ + orcid?: InputMaybe; + /** The project contributor */ + projectContributorId: Scalars['Int']['input']; + /** The roles the contributor has on the research project */ + roles?: InputMaybe>; + /** The contributor's last/sur name */ + surname?: InputMaybe; +}; + +export type EditProjectFunderInput = { + /** The funder's unique id/url for the call for submissions to apply for a grant */ + funderOpportunityNumber?: InputMaybe; + /** The funder's unique id/url for the research project (normally assigned after the grant has been awarded) */ + funderProjectNumber?: InputMaybe; + /** The funder's unique id/url for the award/grant (normally assigned after the grant has been awarded) */ + grantId?: InputMaybe; + /** The project funder */ + projectFunderId: Scalars['Int']['input']; + /** The status of the funding resquest */ + status?: InputMaybe; }; /** The types of object a User can be invited to Collaborate on */ @@ -364,6 +441,20 @@ export type Mutation = { addAffiliation?: Maybe; /** Add a new contributor role (URL and label must be unique!) */ addContributorRole?: Maybe; + /** Add a comment to an answer within a round of feedback */ + addFeedbackComment?: Maybe; + /** Create a plan */ + addPlan?: Maybe; + /** Answer a question */ + addPlanAnswer?: Maybe; + /** Add a collaborator to a Plan */ + addPlanCollaborator?: Maybe; + /** Add a Contributor to a Plan */ + addPlanContributor?: Maybe; + /** Add a contributor to a research project */ + addProjectContributor?: Maybe; + /** Add a Funder to a research project */ + addProjectFunder?: Maybe; /** Create a new Question */ addQuestion: Question; /** Create a new QuestionCondition associated with a question */ @@ -380,16 +471,44 @@ export type Mutation = { addUserEmail?: Maybe; /** Archive a Template (unpublishes any associated PublishedTemplate */ archiveTemplate?: Maybe; + /** Mark the feedback round as complete */ + completeFeedback?: Maybe; /** Publish the template or save as a draft */ createTemplateVersion?: Maybe