From 739cc270b266be9b9158ff12b3682da9551b2e91 Mon Sep 17 00:00:00 2001 From: "AIMEUR M. Amin" <43800537+AimeurAmin@users.noreply.github.com> Date: Thu, 19 Dec 2024 11:30:56 +0000 Subject: [PATCH] [ASAP-585] - Enable Users to start a Discussion (#4459) * adds discussion field to compliance reports * adds create new discussion endpoint * adds create to discussion controller * handles discussion creation in data providers and routes with validations * updates graphql query * creates start compliance report discussion modal * creates hooks and selectors for discussion creation and team state sync * implements start discussion on compliance reports * fixes tests and typechecks * fixes autogenerated files with missing props * fixes linting * fixes broken tests * fixes typecheck issue * updates migration description * renames reply variable to message to avoid confusion * correct text "start discussion" instead of "start a discussion" * fixes ui issues * adds toast when discussion started & fixes compliance report closing when version expanded * changes QuickCheckReplyModal to be reused for quick checks and compliance discussions * adds missing tests * simplifies DiscussionRequest type and reuses it for both discussions and replies * adds workspace missing tests * adds missing tests for create discussion route * adds missing tests for compliance report card * adds missing hooks and selectors tests * removes duplicated line --- .../network/teams/ManuscriptToastProvider.tsx | 8 +- .../src/network/teams/Workspace.tsx | 25 +- .../src/network/teams/__mocks__/api.ts | 9 +- .../teams/__tests__/Workspace.test.tsx | 179 ++++++++- .../src/network/teams/__tests__/api.test.ts | 64 +++- .../network/teams/__tests__/state.test.tsx | 355 ++++++++++++++++++ apps/crn-frontend/src/network/teams/api.ts | 33 +- apps/crn-frontend/src/network/teams/state.ts | 116 +++++- .../src/controllers/discussion.controller.ts | 6 + .../contentful/discussion.data-provider.ts | 13 +- .../contentful/manuscript.data-provider.ts | 3 +- .../crn-server/src/routes/discussion.route.ts | 29 +- .../compliance-report.validation.ts | 3 + .../src/validation/discussion.validation.ts | 31 +- .../controllers/discussion.controller.test.ts | 28 ++ .../discussion.data-provider.test.ts | 136 ++++++- .../test/fixtures/discussions.fixtures.ts | 2 +- .../test/mocks/discussion.controller.mock.ts | 1 + .../test/routes/discussion.route.test.ts | 100 ++++- .../src/TeamProfileWorkspace.stories.tsx | 6 +- .../20241209143327-add-discussion-field.js | 25 ++ .../src/crn/autogenerated-gql/gql.ts | 6 +- .../src/crn/autogenerated-gql/graphql.ts | 85 ++++- .../src/crn/queries/manuscript.queries.ts | 6 + .../crn/schema/autogenerated-schema.graphql | 19 + packages/model/src/compliance-report.ts | 7 +- packages/model/src/discussion.ts | 14 +- packages/react-components/src/index.ts | 2 +- .../src/molecules/Discussion.tsx | 17 +- .../molecules/__tests__/Discussion.test.tsx | 3 +- .../src/organisms/ComplianceReportCard.tsx | 168 ++++++++- ...heckReplyModal.tsx => DiscussionModal.tsx} | 45 ++- .../src/organisms/ManuscriptCard.tsx | 22 +- .../src/organisms/ManuscriptVersionCard.tsx | 44 ++- .../__tests__/ComplianceReportCard.test.tsx | 67 +++- ...odal.test.tsx => DiscussionModal.test.tsx} | 28 +- .../__tests__/ManuscriptCard.test.tsx | 34 +- .../__tests__/ManuscriptVersionCard.test.tsx | 61 ++- .../react-components/src/organisms/index.ts | 2 +- .../src/templates/TeamProfileWorkspace.tsx | 32 +- .../__tests__/TeamProfileWorkspace.test.tsx | 91 ++++- 41 files changed, 1772 insertions(+), 153 deletions(-) create mode 100644 apps/crn-frontend/src/network/teams/__tests__/state.test.tsx create mode 100644 packages/contentful/migrations/crn/complianceReports/20241209143327-add-discussion-field.js rename packages/react-components/src/organisms/{QuickCheckReplyModal.tsx => DiscussionModal.tsx} (80%) rename packages/react-components/src/organisms/__tests__/{QuickCheckReplyModal.test.tsx => DiscussionModal.test.tsx} (78%) diff --git a/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx b/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx index a5cabe15bc..b9048def9b 100644 --- a/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx +++ b/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx @@ -1,7 +1,12 @@ import { Toast } from '@asap-hub/react-components'; import React, { createContext, useState } from 'react'; -type FormType = 'manuscript' | 'compliance-report' | 'quick-check' | ''; +type FormType = + | 'manuscript' + | 'compliance-report' + | 'quick-check' + | 'compliance-report-discussion' + | ''; type ManuscriptToastContextData = { setFormType: React.Dispatch>; @@ -22,6 +27,7 @@ export const ManuscriptToastProvider = ({ manuscript: 'Manuscript submitted successfully.', 'compliance-report': 'Compliance Report submitted successfully.', 'quick-check': 'Replied to quick check successfully.', + 'compliance-report-discussion': 'Discussion started successfully.', }; return ( diff --git a/apps/crn-frontend/src/network/teams/Workspace.tsx b/apps/crn-frontend/src/network/teams/Workspace.tsx index 609c4cc37b..d40e04f797 100644 --- a/apps/crn-frontend/src/network/teams/Workspace.tsx +++ b/apps/crn-frontend/src/network/teams/Workspace.tsx @@ -9,17 +9,19 @@ import { TeamTool, TeamResponse, ManuscriptPutRequest, - DiscussionPatchRequest, + DiscussionRequest, } from '@asap-hub/model'; import { network, useRouteParams } from '@asap-hub/routing'; import { ToastContext, useCurrentUserCRN } from '@asap-hub/react-context'; import { + useCreateComplianceDiscussion, useDiscussionById, useIsComplianceReviewer, usePatchTeamById, usePutManuscript, useReplyToDiscussion, + useVersionById, } from './state'; import { useEligibilityReason } from './useEligibilityReason'; import { useManuscriptToast } from './useManuscriptToast'; @@ -37,6 +39,8 @@ const Workspace: React.FC = ({ team }) => { const patchTeam = usePatchTeamById(team.id); const updateManuscript = usePutManuscript(); const replyToDiscussion = useReplyToDiscussion(); + const createComplianceDiscussion = useCreateComplianceDiscussion(); + const getDiscussion = useDiscussionById; const toast = useContext(ToastContext); @@ -76,14 +80,23 @@ const Workspace: React.FC = ({ team }) => { } } isComplianceReviewer={isComplianceReviewer} - onReplyToDiscussion={async ( - id: string, - patch: DiscussionPatchRequest, - ) => { - await replyToDiscussion(id, patch); + onSave={async (id: string, patch: DiscussionRequest) => { + await replyToDiscussion(id, patch as DiscussionRequest); setFormType('quick-check'); }} getDiscussion={getDiscussion} + createComplianceDiscussion={async ( + complianceReportId: string, + message: string, + ) => { + const discussionId = await createComplianceDiscussion( + complianceReportId, + message, + ); + setFormType('compliance-report-discussion'); + return discussionId; + }} + useVersionById={useVersionById} /> diff --git a/apps/crn-frontend/src/network/teams/__mocks__/api.ts b/apps/crn-frontend/src/network/teams/__mocks__/api.ts index 30ad4ac40f..4140686185 100644 --- a/apps/crn-frontend/src/network/teams/__mocks__/api.ts +++ b/apps/crn-frontend/src/network/teams/__mocks__/api.ts @@ -7,7 +7,7 @@ import { createTeamResponse, } from '@asap-hub/fixtures'; import { - DiscussionPatchRequest, + DiscussionRequest, DiscussionResponse, ListLabsResponse, ListTeamResponse, @@ -68,12 +68,9 @@ export const getDiscussion = jest.fn( ); export const updateDiscussion = jest.fn( - async ( - id: string, - patch: DiscussionPatchRequest, - ): Promise => { + async (id: string, patch: DiscussionRequest): Promise => { const discussion = await getDiscussion(id); - discussion.replies = [createMessage(patch.replyText)]; + discussion.replies = [createMessage(patch.text)]; return discussion; }, ); diff --git a/apps/crn-frontend/src/network/teams/__tests__/Workspace.test.tsx b/apps/crn-frontend/src/network/teams/__tests__/Workspace.test.tsx index 75bc3036dc..7f8ad2ebc4 100644 --- a/apps/crn-frontend/src/network/teams/__tests__/Workspace.test.tsx +++ b/apps/crn-frontend/src/network/teams/__tests__/Workspace.test.tsx @@ -9,7 +9,11 @@ import { fireEvent, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { TeamResponse } from '@asap-hub/model'; +import { + ManuscriptVersion, + TeamManuscript, + TeamResponse, +} from '@asap-hub/model'; import { createDiscussionResponse, createManuscriptResponse, @@ -31,10 +35,13 @@ import { updateManuscript, getDiscussion, updateDiscussion, + createComplianceDiscussion, } from '../api'; import Workspace from '../Workspace'; import { ManuscriptToastProvider } from '../ManuscriptToastProvider'; +import { useVersionById } from '../state'; +import { useManuscriptToast } from '../useManuscriptToast'; jest.setTimeout(60000); jest.mock('../api', () => ({ @@ -42,6 +49,16 @@ jest.mock('../api', () => ({ updateManuscript: jest.fn().mockResolvedValue({}), getDiscussion: jest.fn(), updateDiscussion: jest.fn(), + createComplianceDiscussion: jest.fn(), +})); + +jest.mock('../state', () => ({ + ...jest.requireActual('../state'), + useVersionById: jest.fn(), +})); + +jest.mock('../useManuscriptToast', () => ({ + useManuscriptToast: jest.fn(), })); const mockPatchTeam = patchTeam as jest.MockedFunction; @@ -94,6 +111,28 @@ const user = { ], }; +const mockSetVersion = jest.fn(); + +const version = createManuscriptResponse().versions[0] as ManuscriptVersion; + +const mockVersionData = { + ...version, + complianceReport: { + ...version.complianceReport, + discussionId: 'discussion-id', + }, +}; + +beforeEach(() => { + (useVersionById as jest.Mock).mockImplementation(() => [ + mockVersionData, + mockSetVersion, + ]); + (useManuscriptToast as jest.Mock).mockImplementation(() => ({ + setFormType: jest.fn(), + })); +}); + afterEach(jest.resetAllMocks); describe('Manuscript', () => { @@ -390,8 +429,19 @@ describe('manuscript quick check discussion', () => { it('fetches quick check discussion details', async () => { enable('DISPLAY_MANUSCRIPTS'); + mockGetDiscussion.mockImplementation( + async () => acknowledgedGrantNumberDiscussion, + ); + + (useVersionById as jest.Mock).mockImplementation(() => [ + { + ...mockVersionData, + acknowledgedGrantNumberDetails: acknowledgedGrantNumberDiscussion, + acknowledgedGrantNumber: 'No', + }, + mockSetVersion, + ]); - mockGetDiscussion.mockResolvedValueOnce(acknowledgedGrantNumberDiscussion); const { getByText, findByTestId, getByLabelText, getByTestId } = renderWithWrapper( { it('replies to a quick check discussion', async () => { enable('DISPLAY_MANUSCRIPTS'); + + mockGetDiscussion.mockImplementation( + async () => acknowledgedGrantNumberDiscussion, + ); + + (useVersionById as jest.Mock).mockImplementation(() => [ + { + ...mockVersionData, + acknowledgedGrantNumberDetails: acknowledgedGrantNumberDiscussion, + acknowledgedGrantNumber: 'No', + }, + mockSetVersion, + ]); + mockGetDiscussion.mockResolvedValue(acknowledgedGrantNumberDiscussion); mockUpdateDiscussion.mockResolvedValue(acknowledgedGrantNumberDiscussion); const { findByTestId, getByRole, getByTestId, getByLabelText } = @@ -465,8 +529,117 @@ describe('manuscript quick check discussion', () => { expect(mockUpdateDiscussion).toHaveBeenLastCalledWith( acknowledgedGrantNumberDiscussion.id, - { replyText: 'new reply' }, + { text: 'new reply' }, expect.anything(), ); }); + + it('creates a new discussion to compliance report', async () => { + enable('DISPLAY_MANUSCRIPTS'); + (createComplianceDiscussion as jest.Mock).mockResolvedValue({ + id: 'discussion-id', + }); + + const mockManuscript = { + id: `manuscript_1`, + title: `Manuscript 1`, + teamId: 'team-1', + status: 'Waiting for Report', + count: 1, + versions: [ + { + ...manuscript.versions[0], + complianceReport: { + id: 'compliance-report-id', + url: 'http://example.com/file.pdf', + description: 'A description', + count: 1, + createdDate: '2024-12-10T20:36:54Z', + createdBy: { + displayName: 'John Doe', + email: 'john@doe.com', + firstName: 'John', + lastName: 'Doe', + avatarUrl: 'http://example.com/avatar.jpg', + teams: [ + { + id: 'team-1', + name: 'Team 1', + }, + ], + id: 'user-1', + }, + }, + } as ManuscriptVersion, + ], + }; + + mockGetDiscussion.mockImplementation( + async () => acknowledgedGrantNumberDiscussion, + ); + + (useVersionById as jest.Mock).mockImplementation(() => [ + { + ...mockManuscript.versions[0], + acknowledgedGrantNumberDetails: acknowledgedGrantNumberDiscussion, + acknowledgedGrantNumber: 'No', + }, + mockSetVersion, + ]); + + mockGetDiscussion.mockResolvedValue(acknowledgedGrantNumberDiscussion); + mockUpdateDiscussion.mockResolvedValue(acknowledgedGrantNumberDiscussion); + const { findByTestId, getByText, getByLabelText, getByTestId, findByText } = + renderWithWrapper( + + + , + user, + ); + + await act(async () => { + userEvent.click(await findByTestId('collapsible-button')); + await waitFor(() => { + expect(getByLabelText('Expand Version')).toBeInTheDocument(); + }); + userEvent.click(getByLabelText('Expand Version')); + await waitFor(() => { + expect(getByLabelText('Expand Report')).toBeInTheDocument(); + }); + userEvent.click(getByLabelText('Expand Report')); + }); + + await waitFor(() => { + expect(getByText(/Start Discussion/i)).toBeInTheDocument(); + }); + + await act(async () => { + userEvent.click(await findByText(/Start Discussion/i)); + }); + + const replyEditor = getByTestId('editor'); + await act(async () => { + userEvent.click(replyEditor); + userEvent.tab(); + fireEvent.input(replyEditor, { data: 'New discussion message' }); + userEvent.tab(); + }); + + expect(await findByText(/Send/i)).toBeInTheDocument(); + + userEvent.click(await findByText(/Send/i)); + await waitFor(() => { + expect(createComplianceDiscussion).toHaveBeenCalledWith( + 'compliance-report-id', + 'New discussion message', + 'Bearer access_token', + ); + }); + }); }); diff --git a/apps/crn-frontend/src/network/teams/__tests__/api.test.ts b/apps/crn-frontend/src/network/teams/__tests__/api.test.ts index 6786977e44..23f33228be 100644 --- a/apps/crn-frontend/src/network/teams/__tests__/api.test.ts +++ b/apps/crn-frontend/src/network/teams/__tests__/api.test.ts @@ -9,6 +9,7 @@ import { import { GetListOptions } from '@asap-hub/frontend-utils'; import { ComplianceReportPostRequest, + DiscussionDataObject, DiscussionResponse, ManuscriptFileResponse, ManuscriptPostRequest, @@ -35,6 +36,7 @@ import { updateTeamResearchOutput, uploadManuscriptFile, resubmitManuscript, + createComplianceDiscussion, } from '../api'; jest.mock('../../../config'); @@ -557,7 +559,7 @@ describe('Discussion', () => { describe('updateDiscussion', () => { const patch = { - replyText: 'test reply', + text: 'test reply', }; it('makes an authorized PATCH request for the discussion id', async () => { nock(API_BASE_URL, { reqheaders: { authorization: 'Bearer x' } }) @@ -594,4 +596,64 @@ describe('Discussion', () => { ); }); }); + + describe('createDiscussion', () => { + const message = 'test discussion message'; + it('makes an authorized POST request to create compliance discussion', async () => { + nock(API_BASE_URL, { reqheaders: { authorization: 'Bearer x' } }) + .post('/discussions') + .reply(200, {}); + + await createComplianceDiscussion('42', message, 'Bearer x'); + expect(nock.isDone()).toBe(true); + }); + + it('passes the post object in the body', async () => { + nock(API_BASE_URL) + .post('/discussions', { + message, + type: 'compliance-report', + id: '42', + }) + .reply(200, {}); + + await createComplianceDiscussion('42', message, ''); + expect(nock.isDone()).toBe(true); + }); + + it('returns a successfully created discussion', async () => { + const created: Partial = { + id: 'discussion-1', + message: createMessage(message), + replies: [], + }; + nock(API_BASE_URL) + .post('/discussions', { + message, + type: 'compliance-report', + id: '42', + }) + .reply(200, created); + + expect(await createComplianceDiscussion('42', message, '')).toEqual( + created, + ); + }); + + it('shows errors for an error status', async () => { + nock(API_BASE_URL) + .post('/discussions', { + message, + type: 'compliance-report', + id: '42', + }) + .reply(500, {}); + + await expect( + createComplianceDiscussion('42', message, ''), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create discussion. Expected status 201. Received status 500."`, + ); + }); + }); }); diff --git a/apps/crn-frontend/src/network/teams/__tests__/state.test.tsx b/apps/crn-frontend/src/network/teams/__tests__/state.test.tsx new file mode 100644 index 0000000000..512dbf4957 --- /dev/null +++ b/apps/crn-frontend/src/network/teams/__tests__/state.test.tsx @@ -0,0 +1,355 @@ +import { createManuscriptResponse } from '@asap-hub/fixtures'; +import { + ManuscriptDataObject, + ManuscriptVersion, + TeamDataObject, +} from '@asap-hub/model'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { + RecoilRoot, + MutableSnapshot, + useRecoilValue, + useRecoilState, +} from 'recoil'; +import { + patchedTeamState, + teamState, + useVersionById, + versionSelector, +} from '../state'; + +const teamId = 'team-id-0'; + +const teamMock = { + id: 'id-0', + teamId: 'team-id-0', + tags: [{ id: 'tag-1', name: 'Research' }], + members: [], + lastModifiedDate: '2021-09-01T00:00:00Z', + labCount: 1, + displayName: 'Team One', + projectTitle: 'Project Title', +}; + +const mockVersionData = createManuscriptResponse() + .versions[0] as ManuscriptVersion; + +describe('team selectors', () => { + test('teamState selector retrieves team with tags', () => { + const initialState = ({ set }: MutableSnapshot) => { + const mockTeam: TeamDataObject = { + ...teamMock, + manuscripts: [], + }; + + set(teamState(teamId), mockTeam); + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useRecoilValue(teamState(teamId)), { + wrapper, + }); + + expect(result.current?.displayName).toBe('Team One'); + expect(result.current?.tags.length).toBe(1); + }); + + test('resets team state when newValue is undefined', () => { + jest.spyOn(console, 'error').mockImplementation(); + const mockTeam = { + id: 'id-0', + teamId, + tags: [], + members: [], + lastModifiedDate: '2021-09-01T00:00:00Z', + labCount: 1, + displayName: 'Team One', + projectTitle: 'Project Title', + manuscripts: [], + }; + const initialState = ({ set }: MutableSnapshot) => { + set(patchedTeamState(teamId), mockTeam); + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => { + const [getTeamState, setTeamState] = useRecoilState(teamState(teamId)); + return { setTeamState, getTeamState }; + }, + { wrapper }, + ); + + expect(result.current.getTeamState).toEqual(mockTeam); + + act(() => { + result.current.setTeamState(undefined); + }); + + expect(result.current.getTeamState).toEqual(mockTeam); + }); +}); + +describe('versionSelector', () => { + test('retrieves manuscript version', () => { + const manuscriptId = 'manuscript-id-0'; + const versionId = 'version-id-0'; + + const initialState = ({ set }: MutableSnapshot) => { + const mockTeam = { + ...teamMock, + manuscripts: [ + { + id: manuscriptId, + versions: [ + { + id: versionId, + }, + ], + }, + ] as ManuscriptDataObject[], + }; + + set(teamState(teamId), mockTeam); + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => + useRecoilValue(versionSelector({ teamId, manuscriptId, versionId })), + { + wrapper, + }, + ); + + expect(result.current?.id).toBe(versionId); + }); + + test('returns undefined if manuscript or version does not exist', () => { + const manuscriptId = 'manuscript-id-0'; + const versionId = 'nonexistent-version-id'; + + const initialState = ({ set }: MutableSnapshot) => { + const mockTeam = { + ...teamMock, + manuscripts: [ + { + id: manuscriptId, + versions: [ + { + id: 'version-id-0', + }, + ], + }, + ] as ManuscriptDataObject[], + }; + + set(teamState(teamId), mockTeam); + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => + useRecoilValue(versionSelector({ teamId, manuscriptId, versionId })), + { + wrapper, + }, + ); + + expect(result.current).toBeUndefined(); + }); + + test('does not update version if versionId does not match', () => { + const manuscriptId = 'manuscript-id-1'; + + const initialState = ({ set }: MutableSnapshot) => { + const mockTeam = { + ...teamMock, + manuscripts: [ + { + id: manuscriptId, + versions: [ + { id: 'version-id-1', description: 'Original Version 1' }, + { id: 'version-id-2', description: 'Original Version 2' }, + ], + }, + ] as ManuscriptDataObject[], + }; + + set(teamState(teamId), mockTeam); + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => + useVersionById({ + teamId, + manuscriptId, + versionId: 'nonexistent-version-id', + }), + { wrapper }, + ); + + const updateVersion = (prev: ManuscriptVersion) => ({ + ...prev, + description: 'Updated description', + }); + + act(() => { + result.current[1](updateVersion); + }); + + const updatedTeam = renderHook(() => useRecoilValue(teamState(teamId)), { + wrapper, + }).result.current; + + const originalVersions = updatedTeam?.manuscripts.find( + (m) => m.id === manuscriptId, + )?.versions; + expect(originalVersions).toEqual([ + { id: 'version-id-1', description: 'Original Version 1' }, + { id: 'version-id-2', description: 'Original Version 2' }, + ]); + }); +}); + +describe('useVersionById hook', () => { + test('retrieves manuscript version and allows updating', () => { + const manuscriptId = 'manuscript-id-1'; + + const initialState = ({ set }: MutableSnapshot) => { + const mockTeam = { + ...teamMock, + manuscripts: [ + { + id: manuscriptId, + versions: [mockVersionData], + }, + ] as ManuscriptDataObject[], + }; + + set(teamState(teamId), mockTeam); + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => + useVersionById({ teamId, manuscriptId, versionId: mockVersionData.id }), + { + wrapper, + }, + ); + + expect(result.current[0]?.id).toBe('version-1'); + + const updateVersion = (prev: ManuscriptVersion) => ({ + ...prev, + description: 'Updated description', + }); + act(() => { + result.current[1](updateVersion); + }); + + expect(result.current[0]?.description).toBe('Updated description'); + }); + + test('only updates the matching version in the concerned manuscript and leaves other versions and manuscripts untouched', () => { + const manuscriptId = 'manuscript-id-1'; + + const initialState = ({ set }: MutableSnapshot) => { + const mockTeam = { + ...teamMock, + manuscripts: [ + { + id: manuscriptId, + versions: [ + { + ...mockVersionData, + description: 'Original Version 1 Description', + }, + { + ...mockVersionData, + id: 'version-2', + description: 'Original Version 2 Description', + }, + ], + }, + { + id: 'manuscript-id-2', + versions: [ + { + ...mockVersionData, + id: 'version-3', + description: 'Original Version 3 Description', + }, + ], + }, + ] as ManuscriptDataObject[], + }; + + set(teamState(teamId), mockTeam); + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => + useVersionById({ teamId, manuscriptId, versionId: mockVersionData.id }), + { + wrapper, + }, + ); + + expect(result.current[0]?.id).toBe('version-1'); + + const updateVersion = (prev: ManuscriptVersion) => ({ + ...prev, + description: 'Updated description', + }); + act(() => { + result.current[1](updateVersion); + }); + + expect(result.current[0]?.description).toBe('Updated description'); + + const { result: resultState } = renderHook( + () => useRecoilValue(teamState(teamId)), + { wrapper }, + ); + + const resultVersion = + resultState.current && resultState.current.manuscripts + ? resultState.current.manuscripts[1] + : undefined; + expect(resultVersion).toEqual({ + id: 'manuscript-id-2', + versions: [ + { + ...mockVersionData, + id: 'version-3', + description: 'Original Version 3 Description', + }, + ], + }); + }); +}); diff --git a/apps/crn-frontend/src/network/teams/api.ts b/apps/crn-frontend/src/network/teams/api.ts index 04c8552074..391b40a753 100644 --- a/apps/crn-frontend/src/network/teams/api.ts +++ b/apps/crn-frontend/src/network/teams/api.ts @@ -6,7 +6,7 @@ import { import { ComplianceReportPostRequest, ComplianceReportResponse, - DiscussionPatchRequest, + DiscussionRequest, DiscussionResponse, ListLabsResponse, ListTeamResponse, @@ -317,7 +317,7 @@ export const createComplianceReport = async ( export const updateDiscussion = async ( discussionId: string, - discussion: DiscussionPatchRequest, + discussion: DiscussionRequest, authorization: string, ): Promise => { const resp = await fetch(`${API_BASE_URL}/discussions/${discussionId}`, { @@ -360,3 +360,32 @@ export const getDiscussion = async ( } return resp.json(); }; + +export const createComplianceDiscussion = async ( + complianceReportId: string, + message: string, + authorization: string, +): Promise => { + const resp = await fetch(`${API_BASE_URL}/discussions`, { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + ...createSentryHeaders(), + }, + body: JSON.stringify({ + message, + id: complianceReportId, + type: 'compliance-report', + }), + }); + const response = await resp.json(); + if (!resp.ok) { + throw new BackendError( + `Failed to create discussion. Expected status 201. Received status ${`${resp.status} ${resp.statusText}`.trim()}.`, + response, + resp.status, + ); + } + return response; +}; diff --git a/apps/crn-frontend/src/network/teams/state.ts b/apps/crn-frontend/src/network/teams/state.ts index 0d0bb4a6c7..6d93bd4fcc 100644 --- a/apps/crn-frontend/src/network/teams/state.ts +++ b/apps/crn-frontend/src/network/teams/state.ts @@ -9,9 +9,10 @@ import { ManuscriptFileType, ComplianceReportPostRequest, ManuscriptPutRequest, - DiscussionPatchRequest, + DiscussionRequest, DiscussionResponse, ListPartialManuscriptResponse, + ManuscriptVersion, } from '@asap-hub/model'; import { useCurrentUserCRN } from '@asap-hub/react-context'; import { useCallback } from 'react'; @@ -19,6 +20,7 @@ import { atom, atomFamily, DefaultValue, + RecoilState, selectorFamily, useRecoilCallback, useRecoilState, @@ -40,6 +42,7 @@ import { updateDiscussion, uploadManuscriptFile, resubmitManuscript, + createComplianceDiscussion, } from './api'; const teamIndexState = atomFamily< @@ -98,7 +101,7 @@ const initialTeamState = selectorFamily({ }, }); -const patchedTeamState = atomFamily({ +export const patchedTeamState = atomFamily({ key: 'patchedTeam', default: undefined, }); @@ -109,6 +112,20 @@ export const teamState = selectorFamily({ (id) => ({ get }) => get(patchedTeamState(id)) ?? get(initialTeamState(id)), + set: + (id: string) => + ({ set, reset }, newValue: TeamResponse | DefaultValue | undefined) => { + if (newValue === undefined || newValue instanceof DefaultValue) { + reset(patchedTeamState(id) ?? initialTeamState(id)); + } else { + set(patchedTeamState(id) ?? initialTeamState(id), (prev) => { + if (prev) { + return { ...prev, ...newValue }; + } + return newValue; + }); + } + }, }); export const teamListState = atomFamily< @@ -306,7 +323,7 @@ export const useReplyToDiscussion = () => { const authorization = useRecoilValue(authorizationState); const setDiscussion = useSetDiscussion(); - return async (id: string, patch: DiscussionPatchRequest) => { + return async (id: string, patch: DiscussionRequest) => { const discussion = await updateDiscussion(id, patch, authorization); setDiscussion(discussion); }; @@ -318,3 +335,96 @@ export const useManuscripts = ( total: 0, items: [], }); + +export const versionSelector = selectorFamily< + ManuscriptVersion | undefined, + { teamId: string; manuscriptId: string; versionId: string } +>({ + key: 'versionSelector', + get: + (params) => + ({ get }) => { + const { teamId, manuscriptId, versionId } = params; + + const team = get(teamState(teamId)); + if (!team) return undefined; + + const currentManuscript = team.manuscripts.find( + (manuscript) => manuscript.id === manuscriptId, + ); + if (!currentManuscript) return undefined; + + return currentManuscript.versions.find( + (version) => version.id === versionId, + ); + }, + set: + (params) => + ({ set, get }, newValue: ManuscriptVersion | DefaultValue | undefined) => { + const { teamId, manuscriptId, versionId } = params; + + const team = get(teamState(teamId)); + if (!team) return; + + const manuscript = team.manuscripts.find( + (item) => item.id === manuscriptId, + ); + if (!manuscript) return; + + const version = manuscript.versions.find((item) => item.id === versionId); + if (!version) return; + + set( + teamState(teamId) as RecoilState, + (prev: TeamResponse) => ({ + ...prev, + manuscripts: team.manuscripts.map((manuscriptItem) => { + if (manuscriptItem.id === manuscriptId) { + return { + ...manuscriptItem, + versions: manuscriptItem.versions.map((versionItem) => + versionItem.id === versionId + ? (newValue as ManuscriptVersion) + : versionItem, + ), + }; + } + return manuscriptItem; + }), + }), + ); + }, +}); + +export const useVersionById = (params: { + teamId: string; + manuscriptId: string; + versionId: string; +}): [ + ManuscriptVersion | undefined, + (callback: (prev: ManuscriptVersion) => ManuscriptVersion) => void, +] => { + const [version, setVersion] = useRecoilState(versionSelector(params)); + const setVersionCallback = ( + callback: (prev: ManuscriptVersion) => ManuscriptVersion, + ) => { + setVersion((prev) => (prev ? callback(prev) : prev)); + }; + return [version, setVersionCallback]; +}; + +export const useCreateComplianceDiscussion = () => { + const authorization = useRecoilValue(authorizationState); + + return async ( + complianceReportId: string, + message: string, + ): Promise => { + const discussion = await createComplianceDiscussion( + complianceReportId, + message, + authorization, + ); + return discussion.id; + }; +}; diff --git a/apps/crn-server/src/controllers/discussion.controller.ts b/apps/crn-server/src/controllers/discussion.controller.ts index 7a318aacf3..02fc2d3538 100644 --- a/apps/crn-server/src/controllers/discussion.controller.ts +++ b/apps/crn-server/src/controllers/discussion.controller.ts @@ -28,4 +28,10 @@ export default class DiscussionController { return this.fetchById(id); } + + async create(message: MessageCreateDataObject): Promise { + const id = await this.discussionDataProvider.create(message); + + return this.fetchById(id); + } } diff --git a/apps/crn-server/src/data-providers/contentful/discussion.data-provider.ts b/apps/crn-server/src/data-providers/contentful/discussion.data-provider.ts index 89c473a06c..6844560017 100644 --- a/apps/crn-server/src/data-providers/contentful/discussion.data-provider.ts +++ b/apps/crn-server/src/data-providers/contentful/discussion.data-provider.ts @@ -48,8 +48,12 @@ export class DiscussionContentfulDataProvider async create(input: MessageCreateDataObject): Promise { const environment = await this.getRestClient(); + const { text, userId, complianceReportId, type } = input; - const messageId = await createAndPublishMessage(environment, input); + const messageId = await createAndPublishMessage(environment, { + text, + userId, + }); const discussionEntry = await environment.createEntry('discussions', { fields: addLocaleToFields({ @@ -59,6 +63,13 @@ export class DiscussionContentfulDataProvider await discussionEntry.publish(); + if (complianceReportId && type === 'compliance-report') { + const complianceReport = await environment.getEntry(complianceReportId); + + await patchAndPublish(complianceReport, { + discussion: getLinkEntity(discussionEntry.sys.id), + }); + } return discussionEntry.sys.id; } diff --git a/apps/crn-server/src/data-providers/contentful/manuscript.data-provider.ts b/apps/crn-server/src/data-providers/contentful/manuscript.data-provider.ts index db06e4e0dc..1e653600de 100644 --- a/apps/crn-server/src/data-providers/contentful/manuscript.data-provider.ts +++ b/apps/crn-server/src/data-providers/contentful/manuscript.data-provider.ts @@ -511,6 +511,7 @@ const parseComplianceReport = ( complianceReport: ComplianceReport | undefined, ) => complianceReport && { + id: complianceReport.sys.id, url: complianceReport.url, description: complianceReport.description, count: complianceReport.count, @@ -518,8 +519,8 @@ const parseComplianceReport = ( createdBy: parseGraphqlManuscriptUser( complianceReport.createdBy || undefined, ), + discussionId: complianceReport.discussion?.sys.id, }; - const createQuickCheckDiscussions = async ( environment: Environment, quickCheckDetails: QuickCheckDetailsObject, diff --git a/apps/crn-server/src/routes/discussion.route.ts b/apps/crn-server/src/routes/discussion.route.ts index 7de94e3040..6cfa838463 100644 --- a/apps/crn-server/src/routes/discussion.route.ts +++ b/apps/crn-server/src/routes/discussion.route.ts @@ -3,8 +3,9 @@ import Boom from '@hapi/boom'; import { Response, Router } from 'express'; import DiscussionController from '../controllers/discussion.controller'; import { + validateDiscussionCreateRequest, validateDiscussionParameters, - validateDiscussionPatchRequest, + validateDiscussionRequest, } from '../validation/discussion.validation'; export const discussionRouteFactory = ( @@ -33,11 +34,11 @@ export const discussionRouteFactory = ( const { body, params } = req; const { discussionId } = validateDiscussionParameters(params); - const { replyText } = validateDiscussionPatchRequest(body); + const { text } = validateDiscussionRequest(body); if (!req.loggedInUser) throw Boom.forbidden(); - const reply = { text: replyText, userId: req.loggedInUser.id }; + const reply = { text, userId: req.loggedInUser.id }; const result = await discussionController.update(discussionId, reply); @@ -45,5 +46,27 @@ export const discussionRouteFactory = ( }, ); + discussionRoutes.post( + '/discussions', + async (req, res: Response) => { + const { body } = req; + + const { message: text, id, type } = validateDiscussionCreateRequest(body); + + if (!req.loggedInUser) throw Boom.forbidden(); + + const message = { + text, + userId: req.loggedInUser.id, + type, + ...(type === 'compliance-report' ? { complianceReportId: id } : {}), + }; + + const result = await discussionController.create(message); + + res.json(result); + }, + ); + return discussionRoutes; }; diff --git a/apps/crn-server/src/validation/compliance-report.validation.ts b/apps/crn-server/src/validation/compliance-report.validation.ts index ac50b5c7a5..d9eeadac9e 100644 --- a/apps/crn-server/src/validation/compliance-report.validation.ts +++ b/apps/crn-server/src/validation/compliance-report.validation.ts @@ -13,6 +13,9 @@ const complianceReportPostRequestValidationSchema: JSONSchemaType = +const DiscussionRequestValidationSchema: JSONSchemaType = { + type: 'object', + properties: { + text: { type: 'string', maxLength: 256 }, + }, + required: ['text'], + additionalProperties: false, +}; + +export const validateDiscussionRequest = validateInput( + DiscussionRequestValidationSchema, + { + skipNull: true, + coerce: false, + }, +); + +const discussionCreateRequestValidationSchema: JSONSchemaType = { type: 'object', properties: { - replyText: { type: 'string', maxLength: 256 }, + message: { type: 'string', maxLength: 256 }, + id: { type: 'string', maxLength: 256 }, + type: { type: 'string', enum: ['compliance-report'], maxLength: 256 }, }, - required: ['replyText'], + required: ['message', 'id'], additionalProperties: false, }; -export const validateDiscussionPatchRequest = validateInput( - discussionPatchRequestValidationSchema, +export const validateDiscussionCreateRequest = validateInput( + discussionCreateRequestValidationSchema, { skipNull: true, coerce: false, diff --git a/apps/crn-server/test/controllers/discussion.controller.test.ts b/apps/crn-server/test/controllers/discussion.controller.test.ts index bef01e96c9..92e6869e5b 100644 --- a/apps/crn-server/test/controllers/discussion.controller.test.ts +++ b/apps/crn-server/test/controllers/discussion.controller.test.ts @@ -1,4 +1,5 @@ import { NotFoundError } from '@asap-hub/errors'; +import { DiscussionType } from '@asap-hub/model'; import DiscussionController from '../../src/controllers/discussion.controller'; import { DiscussionDataProvider } from '../../src/data-providers/types'; import { getDiscussionDataObject } from '../fixtures/discussions.fixtures'; @@ -66,4 +67,31 @@ describe('Discussion Controller', () => { ); }); }); + + describe('Create method', () => { + const message = { + text: 'test message', + userId: 'user-id-0', + type: 'compliance-report' as DiscussionType, + complianceReportId: 'compliance-report-id', + }; + + test('Should return the created discussion', async () => { + const mockResponse = getDiscussionDataObject(); + discussionDataProviderMock.fetchById.mockResolvedValue(mockResponse); + const result = await discussionController.create(message); + + expect(result).toEqual(mockResponse); + }); + + test('Should call the data provider with input data', async () => { + discussionDataProviderMock.fetchById.mockResolvedValue( + getDiscussionDataObject(), + ); + + await discussionController.create(message); + + expect(discussionDataProviderMock.create).toHaveBeenCalledWith(message); + }); + }); }); diff --git a/apps/crn-server/test/data-providers/contentful/discussion.data-provider.test.ts b/apps/crn-server/test/data-providers/contentful/discussion.data-provider.test.ts index 89a59e2a2d..b60851d1b4 100644 --- a/apps/crn-server/test/data-providers/contentful/discussion.data-provider.test.ts +++ b/apps/crn-server/test/data-providers/contentful/discussion.data-provider.test.ts @@ -1,4 +1,5 @@ import { Entry, Environment } from '@asap-hub/contentful'; +import { DiscussionType } from '@asap-hub/model'; import { when } from 'jest-when'; import { @@ -9,7 +10,7 @@ import { import { getEntry } from '../../fixtures/contentful.fixtures'; import { getContentfulGraphqlDiscussion, - getDiscussionCreateDataObject, + getDiscussionRequestObject, getDiscussionDataObject, } from '../../fixtures/discussions.fixtures'; import { getContentfulGraphqlClientMock } from '../../mocks/contentful-graphql-client.mock'; @@ -34,7 +35,7 @@ describe('Discussions Contentful Data Provider', () => { test('can create a discussion', async () => { const discussionId = 'discussion-id-1'; const messageId = 'message-id-1'; - const discussionCreateDataObject = getDiscussionCreateDataObject(); + const DiscussionRequestObject = getDiscussionRequestObject(); const publish = jest.fn(); @@ -52,7 +53,7 @@ describe('Discussions Contentful Data Provider', () => { } as unknown as Entry); const result = await discussionDataProviderMock.create({ - ...discussionCreateDataObject, + ...DiscussionRequestObject, }); expect(environmentMock.createEntry).toHaveBeenNthCalledWith( @@ -63,14 +64,14 @@ describe('Discussions Contentful Data Provider', () => { createdBy: { 'en-US': { sys: { - id: discussionCreateDataObject.userId, + id: DiscussionRequestObject.userId, linkType: 'Entry', type: 'Link', }, }, }, text: { - 'en-US': discussionCreateDataObject.text, + 'en-US': DiscussionRequestObject.text, }, }, }, @@ -91,6 +92,131 @@ describe('Discussions Contentful Data Provider', () => { expect(publish).toHaveBeenCalled(); expect(result).toEqual(discussionId); }); + + test('creates a discussion and links it to a compliance report when complianceReportId is provided', async () => { + const discussionId = 'discussion-id-1'; + const messageId = 'message-id-1'; + const complianceReportId = 'compliance-report-id-1'; + + const publish = jest.fn().mockImplementation(() => Promise.resolve()); + const patch = jest + .fn() + .mockImplementation(() => Promise.resolve({ publish: jest.fn() })); + + const DiscussionRequestObject = { + text: 'Test discussion message', + userId: 'user-id-1', + complianceReportId, + type: 'compliance-report' as DiscussionType, + }; + + // Mock `createEntry` for messages and discussions + when(environmentMock.createEntry) + .calledWith('messages', expect.anything()) + .mockResolvedValue({ + sys: { id: messageId }, + publish, + patch, + } as unknown as Entry); + + when(environmentMock.createEntry) + .calledWith('discussions', expect.anything()) + .mockResolvedValue({ + sys: { id: discussionId }, + publish, + patch, + } as unknown as Entry); + + // Mock `getEntry` for compliance reports + when(environmentMock.getEntry) + .calledWith(complianceReportId) + .mockResolvedValue({ + sys: { + id: complianceReportId, + type: 'Entry', + locale: 'en-US', + version: 1, + }, + fields: { + discussion: { + 'en-US': { + sys: { + id: discussionId, + linkType: 'Entry', + type: 'Link', + }, + }, + }, + }, + patch, + publish, + } as unknown as Entry); + + const result = await discussionDataProviderMock.create( + DiscussionRequestObject, + ); + + // Assert `createEntry` calls for `messages` and `discussions` + expect(environmentMock.createEntry).toHaveBeenNthCalledWith( + 1, + 'messages', + { + fields: { + createdBy: { + 'en-US': { + sys: { + id: DiscussionRequestObject.userId, + linkType: 'Entry', + type: 'Link', + }, + }, + }, + text: { + 'en-US': DiscussionRequestObject.text, + }, + }, + }, + ); + + expect(environmentMock.createEntry).toHaveBeenCalledWith('discussions', { + fields: { + message: { + 'en-US': { + sys: { + id: messageId, + linkType: 'Entry', + type: 'Link', + }, + }, + }, + }, + }); + + // Assert `getEntry` was called for compliance report + expect(environmentMock.getEntry).toHaveBeenCalledWith(complianceReportId); + + // Assert `patch` and `publish` calls + expect(patch).toHaveBeenCalledWith([ + { + op: 'replace', + path: '/fields/discussion', + value: { + 'en-US': { + sys: { + id: discussionId, + linkType: 'Entry', + type: 'Link', + }, + }, + }, + }, + ]); + + expect(publish).toHaveBeenCalledTimes(2); // Once for message, once for discussion + + // Assert the result is the discussion ID + expect(result).toEqual(discussionId); + }); }); describe('Fetch', () => { diff --git a/apps/crn-server/test/fixtures/discussions.fixtures.ts b/apps/crn-server/test/fixtures/discussions.fixtures.ts index 6d81cc1509..066ebb3bc4 100644 --- a/apps/crn-server/test/fixtures/discussions.fixtures.ts +++ b/apps/crn-server/test/fixtures/discussions.fixtures.ts @@ -18,7 +18,7 @@ export const getDiscussionDataObject = (): DiscussionDataObject => ({ replies: [getMessage('test reply')], }); -export const getDiscussionCreateDataObject = (): MessageCreateDataObject => ({ +export const getDiscussionRequestObject = (): MessageCreateDataObject => ({ text: 'test message', userId: 'user-id-0', }); diff --git a/apps/crn-server/test/mocks/discussion.controller.mock.ts b/apps/crn-server/test/mocks/discussion.controller.mock.ts index 76c110efbc..7966dd217f 100644 --- a/apps/crn-server/test/mocks/discussion.controller.mock.ts +++ b/apps/crn-server/test/mocks/discussion.controller.mock.ts @@ -3,4 +3,5 @@ import DiscussionController from '../../src/controllers/discussion.controller'; export const discussionControllerMock = { fetchById: jest.fn(), update: jest.fn(), + create: jest.fn(), } as unknown as jest.Mocked; diff --git a/apps/crn-server/test/routes/discussion.route.test.ts b/apps/crn-server/test/routes/discussion.route.test.ts index 3f1674167a..5ac0248ce0 100644 --- a/apps/crn-server/test/routes/discussion.route.test.ts +++ b/apps/crn-server/test/routes/discussion.route.test.ts @@ -90,7 +90,7 @@ describe('/discussions/ route', () => { const response = await supertest(app) .patch(`/discussions/${discussionId}`) .send({ - replyText: 'response', + text: 'response', additionalField: 'some-data', }); @@ -103,7 +103,7 @@ describe('/discussions/ route', () => { const response = await supertest(app) .patch(`/discussions/${discussionId}`) .send({ - replyText: 'response', + text: 'response', }); expect(response.status).toBe(404); @@ -118,7 +118,7 @@ describe('/discussions/ route', () => { const response = await supertest(app) .patch(`/discussions/${discussionId}`) .send({ - replyText: 'response', + text: 'response', }); expect(response.status).toEqual(403); @@ -130,7 +130,7 @@ describe('/discussions/ route', () => { const response = await supertest(app) .patch(`/discussions/${discussionId}`) .send({ - replyText: 'A good reply', + text: 'A good reply', }); expect(response.body).toEqual(discussionResponse); @@ -138,12 +138,12 @@ describe('/discussions/ route', () => { test('Should call the controller with the right parameters', async () => { const discussionId = 'discussion-id-1'; - const replyText = 'test reply'; + const text = 'test reply'; - const reply = { text: replyText, userId: 'user-id-0' }; + const reply = { text, userId: 'user-id-0' }; await supertest(app).patch(`/discussions/${discussionId}`).send({ - replyText, + text, }); expect(discussionControllerMock.update).toBeCalledWith( @@ -158,10 +158,94 @@ describe('/discussions/ route', () => { const response = await supertest(app) .patch(`/discussions/${discussionId}`) .send({ - replyText: 'x'.repeat(257), + text: 'x'.repeat(257), }); expect(response.status).toBe(400); }); }); + + describe('POST /discussions', () => { + test('Should return a 400 error when the payload is invalid', async () => { + const response = await supertest(app).post(`/discussions`).send({ + message: 'something', + id: 'some-id', + type: 'wrong-type', // should be undefined or 'compliance-report' only + }); + + expect(response.status).toBe(400); + }); + + test('Should return a 400 error when additional properties exist', async () => { + const response = await supertest(app).post(`/discussions`).send({ + id: 'some-id', + message: 'response', + additionalField: 'some-data', + }); + + expect(response.status).toBe(400); + }); + + test('Should return the results correctly', async () => { + discussionControllerMock.create.mockResolvedValueOnce(discussionResponse); + + const response = await supertest(app).post(`/discussions`).send({ + id: 'some-id', + message: 'A good message', + }); + + expect(response.body).toEqual(discussionResponse); + }); + + test('Should call the controller with the right parameters when type not set to compliance-report', async () => { + const id = 'compliance-report-id-0'; + const message = 'test reply'; + + const discussion = { + text: message, + userId: 'user-id-0', + type: undefined, + }; + + await supertest(app).post(`/discussions`).send({ + message, + id, + }); + + expect(discussionControllerMock.create).toBeCalledWith(discussion); + }); + + test('Should call the controller with the right parameters when type is set to compliance-report', async () => { + const id = 'compliance-report-id-0'; + const message = 'test reply'; + const type = 'compliance-report'; + + await supertest(app).post(`/discussions`).send({ + message, + id, + type, + }); + + const discussion = { + text: message, + userId: 'user-id-0', + type, + complianceReportId: 'compliance-report-id-0', + }; + + expect(discussionControllerMock.create).toBeCalledWith(discussion); + }); + + test('Should not accept discussion message over 256 characters', async () => { + const id = 'compliance-report-id-0'; + const message = 'A'.repeat(257); + + const response = await supertest(app).post(`/discussions`).send({ + message, + id, + }); + + expect(response.status).toBe(400); + }); + }); }); diff --git a/apps/storybook/src/TeamProfileWorkspace.stories.tsx b/apps/storybook/src/TeamProfileWorkspace.stories.tsx index 9986ebb191..8691ef214f 100644 --- a/apps/storybook/src/TeamProfileWorkspace.stories.tsx +++ b/apps/storybook/src/TeamProfileWorkspace.stories.tsx @@ -25,8 +25,12 @@ export const Normal = () => ( isComplianceReviewer={false} onUpdateManuscript={() => Promise.resolve(createManuscriptResponse())} getDiscussion={() => createDiscussionResponse()} - onReplyToDiscussion={() => Promise.resolve()} + onSave={() => Promise.resolve()} teamId={'team-id'} grantId={'grant-id'} + createComplianceDiscussion={() => + Promise.resolve('compliance-discussion-id') + } + useVersionById={() => [undefined, () => {}]} /> ); diff --git a/packages/contentful/migrations/crn/complianceReports/20241209143327-add-discussion-field.js b/packages/contentful/migrations/crn/complianceReports/20241209143327-add-discussion-field.js new file mode 100644 index 0000000000..c8a404fe25 --- /dev/null +++ b/packages/contentful/migrations/crn/complianceReports/20241209143327-add-discussion-field.js @@ -0,0 +1,25 @@ +module.exports.description = 'Add discussion to compliance reports'; + +module.exports.up = (migration) => { + const complianceReports = migration.editContentType('complianceReports'); + + complianceReports + .createField('discussion') + .name('Discussion') + .type('Link') + .localized(false) + .required(false) + .validations([ + { + linkContentType: ['discussions'], + }, + ]) + .disabled(false) + .omitted(false) + .linkType('Entry'); +}; + +module.exports.down = (migration) => { + const complianceReports = migration.editContentType('complianceReports'); + complianceReports.deleteField('discussion'); +}; diff --git a/packages/contentful/src/crn/autogenerated-gql/gql.ts b/packages/contentful/src/crn/autogenerated-gql/gql.ts index a89cb7599d..7820b77372 100644 --- a/packages/contentful/src/crn/autogenerated-gql/gql.ts +++ b/packages/contentful/src/crn/autogenerated-gql/gql.ts @@ -79,7 +79,7 @@ const documents = { types.FetchInterestGroupsByUserIdDocument, '\n query FetchLabs($limit: Int, $skip: Int, $where: LabsFilter) {\n labsCollection(limit: $limit, skip: $skip, where: $where, order: name_ASC) {\n total\n items {\n sys {\n id\n }\n name\n }\n }\n }\n': types.FetchLabsDocument, - '\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n status\n count\n versionsCollection(limit: 20, order: sys_firstPublishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\n description\n count\n manuscriptFile {\n sys {\n id\n }\n fileName\n url\n }\n keyResourceTable {\n sys {\n id\n }\n fileName\n url\n }\n additionalFilesCollection(limit: 10) {\n items {\n sys {\n id\n }\n fileName\n url\n }\n }\n preprintDoi\n publicationDoi\n requestingApcCoverage\n submitterName\n submissionDate\n otherDetails\n acknowledgedGrantNumber\n acknowledgedGrantNumberDetails {\n ...DiscussionsContent\n }\n asapAffiliationIncluded\n asapAffiliationIncludedDetails {\n ...DiscussionsContent\n }\n manuscriptLicense\n manuscriptLicenseDetails {\n ...DiscussionsContent\n }\n datasetsDeposited\n datasetsDepositedDetails {\n ...DiscussionsContent\n }\n codeDeposited\n codeDepositedDetails {\n ...DiscussionsContent\n }\n protocolsDeposited\n protocolsDepositedDetails {\n ...DiscussionsContent\n }\n labMaterialsRegistered\n labMaterialsRegisteredDetails {\n ...DiscussionsContent\n }\n availabilityStatement\n availabilityStatementDetails {\n ...DiscussionsContent\n }\n teamsCollection(limit: 10) {\n items {\n sys {\n id\n }\n displayName\n inactiveSince\n }\n }\n labsCollection(limit: 10) {\n items {\n sys {\n id\n }\n name\n }\n }\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n updatedBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n firstAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n additionalAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n correspondingAuthorCollection(limit: 1) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n linkedFrom {\n complianceReportsCollection(limit: 1) {\n items {\n sys {\n firstPublishedAt\n }\n url\n description\n count\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n }\n }\n }\n }\n }\n }\n \n': + '\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n status\n count\n versionsCollection(limit: 20, order: sys_firstPublishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\n description\n count\n manuscriptFile {\n sys {\n id\n }\n fileName\n url\n }\n keyResourceTable {\n sys {\n id\n }\n fileName\n url\n }\n additionalFilesCollection(limit: 10) {\n items {\n sys {\n id\n }\n fileName\n url\n }\n }\n preprintDoi\n publicationDoi\n requestingApcCoverage\n submitterName\n submissionDate\n otherDetails\n acknowledgedGrantNumber\n acknowledgedGrantNumberDetails {\n ...DiscussionsContent\n }\n asapAffiliationIncluded\n asapAffiliationIncludedDetails {\n ...DiscussionsContent\n }\n manuscriptLicense\n manuscriptLicenseDetails {\n ...DiscussionsContent\n }\n datasetsDeposited\n datasetsDepositedDetails {\n ...DiscussionsContent\n }\n codeDeposited\n codeDepositedDetails {\n ...DiscussionsContent\n }\n protocolsDeposited\n protocolsDepositedDetails {\n ...DiscussionsContent\n }\n labMaterialsRegistered\n labMaterialsRegisteredDetails {\n ...DiscussionsContent\n }\n availabilityStatement\n availabilityStatementDetails {\n ...DiscussionsContent\n }\n teamsCollection(limit: 10) {\n items {\n sys {\n id\n }\n displayName\n inactiveSince\n }\n }\n labsCollection(limit: 10) {\n items {\n sys {\n id\n }\n name\n }\n }\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n updatedBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n firstAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n additionalAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n correspondingAuthorCollection(limit: 1) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n linkedFrom {\n complianceReportsCollection(limit: 1) {\n items {\n sys {\n id\n firstPublishedAt\n }\n url\n description\n count\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n discussion {\n sys {\n id\n }\n }\n }\n }\n }\n }\n }\n }\n \n': types.ManuscriptsContentFragmentDoc, '\n query FetchManuscriptById($id: String!, $fetchReplies: Boolean = false) {\n manuscripts(id: $id) {\n ...ManuscriptsContent\n teamsCollection(limit: 10) {\n items {\n sys {\n id\n }\n }\n }\n }\n }\n \n': types.FetchManuscriptByIdDocument, @@ -359,8 +359,8 @@ export function gql( * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql( - source: '\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n status\n count\n versionsCollection(limit: 20, order: sys_firstPublishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\n description\n count\n manuscriptFile {\n sys {\n id\n }\n fileName\n url\n }\n keyResourceTable {\n sys {\n id\n }\n fileName\n url\n }\n additionalFilesCollection(limit: 10) {\n items {\n sys {\n id\n }\n fileName\n url\n }\n }\n preprintDoi\n publicationDoi\n requestingApcCoverage\n submitterName\n submissionDate\n otherDetails\n acknowledgedGrantNumber\n acknowledgedGrantNumberDetails {\n ...DiscussionsContent\n }\n asapAffiliationIncluded\n asapAffiliationIncludedDetails {\n ...DiscussionsContent\n }\n manuscriptLicense\n manuscriptLicenseDetails {\n ...DiscussionsContent\n }\n datasetsDeposited\n datasetsDepositedDetails {\n ...DiscussionsContent\n }\n codeDeposited\n codeDepositedDetails {\n ...DiscussionsContent\n }\n protocolsDeposited\n protocolsDepositedDetails {\n ...DiscussionsContent\n }\n labMaterialsRegistered\n labMaterialsRegisteredDetails {\n ...DiscussionsContent\n }\n availabilityStatement\n availabilityStatementDetails {\n ...DiscussionsContent\n }\n teamsCollection(limit: 10) {\n items {\n sys {\n id\n }\n displayName\n inactiveSince\n }\n }\n labsCollection(limit: 10) {\n items {\n sys {\n id\n }\n name\n }\n }\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n updatedBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n firstAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n additionalAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n correspondingAuthorCollection(limit: 1) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n linkedFrom {\n complianceReportsCollection(limit: 1) {\n items {\n sys {\n firstPublishedAt\n }\n url\n description\n count\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n }\n }\n }\n }\n }\n }\n \n', -): (typeof documents)['\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n status\n count\n versionsCollection(limit: 20, order: sys_firstPublishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\n description\n count\n manuscriptFile {\n sys {\n id\n }\n fileName\n url\n }\n keyResourceTable {\n sys {\n id\n }\n fileName\n url\n }\n additionalFilesCollection(limit: 10) {\n items {\n sys {\n id\n }\n fileName\n url\n }\n }\n preprintDoi\n publicationDoi\n requestingApcCoverage\n submitterName\n submissionDate\n otherDetails\n acknowledgedGrantNumber\n acknowledgedGrantNumberDetails {\n ...DiscussionsContent\n }\n asapAffiliationIncluded\n asapAffiliationIncludedDetails {\n ...DiscussionsContent\n }\n manuscriptLicense\n manuscriptLicenseDetails {\n ...DiscussionsContent\n }\n datasetsDeposited\n datasetsDepositedDetails {\n ...DiscussionsContent\n }\n codeDeposited\n codeDepositedDetails {\n ...DiscussionsContent\n }\n protocolsDeposited\n protocolsDepositedDetails {\n ...DiscussionsContent\n }\n labMaterialsRegistered\n labMaterialsRegisteredDetails {\n ...DiscussionsContent\n }\n availabilityStatement\n availabilityStatementDetails {\n ...DiscussionsContent\n }\n teamsCollection(limit: 10) {\n items {\n sys {\n id\n }\n displayName\n inactiveSince\n }\n }\n labsCollection(limit: 10) {\n items {\n sys {\n id\n }\n name\n }\n }\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n updatedBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n firstAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n additionalAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n correspondingAuthorCollection(limit: 1) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n linkedFrom {\n complianceReportsCollection(limit: 1) {\n items {\n sys {\n firstPublishedAt\n }\n url\n description\n count\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n }\n }\n }\n }\n }\n }\n \n']; + source: '\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n status\n count\n versionsCollection(limit: 20, order: sys_firstPublishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\n description\n count\n manuscriptFile {\n sys {\n id\n }\n fileName\n url\n }\n keyResourceTable {\n sys {\n id\n }\n fileName\n url\n }\n additionalFilesCollection(limit: 10) {\n items {\n sys {\n id\n }\n fileName\n url\n }\n }\n preprintDoi\n publicationDoi\n requestingApcCoverage\n submitterName\n submissionDate\n otherDetails\n acknowledgedGrantNumber\n acknowledgedGrantNumberDetails {\n ...DiscussionsContent\n }\n asapAffiliationIncluded\n asapAffiliationIncludedDetails {\n ...DiscussionsContent\n }\n manuscriptLicense\n manuscriptLicenseDetails {\n ...DiscussionsContent\n }\n datasetsDeposited\n datasetsDepositedDetails {\n ...DiscussionsContent\n }\n codeDeposited\n codeDepositedDetails {\n ...DiscussionsContent\n }\n protocolsDeposited\n protocolsDepositedDetails {\n ...DiscussionsContent\n }\n labMaterialsRegistered\n labMaterialsRegisteredDetails {\n ...DiscussionsContent\n }\n availabilityStatement\n availabilityStatementDetails {\n ...DiscussionsContent\n }\n teamsCollection(limit: 10) {\n items {\n sys {\n id\n }\n displayName\n inactiveSince\n }\n }\n labsCollection(limit: 10) {\n items {\n sys {\n id\n }\n name\n }\n }\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n updatedBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n firstAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n additionalAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n correspondingAuthorCollection(limit: 1) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n linkedFrom {\n complianceReportsCollection(limit: 1) {\n items {\n sys {\n id\n firstPublishedAt\n }\n url\n description\n count\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n discussion {\n sys {\n id\n }\n }\n }\n }\n }\n }\n }\n }\n \n', +): (typeof documents)['\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n status\n count\n versionsCollection(limit: 20, order: sys_firstPublishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\n description\n count\n manuscriptFile {\n sys {\n id\n }\n fileName\n url\n }\n keyResourceTable {\n sys {\n id\n }\n fileName\n url\n }\n additionalFilesCollection(limit: 10) {\n items {\n sys {\n id\n }\n fileName\n url\n }\n }\n preprintDoi\n publicationDoi\n requestingApcCoverage\n submitterName\n submissionDate\n otherDetails\n acknowledgedGrantNumber\n acknowledgedGrantNumberDetails {\n ...DiscussionsContent\n }\n asapAffiliationIncluded\n asapAffiliationIncludedDetails {\n ...DiscussionsContent\n }\n manuscriptLicense\n manuscriptLicenseDetails {\n ...DiscussionsContent\n }\n datasetsDeposited\n datasetsDepositedDetails {\n ...DiscussionsContent\n }\n codeDeposited\n codeDepositedDetails {\n ...DiscussionsContent\n }\n protocolsDeposited\n protocolsDepositedDetails {\n ...DiscussionsContent\n }\n labMaterialsRegistered\n labMaterialsRegisteredDetails {\n ...DiscussionsContent\n }\n availabilityStatement\n availabilityStatementDetails {\n ...DiscussionsContent\n }\n teamsCollection(limit: 10) {\n items {\n sys {\n id\n }\n displayName\n inactiveSince\n }\n }\n labsCollection(limit: 10) {\n items {\n sys {\n id\n }\n name\n }\n }\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n updatedBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n firstAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n additionalAuthorsCollection(limit: 15) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n correspondingAuthorCollection(limit: 1) {\n items {\n __typename\n ... on ExternalAuthors {\n sys {\n id\n }\n name\n email\n }\n ... on Users {\n sys {\n id\n }\n avatar {\n url\n }\n firstName\n lastName\n nickname\n email\n }\n }\n }\n linkedFrom {\n complianceReportsCollection(limit: 1) {\n items {\n sys {\n id\n firstPublishedAt\n }\n url\n description\n count\n createdBy {\n sys {\n id\n }\n firstName\n nickname\n lastName\n alumniSinceDate\n avatar {\n url\n }\n teamsCollection(limit: 3) {\n items {\n team {\n sys {\n id\n }\n displayName\n }\n }\n }\n }\n discussion {\n sys {\n id\n }\n }\n }\n }\n }\n }\n }\n }\n \n']; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/contentful/src/crn/autogenerated-gql/graphql.ts b/packages/contentful/src/crn/autogenerated-gql/graphql.ts index 13b72f6a6f..88ad9e2385 100644 --- a/packages/contentful/src/crn/autogenerated-gql/graphql.ts +++ b/packages/contentful/src/crn/autogenerated-gql/graphql.ts @@ -613,6 +613,7 @@ export type ComplianceReports = Entry & count?: Maybe; createdBy?: Maybe; description?: Maybe; + discussion?: Maybe; linkedFrom?: Maybe; manuscriptVersion?: Maybe; sys: Sys; @@ -636,6 +637,13 @@ export type ComplianceReportsDescriptionArgs = { locale?: InputMaybe; }; +/** [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/complianceReports) */ +export type ComplianceReportsDiscussionArgs = { + locale?: InputMaybe; + preview?: InputMaybe; + where?: InputMaybe; +}; + /** [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/complianceReports) */ export type ComplianceReportsLinkedFromArgs = { allowedLocales?: InputMaybe>>; @@ -682,6 +690,8 @@ export type ComplianceReportsFilter = { description_not?: InputMaybe; description_not_contains?: InputMaybe; description_not_in?: InputMaybe>>; + discussion?: InputMaybe; + discussion_exists?: InputMaybe; manuscriptVersion?: InputMaybe; manuscriptVersion_exists?: InputMaybe; sys?: InputMaybe; @@ -1276,10 +1286,23 @@ export type DiscussionsFilter = { }; export type DiscussionsLinkingCollections = { + complianceReportsCollection?: Maybe; entryCollection?: Maybe; manuscriptVersionsCollection?: Maybe; }; +export type DiscussionsLinkingCollectionsComplianceReportsCollectionArgs = { + limit?: InputMaybe; + locale?: InputMaybe; + order?: InputMaybe< + Array< + InputMaybe + > + >; + preview?: InputMaybe; + skip?: InputMaybe; +}; + export type DiscussionsLinkingCollectionsEntryCollectionArgs = { limit?: InputMaybe; locale?: InputMaybe; @@ -1299,6 +1322,21 @@ export type DiscussionsLinkingCollectionsManuscriptVersionsCollectionArgs = { skip?: InputMaybe; }; +export enum DiscussionsLinkingCollectionsComplianceReportsCollectionOrder { + CountAsc = 'count_ASC', + CountDesc = 'count_DESC', + SysFirstPublishedAtAsc = 'sys_firstPublishedAt_ASC', + SysFirstPublishedAtDesc = 'sys_firstPublishedAt_DESC', + SysIdAsc = 'sys_id_ASC', + SysIdDesc = 'sys_id_DESC', + SysPublishedAtAsc = 'sys_publishedAt_ASC', + SysPublishedAtDesc = 'sys_publishedAt_DESC', + SysPublishedVersionAsc = 'sys_publishedVersion_ASC', + SysPublishedVersionDesc = 'sys_publishedVersion_DESC', + UrlAsc = 'url_ASC', + UrlDesc = 'url_DESC', +} + export enum DiscussionsLinkingCollectionsManuscriptVersionsCollectionOrder { AcknowledgedGrantNumberAsc = 'acknowledgedGrantNumber_ASC', AcknowledgedGrantNumberDesc = 'acknowledgedGrantNumber_DESC', @@ -18315,7 +18353,7 @@ export type ManuscriptsContentFragment = Pick< items: Array< Maybe< Pick & { - sys: Pick; + sys: Pick; createdBy?: Maybe< Pick< Users, @@ -18339,6 +18377,7 @@ export type ManuscriptsContentFragment = Pick< }>; } >; + discussion?: Maybe<{ sys: Pick }>; } > >; @@ -19013,7 +19052,7 @@ export type FetchManuscriptByIdQuery = { ComplianceReports, 'url' | 'description' | 'count' > & { - sys: Pick; + sys: Pick; createdBy?: Maybe< Pick< Users, @@ -19037,6 +19076,7 @@ export type FetchManuscriptByIdQuery = { }>; } >; + discussion?: Maybe<{ sys: Pick }>; } > >; @@ -21360,7 +21400,7 @@ export type FetchTeamByIdQuery = { ComplianceReports, 'url' | 'description' | 'count' > & { - sys: Pick; + sys: Pick; createdBy?: Maybe< Pick< Users, @@ -21384,6 +21424,7 @@ export type FetchTeamByIdQuery = { }>; } >; + discussion?: Maybe<{ sys: Pick }>; } > >; @@ -26578,6 +26619,13 @@ export const ManuscriptsContentFragmentDoc = { selectionSet: { kind: 'SelectionSet', selections: [ + { + kind: 'Field', + name: { + kind: 'Name', + value: 'id', + }, + }, { kind: 'Field', name: { @@ -26764,6 +26812,37 @@ export const ManuscriptsContentFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { + kind: 'Name', + value: 'discussion', + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { + kind: 'Name', + value: 'sys', + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { + kind: 'Name', + value: 'id', + }, + }, + ], + }, + }, + ], + }, + }, ], }, }, diff --git a/packages/contentful/src/crn/queries/manuscript.queries.ts b/packages/contentful/src/crn/queries/manuscript.queries.ts index d5cf2ea02d..e8d6da2610 100644 --- a/packages/contentful/src/crn/queries/manuscript.queries.ts +++ b/packages/contentful/src/crn/queries/manuscript.queries.ts @@ -220,6 +220,7 @@ export const manuscriptContentQueryFragment = gql` complianceReportsCollection(limit: 1) { items { sys { + id firstPublishedAt } url @@ -247,6 +248,11 @@ export const manuscriptContentQueryFragment = gql` } } } + discussion { + sys { + id + } + } } } } diff --git a/packages/contentful/src/crn/schema/autogenerated-schema.graphql b/packages/contentful/src/crn/schema/autogenerated-schema.graphql index b140286384..930a845029 100644 --- a/packages/contentful/src/crn/schema/autogenerated-schema.graphql +++ b/packages/contentful/src/crn/schema/autogenerated-schema.graphql @@ -378,6 +378,7 @@ type ComplianceReports implements Entry & _Node { count(locale: String): Int createdBy(locale: String, preview: Boolean, where: UsersFilter): Users description(locale: String): String + discussion(locale: String, preview: Boolean, where: DiscussionsFilter): Discussions linkedFrom(allowedLocales: [String]): ComplianceReportsLinkingCollections manuscriptVersion(locale: String, preview: Boolean, where: ManuscriptVersionsFilter): ManuscriptVersions sys: Sys! @@ -413,6 +414,8 @@ input ComplianceReportsFilter { description_not: String description_not_contains: String description_not_in: [String] + discussion: cfDiscussionsNestedFilter + discussion_exists: Boolean manuscriptVersion: cfManuscriptVersionsNestedFilter manuscriptVersion_exists: Boolean sys: SysFilter @@ -898,10 +901,26 @@ input DiscussionsFilter { } type DiscussionsLinkingCollections { + complianceReportsCollection(limit: Int = 100, locale: String, order: [DiscussionsLinkingCollectionsComplianceReportsCollectionOrder], preview: Boolean, skip: Int = 0): ComplianceReportsCollection entryCollection(limit: Int = 100, locale: String, preview: Boolean, skip: Int = 0): EntryCollection manuscriptVersionsCollection(limit: Int = 100, locale: String, order: [DiscussionsLinkingCollectionsManuscriptVersionsCollectionOrder], preview: Boolean, skip: Int = 0): ManuscriptVersionsCollection } +enum DiscussionsLinkingCollectionsComplianceReportsCollectionOrder { + count_ASC + count_DESC + sys_firstPublishedAt_ASC + sys_firstPublishedAt_DESC + sys_id_ASC + sys_id_DESC + sys_publishedAt_ASC + sys_publishedAt_DESC + sys_publishedVersion_ASC + sys_publishedVersion_DESC + url_ASC + url_DESC +} + enum DiscussionsLinkingCollectionsManuscriptVersionsCollectionOrder { acknowledgedGrantNumber_ASC acknowledgedGrantNumber_DESC diff --git a/packages/model/src/compliance-report.ts b/packages/model/src/compliance-report.ts index c5b36f56b5..67a97a8890 100644 --- a/packages/model/src/compliance-report.ts +++ b/packages/model/src/compliance-report.ts @@ -1,6 +1,7 @@ import { UserResponse } from './user'; export type ComplianceReportDataObject = { + id?: string; url: string; description: string; count: number; @@ -16,15 +17,19 @@ export type ComplianceReportDataObject = { > & { teams: { id: string; name: string }[]; }; + discussionId?: string; + versionId?: string; + manuscriptId?: string; }; export type ComplianceReportResponse = ComplianceReportDataObject; export type ComplianceReportFormData = Omit< ComplianceReportDataObject, - 'count' | 'createdDate' | 'createdBy' + 'count' | 'createdDate' | 'createdBy' | 'id' >; export type ComplianceReportCreateDataObject = ComplianceReportFormData & { manuscriptVersionId: string; userId: string; + discussionId?: string; }; export type ComplianceReportPostRequest = Omit< ComplianceReportCreateDataObject, diff --git a/packages/model/src/discussion.ts b/packages/model/src/discussion.ts index 78a76f95f8..7d53df64e5 100644 --- a/packages/model/src/discussion.ts +++ b/packages/model/src/discussion.ts @@ -22,17 +22,27 @@ export type DiscussionDataObject = { replies?: Message[]; }; +export type DiscussionType = 'compliance-report' | ''; + export type MessageCreateDataObject = { text: string; userId: string; + complianceReportId?: string; + type?: DiscussionType; }; export type DiscussionUpdateDataObject = { reply: MessageCreateDataObject; }; -export type DiscussionPatchRequest = { - replyText: string; +export type DiscussionRequest = { + text: string; +}; + +export type DiscussionCreateRequest = { + message: string; + id: string; + type: DiscussionType; }; export type DiscussionResponse = DiscussionDataObject; diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index ef2dd3fe77..1c43310ac0 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -166,7 +166,7 @@ export { ProfileCardList, ProfileExpertiseAndResources, QuestionsSection, - QuickCheckReplyModal, + DiscussionModal, RecentSharedOutputs, RelatedEventsCard, RelatedResearchCard, diff --git a/packages/react-components/src/molecules/Discussion.tsx b/packages/react-components/src/molecules/Discussion.tsx index 44e9941383..37a96eca3e 100644 --- a/packages/react-components/src/molecules/Discussion.tsx +++ b/packages/react-components/src/molecules/Discussion.tsx @@ -4,17 +4,18 @@ import { ComponentProps, FC, useState } from 'react'; import { UserAvatarList } from '..'; import { Button } from '../atoms'; import { minusRectIcon, plusRectIcon, replyIcon } from '../icons'; -import { QuickCheckReplyModal } from '../organisms'; +import { DiscussionModal } from '../organisms'; import { rem } from '../pixels'; import UserComment from './UserComment'; type DiscussionProps = Pick< - ComponentProps, - 'onReplyToDiscussion' + ComponentProps, + 'onSave' > & { id: string; canReply: boolean; + modalTitle: string; getDiscussion: (id: string) => DiscussionDataObject | undefined; }; @@ -41,8 +42,9 @@ const replyAvatarsStyles = css({ const Discussion: FC = ({ id, canReply, + modalTitle, getDiscussion, - onReplyToDiscussion, + onSave, }) => { const discussion = getDiscussion(id); const [replyToDiscussion, setReplyToDiscussion] = useState(false); @@ -57,10 +59,13 @@ const Discussion: FC = ({ return ( <> {replyToDiscussion && ( - setReplyToDiscussion(false)} discussionId={id} - onReplyToDiscussion={onReplyToDiscussion} + onSave={onSave} /> )} diff --git a/packages/react-components/src/molecules/__tests__/Discussion.test.tsx b/packages/react-components/src/molecules/__tests__/Discussion.test.tsx index 0fb60357e5..1f4868280b 100644 --- a/packages/react-components/src/molecules/__tests__/Discussion.test.tsx +++ b/packages/react-components/src/molecules/__tests__/Discussion.test.tsx @@ -10,9 +10,10 @@ import Discussion from '../Discussion'; const props: ComponentProps = { id: 'discussion-id', + modalTitle: 'Reply to quick check', canReply: true, getDiscussion: jest.fn().mockReturnValue(createDiscussionResponse()), - onReplyToDiscussion: jest.fn(), + onSave: jest.fn(), }; it('handles case when discussion is not found', () => { diff --git a/packages/react-components/src/organisms/ComplianceReportCard.tsx b/packages/react-components/src/organisms/ComplianceReportCard.tsx index 66b25cc2ff..9f763b2e70 100644 --- a/packages/react-components/src/organisms/ComplianceReportCard.tsx +++ b/packages/react-components/src/organisms/ComplianceReportCard.tsx @@ -1,6 +1,12 @@ -import { ComplianceReportResponse } from '@asap-hub/model'; +import { + ComplianceReportResponse, + DiscussionRequest, + DiscussionDataObject, + ManuscriptVersion, + ComplianceReportDataObject, +} from '@asap-hub/model'; import { css } from '@emotion/react'; -import { useState } from 'react'; +import { memo, Suspense, useEffect, useRef, useState } from 'react'; import { Button, minusRectIcon, @@ -14,13 +20,28 @@ import { ExpandableText, Caption, formatDate, + Loading, + replyIcon, + Divider, + DiscussionModal, } from '..'; import { paddingStyles } from '../card'; +import { Discussion } from '../molecules'; import UserTeamInfo from '../molecules/UserTeamInfo'; import { mobileScreen, perRem, rem } from '../pixels'; import { getTeams, getUserHref } from './ManuscriptVersionCard'; -type ComplianceReportCardProps = ComplianceReportResponse; +type ComplianceReportCardProps = ComplianceReportResponse & { + createComplianceDiscussion: ( + complianceReportId: string, + message: string, + ) => Promise; + getDiscussion: (id: string) => DiscussionDataObject | undefined; + onSave: (id: string, data: DiscussionRequest) => Promise; + setVersion: ( + callback: (prev: ManuscriptVersion) => ManuscriptVersion, + ) => void; +}; const toastStyles = css({ padding: `${15 / perRem}em ${24 / perRem}em`, @@ -71,21 +92,66 @@ const userContainerStyles = css({ paddingTop: rem(32), }); +const buttonsWrapperStyles = css({ + display: 'flex', + alignItems: 'center', + gap: rem(8), +}); + +const startDiscussionButtonStyles = css({ + width: 'fit-content', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + maxWidth: 'fit-content', + height: rem(40), +}); + const ComplianceReportCard: React.FC = ({ + id, url, description, count, createdBy, createdDate, + discussionId, + manuscriptId, + versionId, + createComplianceDiscussion, + getDiscussion, + onSave, + setVersion, }) => { const [expanded, setExpanded] = useState(false); + const [startDiscussion, setStartDiscussion] = useState(false); + + const startedDiscussionIdRef = useRef(''); + + useEffect( + () => () => { + if (startedDiscussionIdRef.current) { + setVersion((prev) => ({ + ...prev, + complianceReport: { + ...(prev.complianceReport as ComplianceReportDataObject), + discussionId: startedDiscussionIdRef.current, + }, + })); + } + }, + [setVersion], + ); return (
- @@ -99,13 +165,75 @@ const ComplianceReportCard: React.FC = ({ -
- - - View Report - - +
+
+ + + View Report + + +
+ + {!discussionId && !startedDiscussionIdRef.current && ( + <> + + {startDiscussion && id && ( + setStartDiscussion(false)} + onSave={async ( + complianceReportId: string, + data: DiscussionRequest, + ) => { + if (!manuscriptId || !versionId) return; + const createdDiscussionId = + await createComplianceDiscussion( + complianceReportId, + data.text, + ); + startedDiscussionIdRef.current = createdDiscussionId; + }} + /> + )} + + )}
+ {(discussionId || startedDiscussionIdRef.current) && ( + <> + + Discussion Started + }> + + + + )}
Date added: @@ -126,4 +254,22 @@ const ComplianceReportCard: React.FC = ({ ); }; -export default ComplianceReportCard; +export default memo(ComplianceReportCard, (prevProps, props) => { + const { + createComplianceDiscussion: _createComplianceDiscussion, + getDiscussion: _getDiscussion, + onSave: _onSave, + setVersion: _setVersion, + ...restPrevProps + } = prevProps; + + const { + createComplianceDiscussion: _createComplianceDiscussionNew, + getDiscussion: _getDiscussionNew, + onSave: _onSaveNew, + setVersion: _setVersionNew, + ...restProps + } = props; + + return JSON.stringify(restPrevProps) === JSON.stringify(restProps); +}); diff --git a/packages/react-components/src/organisms/QuickCheckReplyModal.tsx b/packages/react-components/src/organisms/DiscussionModal.tsx similarity index 80% rename from packages/react-components/src/organisms/QuickCheckReplyModal.tsx rename to packages/react-components/src/organisms/DiscussionModal.tsx index 896c441c73..bdecccce07 100644 --- a/packages/react-components/src/organisms/QuickCheckReplyModal.tsx +++ b/packages/react-components/src/organisms/DiscussionModal.tsx @@ -1,4 +1,4 @@ -import { DiscussionPatchRequest } from '@asap-hub/model'; +import { DiscussionRequest } from '@asap-hub/model'; import { css } from '@emotion/react'; import { Controller, useForm } from 'react-hook-form'; @@ -53,27 +53,31 @@ const dismissButtonStyles = css({ }, }); -type QuickCheckReplyModalProps = { +type DiscussionModalProps = { + title: string; + editorLabel: string; + ruleMessage: string; onDismiss: () => void; discussionId: string; - onReplyToDiscussion: ( - id: string, - patch: DiscussionPatchRequest, - ) => Promise; + onSave: (id: string, data: DiscussionRequest) => Promise; }; -type QuickCheckReplyModalData = { - replyText: string; +type DiscussionModalData = { + text: string; }; -const QuickCheckReplyModal: React.FC = ({ - onDismiss, + +const DiscussionModal: React.FC = ({ + title, + editorLabel, + ruleMessage, discussionId, - onReplyToDiscussion, + onDismiss, + onSave, }) => { - const methods = useForm({ + const methods = useForm({ mode: 'onChange', defaultValues: { - replyText: '', + text: '', }, }); @@ -83,8 +87,8 @@ const QuickCheckReplyModal: React.FC = ({ handleSubmit, } = methods; - const onSubmit = async (data: QuickCheckReplyModalData) => { - await onReplyToDiscussion(discussionId, data); + const onSubmit = async (data: DiscussionModalData) => { + await onSave(discussionId, data); onDismiss(); }; @@ -97,22 +101,22 @@ const QuickCheckReplyModal: React.FC = ({ {crossIcon}
- Reply to quick check + {title}
( = ({ enabled={!isSubmitting && isValid} submit preventDefault={false} + data-testid="discussion-modal-submit" > Send @@ -147,4 +152,4 @@ const QuickCheckReplyModal: React.FC = ({ ); }; -export default QuickCheckReplyModal; +export default DiscussionModal; diff --git a/packages/react-components/src/organisms/ManuscriptCard.tsx b/packages/react-components/src/organisms/ManuscriptCard.tsx index bc3de08130..c1000f79b7 100644 --- a/packages/react-components/src/organisms/ManuscriptCard.tsx +++ b/packages/react-components/src/organisms/ManuscriptCard.tsx @@ -33,7 +33,7 @@ type ManuscriptCardProps = Pick< > & Pick< ComponentProps, - 'onReplyToDiscussion' | 'getDiscussion' + 'onSave' | 'getDiscussion' > & { user: User | null; teamId: string; @@ -46,6 +46,18 @@ type ManuscriptCardProps = Pick< manuscriptId: string, payload: ManuscriptPutRequest, ) => Promise; + createComplianceDiscussion: ( + complianceReportId: string, + message: string, + ) => Promise; + useVersionById: (args: { + teamId: string; + manuscriptId: string; + versionId: string; + }) => [ + ManuscriptVersion | undefined, + (callback: (prev: ManuscriptVersion) => ManuscriptVersion) => void, + ]; }; const manuscriptContainerStyles = css({ @@ -161,8 +173,10 @@ const ManuscriptCard: React.FC = ({ isActiveTeam, onUpdateManuscript, getDiscussion, - onReplyToDiscussion, + onSave, user, + createComplianceDiscussion, + useVersionById, }) => { const [displayConfirmStatusChangeModal, setDisplayConfirmStatusChangeModal] = useState(false); @@ -325,7 +339,7 @@ const ManuscriptCard: React.FC = ({
{versions.map((version, index) => ( = ({ } isActiveManuscript={isActiveManuscript} isTeamMember={isTeamMember} + createComplianceDiscussion={createComplianceDiscussion} + useVersionById={useVersionById} /> ))}
diff --git a/packages/react-components/src/organisms/ManuscriptVersionCard.tsx b/packages/react-components/src/organisms/ManuscriptVersionCard.tsx index f75982f094..b398c49e0b 100644 --- a/packages/react-components/src/organisms/ManuscriptVersionCard.tsx +++ b/packages/react-components/src/organisms/ManuscriptVersionCard.tsx @@ -24,7 +24,7 @@ import { Pill, PencilIcon, plusRectIcon, - QuickCheckReplyModal, + DiscussionModal, Subtitle, colors, } from '..'; @@ -45,7 +45,19 @@ type ManuscriptVersionCardProps = { isTeamMember: boolean; canEditManuscript: boolean; isActiveManuscript: boolean; -} & Pick, 'onReplyToDiscussion'> & + createComplianceDiscussion: ( + complianceReportId: string, + message: string, + ) => Promise; + useVersionById: (args: { + teamId: string; + manuscriptId: string; + versionId: string; + }) => [ + ManuscriptVersion | undefined, + (callback: (prev: ManuscriptVersion) => ManuscriptVersion) => void, + ]; +} & Pick, 'onSave'> & Pick, 'getDiscussion'>; const toastStyles = css({ @@ -235,18 +247,28 @@ export const getTeams = (teams: Message['createdBy']['teams']) => })); const ManuscriptVersionCard: React.FC = ({ - version, + version: versionProp, teamId, teamIdCode, grantId, manuscriptCount, - onReplyToDiscussion, + onSave, getDiscussion, manuscriptId, isTeamMember, isActiveManuscript, canEditManuscript, + createComplianceDiscussion, + useVersionById, }) => { + const [versionData, setVersion] = useVersionById({ + teamId, + manuscriptId, + versionId: versionProp.id, + }); + + const version = versionData ?? versionProp; + const history = useHistory(); const [expanded, setExpanded] = useState(false); @@ -288,11 +310,20 @@ const ManuscriptVersionCard: React.FC = ({ history.push(editManuscriptRoute); } }; + return ( <>
{version.complianceReport && ( - + )}
@@ -423,10 +454,11 @@ const ManuscriptVersionCard: React.FC = ({ {question} }> diff --git a/packages/react-components/src/organisms/__tests__/ComplianceReportCard.test.tsx b/packages/react-components/src/organisms/__tests__/ComplianceReportCard.test.tsx index a651b2c337..06e0817ee6 100644 --- a/packages/react-components/src/organisms/__tests__/ComplianceReportCard.test.tsx +++ b/packages/react-components/src/organisms/__tests__/ComplianceReportCard.test.tsx @@ -1,20 +1,31 @@ import { manuscriptAuthor } from '@asap-hub/fixtures'; -import { render } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { act } from 'react-dom/test-utils'; import ComplianceReportCard from '../ComplianceReportCard'; +const props = { + id: 'compliance-report-id', + url: 'http://example.com/', + description: 'compliance report description', + count: 1, + createdDate: '2024-12-10T20:36:54Z', + createdBy: { + ...manuscriptAuthor, + displayName: 'Test User', + id: 'test-user-id', + }, + versionId: 'version-id', + manuscriptId: 'manuscript-id', + createComplianceDiscussion: jest + .fn() + .mockImplementation(() => 'discussion-id'), + getDiscussion: jest.fn(), + setVersion: jest.fn(), + onSave: jest.fn(), +}; + it('displays compliance report description, url and creation details when expanded', () => { - const props = { - url: 'http://example.com/', - description: 'compliance report description', - count: 1, - createdDate: '2024-12-10T20:36:54Z', - createdBy: { - ...manuscriptAuthor, - displayName: 'Test User', - id: 'test-user-id', - }, - }; const { getByText, queryByText, getByRole, rerender } = render( , ); @@ -39,3 +50,35 @@ it('displays compliance report description, url and creation details when expand '/network/users/test-user-id', ); }); + +it('calls setVersion when component is unmouted and a discussion was created', async () => { + const { getByLabelText, findByText, unmount } = render( + , + ); + + await act(async () => { + userEvent.click(getByLabelText('Expand Report')); + userEvent.click(await findByText(/Start Discussion/i)); + }); + + const replyEditor = screen.getByTestId('editor'); + await act(async () => { + userEvent.click(replyEditor); + userEvent.tab(); + fireEvent.input(replyEditor, { data: 'New discussion message' }); + userEvent.tab(); + }); + + expect(await findByText(/Send/i)).toBeInTheDocument(); + await act(async () => { + userEvent.click(await findByText(/Send/i)); + }); + + await waitFor(() => { + expect(props.createComplianceDiscussion).toHaveBeenCalled(); + }); + + unmount(); + + expect(props.setVersion).toHaveBeenCalled(); +}); diff --git a/packages/react-components/src/organisms/__tests__/QuickCheckReplyModal.test.tsx b/packages/react-components/src/organisms/__tests__/DiscussionModal.test.tsx similarity index 78% rename from packages/react-components/src/organisms/__tests__/QuickCheckReplyModal.test.tsx rename to packages/react-components/src/organisms/__tests__/DiscussionModal.test.tsx index 4491e93248..62dfe9dc10 100644 --- a/packages/react-components/src/organisms/__tests__/QuickCheckReplyModal.test.tsx +++ b/packages/react-components/src/organisms/__tests__/DiscussionModal.test.tsx @@ -7,31 +7,29 @@ import { } from '@testing-library/react'; import { ComponentProps } from 'react'; import userEvent from '@testing-library/user-event'; -import QuickCheckReplyModal from '../QuickCheckReplyModal'; +import DiscussionModal from '../DiscussionModal'; const discussionId = 'discussion-id'; -const defaultProps: ComponentProps = { +const defaultProps: ComponentProps = { + title: 'Reply to quick check', + editorLabel: 'Reply', + ruleMessage: 'Reply cannot exceed 256 characters.', onDismiss: jest.fn(), discussionId, - onReplyToDiscussion: jest.fn(), + onSave: jest.fn(), }; it('renders the form', async () => { - render(); + render(); expect(await screen.findByText(/Reply to quick check/i)).toBeVisible(); expect(screen.getByRole('button', { name: /Send/i })).toBeVisible(); }); it('data is sent on form submission', async () => { - const onReplyToDiscussion = jest.fn(); - render( - , - ); + const onSave = jest.fn(); + render(); const replyEditor = screen.getByTestId('editor'); await act(async () => { @@ -45,14 +43,14 @@ it('data is sent on form submission', async () => { await waitFor(() => expect(shareButton).toBeEnabled()); userEvent.click(shareButton); await waitFor(() => { - expect(onReplyToDiscussion).toHaveBeenCalledWith(discussionId, { - replyText: 'test reply', + expect(onSave).toHaveBeenCalledWith(discussionId, { + text: 'test reply', }); }); }); it('send button is enabled when reply is provided', async () => { - render(); + render(); const sendButton = screen.getByRole('button', { name: /Send/i }); @@ -70,7 +68,7 @@ it('send button is enabled when reply is provided', async () => { }); it('displays error message when reply is bigger than 256 characters', async () => { - render(); + render(); const sendButton = screen.getByRole('button', { name: /Send/i }); diff --git a/packages/react-components/src/organisms/__tests__/ManuscriptCard.test.tsx b/packages/react-components/src/organisms/__tests__/ManuscriptCard.test.tsx index 538e0af44f..b16e553a70 100644 --- a/packages/react-components/src/organisms/__tests__/ManuscriptCard.test.tsx +++ b/packages/react-components/src/organisms/__tests__/ManuscriptCard.test.tsx @@ -3,7 +3,7 @@ import { createUserResponse, manuscriptAuthor, } from '@asap-hub/fixtures'; -import { UserTeam } from '@asap-hub/model'; +import { ManuscriptVersion, UserTeam } from '@asap-hub/model'; import { act, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ComponentProps } from 'react'; @@ -14,6 +14,16 @@ import ManuscriptCard, { isManuscriptLead, } from '../ManuscriptCard'; +const version = createManuscriptResponse().versions[0] as ManuscriptVersion; + +const mockVersionData = { + ...version, + complianceReport: { + ...version.complianceReport, + discussionId: 'discussion-id', + }, +}; + const baseUser = createUserResponse({}, 1); const props: ComponentProps = { ...createManuscriptResponse(), @@ -22,13 +32,16 @@ const props: ComponentProps = { grantId: '000123', isComplianceReviewer: false, onUpdateManuscript: jest.fn(), - onReplyToDiscussion: jest.fn(), + onSave: jest.fn(), getDiscussion: jest.fn(), isTeamMember: true, isActiveTeam: true, + createComplianceDiscussion: jest.fn(), + useVersionById: jest.fn(), }; const complianceReport = { + id: 'compliance-report-id', url: 'https://example.com', description: 'description', count: 1, @@ -108,8 +121,21 @@ describe('isManuscriptLead', () => { }); it('displays manuscript version card when expanded', () => { + const useVersionById = jest.fn(); + + useVersionById + .mockImplementation(() => [ + { + ...mockVersionData, + type: 'Original Research', + lifecycle: 'Preprint', + }, + jest.fn(), + ]) + .mockImplementationOnce(() => [mockVersionData, jest.fn()]); + const { getByText, queryByText, getByTestId, rerender } = render( - , + , ); expect(queryByText(/Original Research/i)).not.toBeInTheDocument(); @@ -128,6 +154,7 @@ it('displays manuscript version card when expanded', () => { lifecycle: 'Preprint', }, ]} + useVersionById={useVersionById} />, ); @@ -186,6 +213,7 @@ it('redirects to resubmit manuscript form when user clicks on Submit Revised Man const manuscriptVersions = createManuscriptResponse().versions; manuscriptVersions[0]!.firstAuthors = [user]; manuscriptVersions[0]!.complianceReport = { + id: 'compliance-report-id', url: 'https://example.com', description: 'test compliance report', count: 1, diff --git a/packages/react-components/src/organisms/__tests__/ManuscriptVersionCard.test.tsx b/packages/react-components/src/organisms/__tests__/ManuscriptVersionCard.test.tsx index a5cfe07ffc..90031a78e0 100644 --- a/packages/react-components/src/organisms/__tests__/ManuscriptVersionCard.test.tsx +++ b/packages/react-components/src/organisms/__tests__/ManuscriptVersionCard.test.tsx @@ -28,6 +28,10 @@ const setScrollHeightMock = (height: number) => { jest.spyOn(React, 'useRef').mockReturnValue(ref); }; +const useVersionById = jest + .fn() + .mockImplementation(() => [undefined, jest.fn()]); + afterAll(jest.clearAllMocks); const baseVersion = createManuscriptResponse().versions[0] as ManuscriptVersion; @@ -37,12 +41,14 @@ const props: ComponentProps = { teamIdCode: 'TI1', teamId: 'team-id-0', manuscriptCount: 1, - onReplyToDiscussion: jest.fn(), + onSave: jest.fn(), getDiscussion: jest.fn(), manuscriptId: 'manuscript-1', isTeamMember: true, canEditManuscript: true, isActiveManuscript: true, + createComplianceDiscussion: jest.fn(), + useVersionById: jest.fn(), }; it('displays quick checks when present', () => { @@ -101,7 +107,13 @@ it('displays quick checks when present', () => { ], }; const { getByText, queryByText, getByLabelText, rerender, getAllByText } = - render(); + render( + , + ); userEvent.click(getByLabelText('Expand Version')); expect( @@ -125,6 +137,7 @@ it('displays quick checks when present', () => { {...props} version={updatedVersion} getDiscussion={getDiscussion} + useVersionById={useVersionById} />, ); @@ -189,6 +202,7 @@ it('displays createdBy as fallback for updatedBy when updatedBy is well defined' id: '', }, }} + useVersionById={useVersionById} />, ); @@ -206,14 +220,22 @@ it('displays createdBy as fallback for updatedBy when updatedBy is well defined' describe('edit', () => { it('does not display the edit button when canEditManuscript is false', () => { const { queryByLabelText } = render( - , + , ); expect(queryByLabelText('Edit')).not.toBeInTheDocument(); }); it('does not display the edit button when isActiveManuscript is false', () => { const { queryByLabelText } = render( - , + , ); expect(queryByLabelText('Edit')).not.toBeInTheDocument(); }); @@ -224,7 +246,7 @@ describe('edit', () => { const { getByLabelText } = render( - + , ); userEvent.click(getByLabelText('Edit')); @@ -236,7 +258,7 @@ describe('edit', () => { it('displays Additional Information section when present', () => { const { getByRole, queryByRole, rerender, getByLabelText } = render( - , + , ); userEvent.click(getByLabelText('Expand Version')); expect( @@ -252,6 +274,7 @@ it('displays Additional Information section when present', () => { {...props} version={{ ...baseVersion, otherDetails: 'Necessary info' }} getDiscussion={getDiscussion} + useVersionById={useVersionById} />, ); @@ -271,6 +294,7 @@ it('renders a divider between fields in Additional Information section and files requestingApcCoverage: 'Already submitted', otherDetails: 'Necessary info', }} + useVersionById={useVersionById} />, ); @@ -286,7 +310,7 @@ it.each` ${'otherDetails'} | ${'Other details'} | ${'new details'} `(`displays field $field when present`, async ({ field, title, newValue }) => { const { getByLabelText, getByText, queryByText, rerender } = render( - , + , ); userEvent.click(getByLabelText('Expand Version')); expect(queryByText(title)).not.toBeInTheDocument(); @@ -296,7 +320,13 @@ it.each` [field]: newValue, }; - rerender(); + rerender( + , + ); expect(getByText(title)).toBeVisible(); expect(getByText(newValue)).toBeVisible(); @@ -320,6 +350,7 @@ it('builds the correct href for doi fields', () => { preprintDoi: preprintDoiValue, publicationDoi: publicationDoiValue, }} + useVersionById={useVersionById} />, ); userEvent.click(getByLabelText('Expand Version')); @@ -347,6 +378,7 @@ it('renders manuscript main file details and download link', () => { }, keyResourceTable: undefined, }} + useVersionById={useVersionById} />, ); userEvent.click(getByLabelText('Expand Version')); @@ -375,6 +407,7 @@ it('renders key resource table file details and download link', () => { id: 'file-2', }, }} + useVersionById={useVersionById} />, ); userEvent.click(getByLabelText('Expand Version')); @@ -396,6 +429,7 @@ it("does not display Submitter's Name and Submission Date if submitterName and s submissionDate: undefined, submitterName: undefined, }} + useVersionById={useVersionById} />, ); userEvent.click(getByLabelText('Expand Version')); @@ -418,6 +452,7 @@ it('displays apc coverage information', () => { submissionDate: new Date('2024-10-03'), submitterName: 'Janet Doe', }} + useVersionById={useVersionById} />, ); userEvent.click(getByLabelText('Expand Version')); @@ -452,6 +487,7 @@ it('renders additional files details and download link when provided', () => { }, ], }} + useVersionById={useVersionById} />, ); userEvent.click(getByLabelText('Expand Version')); @@ -464,13 +500,14 @@ it('renders additional files details and download link when provided', () => { }); it('displays compliance report section when present', () => { - const { getByLabelText, queryByRole, rerender, getByRole } = render( - , + const { getByLabelText, queryByRole, rerender, getByRole, unmount } = render( + , ); userEvent.click(getByLabelText('Expand Version')); expect( queryByRole('heading', { name: /Compliance Report/i }), ).not.toBeInTheDocument(); + unmount(); rerender( { ...baseVersion, complianceReport: getComplianceReportDataObject(), }} + useVersionById={useVersionById} />, ); @@ -498,6 +536,7 @@ it('displays manuscript description', () => { ...baseVersion, description: shortDescription, }} + useVersionById={useVersionById} />, ); userEvent.click(getByLabelText('Expand Version')); @@ -512,6 +551,7 @@ it('displays manuscript description', () => { ...baseVersion, description: longDescription, }} + useVersionById={useVersionById} />, ); @@ -554,6 +594,7 @@ it('does not display reply button if isActiveManuscript is false', () => { version={updatedVersion} getDiscussion={getDiscussion} isActiveManuscript={false} + useVersionById={useVersionById} />, ); userEvent.click(getByLabelText('Expand Version')); diff --git a/packages/react-components/src/organisms/index.ts b/packages/react-components/src/organisms/index.ts index 094250c561..287b336e5d 100644 --- a/packages/react-components/src/organisms/index.ts +++ b/packages/react-components/src/organisms/index.ts @@ -53,7 +53,7 @@ export { default as PerformanceCard } from './PerformanceCard'; export { default as ProfileCardList } from './ProfileCardList'; export { default as ProfileExpertiseAndResources } from './ProfileExpertiseAndResources'; export { default as QuestionsSection } from './QuestionsSection'; -export { default as QuickCheckReplyModal } from './QuickCheckReplyModal'; +export { default as DiscussionModal } from './DiscussionModal'; export { default as RecentSharedOutputs } from './RecentSharedOutputs'; export { default as RelatedEventsCard } from './RelatedEventsCard'; export { default as RelatedResearchCard } from './RelatedResearchCard'; diff --git a/packages/react-components/src/templates/TeamProfileWorkspace.tsx b/packages/react-components/src/templates/TeamProfileWorkspace.tsx index 7582ea3833..1337a2b62d 100644 --- a/packages/react-components/src/templates/TeamProfileWorkspace.tsx +++ b/packages/react-components/src/templates/TeamProfileWorkspace.tsx @@ -1,5 +1,5 @@ import { isEnabled } from '@asap-hub/flags'; -import { TeamResponse, TeamTool } from '@asap-hub/model'; +import { ManuscriptVersion, TeamResponse, TeamTool } from '@asap-hub/model'; import { useCurrentUserCRN } from '@asap-hub/react-context'; import { network } from '@asap-hub/routing'; import { css } from '@emotion/react'; @@ -115,12 +115,24 @@ type TeamProfileWorkspaceProps = Readonly< > & Pick< ComponentProps, - 'onReplyToDiscussion' | 'isComplianceReviewer' | 'getDiscussion' + 'onSave' | 'isComplianceReviewer' | 'getDiscussion' > & { readonly tools: ReadonlyArray; readonly onDeleteTool?: (toolIndex: number) => Promise; readonly setEligibilityReasons: (newEligibilityReason: Set) => void; readonly isTeamMember: boolean; + readonly createComplianceDiscussion: ( + complianceReportId: string, + message: string, + ) => Promise; + readonly useVersionById: (args: { + teamId: string; + manuscriptId: string; + versionId: string; + }) => [ + ManuscriptVersion | undefined, + (callback: (prev: ManuscriptVersion) => ManuscriptVersion) => void, + ]; }; const TeamProfileWorkspace: React.FC = ({ @@ -135,11 +147,13 @@ const TeamProfileWorkspace: React.FC = ({ collaborationManuscripts, tools, onDeleteTool, - onReplyToDiscussion, + onSave, getDiscussion, setEligibilityReasons, isComplianceReviewer = false, isTeamMember, + createComplianceDiscussion, + useVersionById, }) => { const [displayEligibilityModal, setDisplayEligibilityModal] = useState(false); const history = useHistory(); @@ -224,9 +238,13 @@ const TeamProfileWorkspace: React.FC = ({ isComplianceReviewer={isComplianceReviewer} isTeamMember={isTeamMember} onUpdateManuscript={onUpdateManuscript} - onReplyToDiscussion={onReplyToDiscussion} + onSave={onSave} getDiscussion={getDiscussion} isActiveTeam={!inactiveSince} + createComplianceDiscussion={ + createComplianceDiscussion + } + useVersionById={useVersionById} />
))} @@ -261,8 +279,12 @@ const TeamProfileWorkspace: React.FC = ({ isTeamMember={isTeamMember} isActiveTeam={!inactiveSince} onUpdateManuscript={onUpdateManuscript} - onReplyToDiscussion={onReplyToDiscussion} + onSave={onSave} getDiscussion={getDiscussion} + createComplianceDiscussion={ + createComplianceDiscussion + } + useVersionById={useVersionById} />
))} diff --git a/packages/react-components/src/templates/__tests__/TeamProfileWorkspace.test.tsx b/packages/react-components/src/templates/__tests__/TeamProfileWorkspace.test.tsx index 02c75985c1..4eef21ddd4 100644 --- a/packages/react-components/src/templates/__tests__/TeamProfileWorkspace.test.tsx +++ b/packages/react-components/src/templates/__tests__/TeamProfileWorkspace.test.tsx @@ -1,5 +1,9 @@ -import { createTeamResponse } from '@asap-hub/fixtures'; +import { + createManuscriptResponse, + createTeamResponse, +} from '@asap-hub/fixtures'; import { disable, enable } from '@asap-hub/flags'; +import { ManuscriptVersion } from '@asap-hub/model'; import { getByText as getChildByText, render, @@ -8,11 +12,13 @@ import { within, getByTestId, getByRole as getByRoleInContainer, + fireEvent, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; import { ComponentProps } from 'react'; import { Route, Router } from 'react-router-dom'; +import { act } from 'react-test-renderer'; import TeamProfileWorkspace from '../TeamProfileWorkspace'; @@ -24,9 +30,11 @@ const team: ComponentProps = { tools: [], isComplianceReviewer: false, onUpdateManuscript: jest.fn(), - onReplyToDiscussion: jest.fn(), + onSave: jest.fn(), getDiscussion: jest.fn(), isTeamMember: true, + createComplianceDiscussion: jest.fn(), + useVersionById: jest.fn().mockImplementation(() => [undefined, jest.fn()]), }; it('renders the team workspace page', () => { @@ -47,6 +55,8 @@ it('does not display Collaboration Tools section if user is not a team member', ).not.toBeInTheDocument(); }); +jest.setTimeout(30000); + describe('compliance section', () => { beforeAll(() => { enable('DISPLAY_MANUSCRIPTS'); @@ -392,6 +402,82 @@ describe('compliance section', () => { '/network/teams/t0/workspace/create-manuscript', ); }); + + it('opens modal to create new discussion on compliance report', async () => { + jest.spyOn(console, 'error').mockImplementation(); + const mockCreateComplianceDiscussion = jest + .fn() + .mockResolvedValue('new-discussion-id'); + const mockSetVersion = jest.fn(); + const version = { + ...createManuscriptResponse().versions[0], + complianceReport: { + id: 'compliance-report-id', + url: 'http://example.com/file.pdf', + description: 'A description', + count: 1, + createdDate: '2020-12-10T20:36:54Z', + createdBy: { + displayName: 'John Doe', + firstName: 'John', + lastName: 'Doe', + id: 'john-doe', + teams: [{ id: 'alessi', name: 'Alessi' }], + avatarUrl: '', + alumniSinceDate: undefined, + }, + }, + } as ManuscriptVersion; + + const teamWithManuscripts: ComponentProps = { + ...team, + manuscripts: [ + { + id: 'manuscript-id', + title: 'Nice manuscript', + count: 1, + versions: [version], + }, + ], + createComplianceDiscussion: mockCreateComplianceDiscussion, + useVersionById: jest + .fn() + .mockImplementation(() => [version, mockSetVersion]), + }; + + const { + getByTestId: localGetByTestId, + findByText, + getByLabelText, + unmount, + } = render(); + + await act(async () => { + userEvent.click(localGetByTestId('collapsible-button')); + userEvent.click(getByLabelText('Expand Report')); + userEvent.click(await findByText(/Start Discussion/i)); + }); + + const replyEditor = screen.getByTestId('editor'); + await act(async () => { + userEvent.click(replyEditor); + userEvent.tab(); + fireEvent.input(replyEditor, { data: 'New discussion message' }); + userEvent.tab(); + }); + + expect(await findByText(/Send/i)).toBeInTheDocument(); + + userEvent.click(await findByText(/Send/i)); + await waitFor(() => { + expect(mockCreateComplianceDiscussion).toHaveBeenCalledWith( + 'compliance-report-id', + 'New discussion message', + ); + }); + + unmount(); + }); }); it('renders contact project manager when point of contact provided', () => { @@ -493,6 +579,7 @@ describe('a tool', () => { }); it('has a delete button', async () => { + jest.spyOn(console, 'error').mockImplementation(); const handleDeleteTool = jest.fn(); const { getByText } = render(