diff --git a/.pnp.cjs b/.pnp.cjs index 698dded8a4..9f8be02c8c 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -9027,6 +9027,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "react-focus-lock",\ "virtual:e6a8db757cc098a75c904148222816291a2c8f0db104c23d146b3871bbe2ec45c1c89b53bd4b464eaf2679a6a005df432c7d8ffd613f302ca3d3d067c7147c15#npm:2.9.6"\ ],\ + [\ + "react-hook-form",\ + "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:7.51.4"\ + ],\ [\ "react-input-autosize",\ "virtual:800831ff2f901e956a697d9b1e4c860e9a81b33987ce32b240eaf4789b550513fb78cec1003e0d146a6c72371a991e42f4d5d1f2d9f8b0787115f0606f260b91#npm:3.0.0"\ @@ -12093,6 +12097,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["react-app-polyfill", "npm:3.0.0"],\ ["react-dom", "virtual:70ee702ae21962651e36bbfc38610149cb09f8829834f72efabf6ff499ef36bdf828f0ce38bc9269076bac68bed4fbc3b604042ba63016aed4c3a27d5308300f#npm:17.0.2"],\ ["react-error-boundary", "virtual:e53d29e2e4020291f2a68ca0ab4738c8fa7bf760678e5dfc54ff2f07fd5e64d6c5cee7c34f69b3038205c44d115808fe3f1a764fb932cd2f98c9b82b17d8a3ef#npm:3.1.4"],\ + ["react-hook-form", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:7.51.4"],\ ["react-router-dom", "virtual:70ee702ae21962651e36bbfc38610149cb09f8829834f72efabf6ff499ef36bdf828f0ce38bc9269076bac68bed4fbc3b604042ba63016aed4c3a27d5308300f#npm:5.3.4"],\ ["react-router-last-location", "virtual:9d902e8fa3d0aec40e001519f3af8204bbaacbb7348be367280bb310f9537e3019252456410d2b71e7b1075fd4a7eb26825f777fb5c7d4d6219fc027ca539fa7#npm:2.0.1"],\ ["react-test-renderer", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:17.0.2"],\ @@ -13109,6 +13114,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["ramda", "npm:0.27.1"],\ ["react", "npm:17.0.2"],\ ["react-dom", "virtual:70ee702ae21962651e36bbfc38610149cb09f8829834f72efabf6ff499ef36bdf828f0ce38bc9269076bac68bed4fbc3b604042ba63016aed4c3a27d5308300f#npm:17.0.2"],\ + ["react-hook-form", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:7.51.4"],\ ["react-router-dom", "virtual:70ee702ae21962651e36bbfc38610149cb09f8829834f72efabf6ff499ef36bdf828f0ce38bc9269076bac68bed4fbc3b604042ba63016aed4c3a27d5308300f#npm:5.3.4"],\ ["react-router-hash-link", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:2.4.3"],\ ["react-select", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:4.3.1"],\ @@ -13197,6 +13203,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["ramda", "npm:0.27.1"],\ ["react", "npm:17.0.2"],\ ["react-dom", "virtual:70ee702ae21962651e36bbfc38610149cb09f8829834f72efabf6ff499ef36bdf828f0ce38bc9269076bac68bed4fbc3b604042ba63016aed4c3a27d5308300f#npm:17.0.2"],\ + ["react-hook-form", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:7.51.4"],\ ["react-router-dom", "virtual:70ee702ae21962651e36bbfc38610149cb09f8829834f72efabf6ff499ef36bdf828f0ce38bc9269076bac68bed4fbc3b604042ba63016aed4c3a27d5308300f#npm:5.3.4"],\ ["react-router-hash-link", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:2.4.3"],\ ["react-select", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:4.3.1"],\ @@ -13289,6 +13296,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["ramda", "npm:0.27.1"],\ ["react", "npm:17.0.2"],\ ["react-dom", "virtual:70ee702ae21962651e36bbfc38610149cb09f8829834f72efabf6ff499ef36bdf828f0ce38bc9269076bac68bed4fbc3b604042ba63016aed4c3a27d5308300f#npm:17.0.2"],\ + ["react-hook-form", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:7.51.4"],\ ["react-router-dom", "virtual:70ee702ae21962651e36bbfc38610149cb09f8829834f72efabf6ff499ef36bdf828f0ce38bc9269076bac68bed4fbc3b604042ba63016aed4c3a27d5308300f#npm:5.3.4"],\ ["react-router-hash-link", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:2.4.3"],\ ["react-select", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:4.3.1"],\ @@ -13381,6 +13389,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["ramda", "npm:0.27.1"],\ ["react", "npm:17.0.2"],\ ["react-dom", "virtual:70ee702ae21962651e36bbfc38610149cb09f8829834f72efabf6ff499ef36bdf828f0ce38bc9269076bac68bed4fbc3b604042ba63016aed4c3a27d5308300f#npm:17.0.2"],\ + ["react-hook-form", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:7.51.4"],\ ["react-router-dom", "virtual:70ee702ae21962651e36bbfc38610149cb09f8829834f72efabf6ff499ef36bdf828f0ce38bc9269076bac68bed4fbc3b604042ba63016aed4c3a27d5308300f#npm:5.3.4"],\ ["react-router-hash-link", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:2.4.3"],\ ["react-select", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:4.3.1"],\ @@ -68098,6 +68107,28 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["react-hook-form", [\ + ["npm:7.51.4", {\ + "packageLocation": "./.yarn/cache/react-hook-form-npm-7.51.4-e976742d97-b3587c2342.zip/node_modules/react-hook-form/",\ + "packageDependencies": [\ + ["react-hook-form", "npm:7.51.4"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:7.51.4", {\ + "packageLocation": "./.yarn/__virtual__/react-hook-form-virtual-f640f4dd3e/0/cache/react-hook-form-npm-7.51.4-e976742d97-b3587c2342.zip/node_modules/react-hook-form/",\ + "packageDependencies": [\ + ["react-hook-form", "virtual:3a393e218825bde954376ca1a828a8b21ca2967b8d720dd56f28d8017fc081fa726c0b293069a94a55394c33a36ded19a9a4675c0d537b344c90f8add76eb926#npm:7.51.4"],\ + ["@types/react", "npm:17.0.65"],\ + ["react", "npm:17.0.2"]\ + ],\ + "packagePeers": [\ + "@types/react",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["react-input-autosize", [\ ["npm:3.0.0", {\ "packageLocation": "./.yarn/cache/react-input-autosize-npm-3.0.0-c2fd3b468a-cc3309ddc8.zip/node_modules/react-input-autosize/",\ diff --git a/.yarn/cache/react-hook-form-npm-7.51.4-e976742d97-b3587c2342.zip b/.yarn/cache/react-hook-form-npm-7.51.4-e976742d97-b3587c2342.zip new file mode 100644 index 0000000000..9b682ec07a Binary files /dev/null and b/.yarn/cache/react-hook-form-npm-7.51.4-e976742d97-b3587c2342.zip differ diff --git a/apps/crn-frontend/package.json b/apps/crn-frontend/package.json index 4dfbcdba6f..71b8f32492 100644 --- a/apps/crn-frontend/package.json +++ b/apps/crn-frontend/package.json @@ -41,6 +41,7 @@ "react-app-polyfill": "3.0.0", "react-dom": "17.0.2", "react-error-boundary": "3.1.4", + "react-hook-form": "^7.51.4", "react-router-dom": "5.3.4", "react-router-last-location": "2.0.1", "recoil": "0.7.7", diff --git a/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx b/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx new file mode 100644 index 0000000000..babeb1bec6 --- /dev/null +++ b/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx @@ -0,0 +1,34 @@ +import { Toast } from '@asap-hub/react-components'; +import React, { createContext, useState } from 'react'; + +type ManuscriptToastContextData = { + setShowSuccessBanner: React.Dispatch>; +}; + +export const ManuscriptToastContext = createContext( + {} as ManuscriptToastContextData, +); + +export const ManuscriptToastProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [showSuccessBanner, setShowSuccessBanner] = useState(false); + + return ( + + <> + {showSuccessBanner && ( + setShowSuccessBanner(false)} + > + Manuscript submitted successfully. + + )} + {children} + + + ); +}; diff --git a/apps/crn-frontend/src/network/teams/TeamManuscript.tsx b/apps/crn-frontend/src/network/teams/TeamManuscript.tsx new file mode 100644 index 0000000000..cb5795137f --- /dev/null +++ b/apps/crn-frontend/src/network/teams/TeamManuscript.tsx @@ -0,0 +1,38 @@ +import { Frame } from '@asap-hub/frontend-utils'; +import { + ManuscriptForm, + ManuscriptHeader, + usePushFromHere, +} from '@asap-hub/react-components'; +import { network } from '@asap-hub/routing'; +import { FormProvider, useForm } from 'react-hook-form'; +import { usePostManuscript } from './state'; +import { useManuscriptToast } from './useManuscriptToast'; + +type TeamManuscriptProps = { + teamId: string; +}; +const TeamManuscript: React.FC = ({ teamId }) => { + const { setShowSuccessBanner } = useManuscriptToast(); + const form = useForm(); + const createManuscript = usePostManuscript(); + + const pushFromHere = usePushFromHere(); + + const onSuccess = () => { + const path = network({}).teams({}).team({ teamId }).workspace({}).$; + + setShowSuccessBanner(true); + pushFromHere(path); + }; + + return ( + + + + + + + ); +}; +export default TeamManuscript; diff --git a/apps/crn-frontend/src/network/teams/TeamProfile.tsx b/apps/crn-frontend/src/network/teams/TeamProfile.tsx index 7be5a93929..74ebb5fd97 100644 --- a/apps/crn-frontend/src/network/teams/TeamProfile.tsx +++ b/apps/crn-frontend/src/network/teams/TeamProfile.tsx @@ -18,7 +18,9 @@ import { import { useUpcomingAndPastEvents } from '../events'; import ProfileSwitch from '../ProfileSwitch'; +import { ManuscriptToastProvider } from './ManuscriptToastProvider'; import { useTeamById } from './state'; +import TeamManuscript from './TeamManuscript'; const loadAbout = () => import(/* webpackChunkName: "network-team-about" */ './About'); @@ -134,60 +136,69 @@ const TeamProfile: FC = ({ currentTime }) => { - - {canShareResearchOutput && ( - - - + + + + + - )} - {canDuplicateResearchOutput && ( - - - - - - )} - - ( - - )} - currentTime={currentTime} - displayName={team.displayName} - eventConstraint={{ teamId }} - isActive={!team?.inactiveSince} - Outputs={ - - } - DraftOutputs={ - + {canShareResearchOutput && ( + + + + + + )} + {canDuplicateResearchOutput && ( + + + + + + )} + ( - - )} - /> - - + > + ( + + )} + currentTime={currentTime} + displayName={team.displayName} + eventConstraint={{ teamId }} + isActive={!team?.inactiveSince} + Outputs={ + + } + DraftOutputs={ + + } + paths={paths} + type="team" + Workspace={() => ( + + )} + /> + + + ); } diff --git a/apps/crn-frontend/src/network/teams/__tests__/TeamManuscript.test.tsx b/apps/crn-frontend/src/network/teams/__tests__/TeamManuscript.test.tsx new file mode 100644 index 0000000000..df6f6b213a --- /dev/null +++ b/apps/crn-frontend/src/network/teams/__tests__/TeamManuscript.test.tsx @@ -0,0 +1,104 @@ +import { + Auth0Provider, + WhenReady, +} from '@asap-hub/crn-frontend/src/auth/test-utils'; +import { network } from '@asap-hub/routing'; +import { + render, + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import { ComponentProps, Suspense } from 'react'; +import { Route, Router } from 'react-router-dom'; +import { RecoilRoot } from 'recoil'; + +import { createManuscript } from '../api'; +import { ManuscriptToastProvider } from '../ManuscriptToastProvider'; +import { refreshTeamState } from '../state'; +import TeamManuscript from '../TeamManuscript'; + +const manuscriptResponse = { id: '1', title: 'The Manuscript' }; + +const teamId = '42'; +const history = createMemoryHistory({ + initialEntries: [ + network({}).teams({}).team({ teamId }).workspace({}).createManuscript({}).$, + ], +}); +jest.mock('../api', () => ({ + createManuscript: jest.fn().mockResolvedValue(manuscriptResponse), +})); + +beforeEach(() => { + jest.resetModules(); +}); + +const renderPage = async ( + user: ComponentProps['user'] = {}, +) => { + const path = + network.template + + network({}).teams.template + + network({}).teams({}).team.template + + network({}).teams({}).team({ teamId }).workspace.template + + network({}).teams({}).team({ teamId }).workspace({}).createManuscript + .template; + + const { container } = render( + { + set(refreshTeamState(teamId), Math.random()); + }} + > + + + + + + + + + + + + + + , + ); + await waitForElementToBeRemoved(() => screen.queryByText(/loading/i)); + return { container }; +}; + +it('renders manuscript form page', async () => { + const { container } = await renderPage(); + + expect(container).toHaveTextContent( + 'Submit your manuscript to receive a compliance report and find out which areas need to be improved before publishing your article', + ); + expect(container).toHaveTextContent('What are you sharing'); + expect(container).toHaveTextContent('Title of Manuscript'); +}); + +it('can publish a form when the data is valid and navigates to team workspace', async () => { + const title = 'The Manuscript'; + + await renderPage(); + + userEvent.type( + screen.getByRole('textbox', { name: /title of manuscript/i }), + title, + ); + + const submitButton = screen.getByRole('button', { name: /Submit/i }); + userEvent.click(submitButton); + + await waitFor(() => { + expect(createManuscript).toHaveBeenCalledWith({ title }, expect.anything()); + expect(history.location.pathname).toBe( + `/network/teams/${teamId}/workspace`, + ); + }); +}); diff --git a/apps/crn-frontend/src/network/teams/__tests__/TeamProfile.test.tsx b/apps/crn-frontend/src/network/teams/__tests__/TeamProfile.test.tsx index 2ff681e3ea..cea1ee9f3a 100644 --- a/apps/crn-frontend/src/network/teams/__tests__/TeamProfile.test.tsx +++ b/apps/crn-frontend/src/network/teams/__tests__/TeamProfile.test.tsx @@ -9,6 +9,7 @@ import { createTeamResponse, createUserResponse, } from '@asap-hub/fixtures'; +import { enable } from '@asap-hub/flags'; import { ResearchOutputTeamResponse, TeamResponse } from '@asap-hub/model'; import { network, sharedResearch } from '@asap-hub/routing'; import { @@ -31,10 +32,20 @@ import { import { refreshResearchOutputState } from '../../../shared-research/state'; import { createResearchOutputListAlgoliaResponse } from '../../../__fixtures__/algolia'; import { createResearchOutput, getTeam } from '../api'; +import { ManuscriptToastProvider } from '../ManuscriptToastProvider'; import { refreshTeamState } from '../state'; import TeamProfile from '../TeamProfile'; -jest.mock('../api'); +// jest.mock('../api'); +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + getTeam: jest.fn(), + createResearchOutput: jest.fn(), + createManuscript: jest + .fn() + .mockResolvedValue({ title: 'A manuscript', id: '1' }), +})); + jest.mock('../interest-groups/api'); jest.mock('../../../shared-research/api'); jest.mock('../../../events/api'); @@ -85,7 +96,9 @@ const renderPage = async ( network({}).teams({}).team.template } > - + + + @@ -151,6 +164,47 @@ it('navigates to the workspace tab', async () => { userEvent.click(screen.getByText(/workspace/i, { selector: 'nav *' })); expect(await screen.findByText(/tools/i)).toBeVisible(); }); + +it('displays manuscript success toast message and user can dismiss toast', async () => { + enable('DISPLAY_MANUSCRIPTS'); + + await renderPage({ + ...createTeamResponse(), + tools: [], + }); + + userEvent.click(screen.getByText(/workspace/i, { selector: 'nav *' })); + + expect(await screen.findByText(/tools/i)).toBeVisible(); + + userEvent.click(screen.getByText(/Share Manuscript/i)); + + const submitButton = screen.getByRole('button', { name: /Submit/i }); + + await waitFor(() => { + expect(submitButton).toBeVisible(); + }); + + userEvent.type( + screen.getByRole('textbox', { name: /Title of Manuscript/i }), + 'manuscript title', + ); + + userEvent.click(submitButton); + + await waitFor(() => { + expect(submitButton).not.toBeVisible(); + }); + + expect( + screen.getByText('Manuscript submitted successfully.'), + ).toBeInTheDocument(); + + userEvent.click(screen.getByLabelText('Close')); + + expect(screen.queryByText('Manuscript submitted successfully.')).toBeNull(); +}); + it('does not allow navigating to the workspace tab when team tools are not available', async () => { await renderPage({ ...createTeamResponse(), 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 90faaf0315..64e5322e32 100644 --- a/apps/crn-frontend/src/network/teams/__tests__/api.test.ts +++ b/apps/crn-frontend/src/network/teams/__tests__/api.test.ts @@ -1,22 +1,29 @@ -import nock from 'nock'; import { - createTeamResponse, - createListTeamResponse, createListLabsResponse, + createListTeamResponse, + createTeamResponse, + createManuscriptResponse, } from '@asap-hub/fixtures'; -import { ResearchOutputPostRequest, TeamResponse } from '@asap-hub/model'; import { GetListOptions } from '@asap-hub/frontend-utils'; +import { + ManuscriptPostRequest, + ResearchOutputPostRequest, + TeamResponse, +} from '@asap-hub/model'; +import nock from 'nock'; import { API_BASE_URL } from '../../../config'; +import { CARD_VIEW_PAGE_SIZE } from '../../../hooks'; import { + createManuscript, + createResearchOutput, + getLabs, + getManuscript, getTeam, - patchTeam, getTeams, - createResearchOutput, + patchTeam, updateTeamResearchOutput, - getLabs, } from '../api'; -import { CARD_VIEW_PAGE_SIZE } from '../../../hooks'; jest.mock('../../../config'); @@ -243,3 +250,59 @@ describe('getLabs', () => { ); }); }); + +describe('Manuscript', () => { + describe('POST', () => { + const payload: ManuscriptPostRequest = { + title: 'The Manuscript', + }; + it('makes an authorized POST request to create a manuscript', async () => { + nock(API_BASE_URL, { reqheaders: { authorization: 'Bearer x' } }) + .post('/manuscripts', payload) + .reply(201, { id: 123 }); + + await createManuscript(payload, 'Bearer x'); + expect(nock.isDone()).toBe(true); + }); + + it('errors for an error status', async () => { + nock(API_BASE_URL).post('/manuscripts').reply(500, {}); + + await expect( + createManuscript(payload, 'Bearer x'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create manuscript. Expected status 201. Received status 500."`, + ); + }); + }); + + describe('GET', () => { + it('makes an authorized GET request for the manuscript id', async () => { + nock(API_BASE_URL, { reqheaders: { authorization: 'Bearer x' } }) + .get('/manuscripts/42') + .reply(200, {}); + await getManuscript('42', 'Bearer x'); + expect(nock.isDone()).toBe(true); + }); + + it('returns a successfully fetched manuscript', async () => { + const manuscript = createManuscriptResponse(); + nock(API_BASE_URL).get('/manuscripts/42').reply(200, manuscript); + expect(await getManuscript('42', '')).toEqual(manuscript); + }); + + it('returns undefined for a 404', async () => { + nock(API_BASE_URL).get('/manuscripts/42').reply(404); + expect(await getManuscript('42', '')).toBe(undefined); + }); + + it('errors for another status', async () => { + nock(API_BASE_URL).get('/manuscripts/42').reply(500); + await expect( + getManuscript('42', ''), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to fetch manuscript with id 42. Expected status 2xx or 404. Received status 500."`, + ); + }); + }); +}); diff --git a/apps/crn-frontend/src/network/teams/api.ts b/apps/crn-frontend/src/network/teams/api.ts index 4a5c19b535..45805b78e5 100644 --- a/apps/crn-frontend/src/network/teams/api.ts +++ b/apps/crn-frontend/src/network/teams/api.ts @@ -6,6 +6,8 @@ import { import { ListLabsResponse, ListTeamResponse, + ManuscriptPostRequest, + ManuscriptResponse, ResearchOutputPostRequest, ResearchOutputResponse, TeamPatchRequest, @@ -154,3 +156,48 @@ export const getLabs = async ( } return resp.json(); }; + +export const createManuscript = async ( + manuscript: ManuscriptPostRequest, + authorization: string, +): Promise => { + const resp = await fetch(`${API_BASE_URL}/manuscripts`, { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + ...createSentryHeaders(), + }, + body: JSON.stringify(manuscript), + }); + const response = await resp.json(); + if (!resp.ok) { + throw new BackendError( + `Failed to create manuscript. Expected status 201. Received status ${`${resp.status} ${resp.statusText}`.trim()}.`, + response, + resp.status, + ); + } + return response; +}; + +export const getManuscript = async ( + id: string, + authorization: string, +): Promise => { + const resp = await fetch(`${API_BASE_URL}/manuscripts/${id}`, { + headers: { + authorization, + ...createSentryHeaders(), + }, + }); + if (!resp.ok) { + if (resp.status === 404) { + return undefined; + } + throw new Error( + `Failed to fetch manuscript with id ${id}. Expected status 2xx or 404. Received status ${`${resp.status} ${resp.statusText}`.trim()}.`, + ); + } + return resp.json(); +}; diff --git a/apps/crn-frontend/src/network/teams/state.ts b/apps/crn-frontend/src/network/teams/state.ts index 57790e920e..d6540645d5 100644 --- a/apps/crn-frontend/src/network/teams/state.ts +++ b/apps/crn-frontend/src/network/teams/state.ts @@ -4,11 +4,15 @@ import { TeamPatchRequest, TeamResponse, TeamListItemResponse, + ManuscriptPostRequest, + ManuscriptResponse, } from '@asap-hub/model'; import { + atom, atomFamily, DefaultValue, selectorFamily, + useRecoilCallback, useRecoilState, useRecoilValue, useSetRecoilState, @@ -16,7 +20,7 @@ import { import useDeepCompareEffect from 'use-deep-compare-effect'; import { authorizationState } from '../../auth/state'; import { CARD_VIEW_PAGE_SIZE } from '../../hooks'; -import { getTeam, getTeams, patchTeam } from './api'; +import { createManuscript, getTeam, getTeams, patchTeam } from './api'; const teamIndexState = atomFamily< { ids: ReadonlyArray; total: number } | Error | undefined, @@ -132,3 +136,39 @@ export const usePatchTeamById = (id: string) => { setPatchedTeam(await patchTeam(id, patch, authorization)); }; }; + +export const refreshManuscriptIndex = atom({ + key: 'refreshManuscriptIndex', + default: 0, +}); + +export const refreshManuscriptState = atomFamily({ + key: 'refreshManuscript', + default: 0, +}); + +export const manuscriptState = atomFamily< + ManuscriptResponse | undefined, + string +>({ + key: 'researchOutput', + default: undefined, +}); + +export const useSetManuscriptItem = () => { + const [refresh, setRefresh] = useRecoilState(refreshManuscriptIndex); + return useRecoilCallback(({ set }) => (manuscript: ManuscriptResponse) => { + setRefresh(refresh + 1); + set(manuscriptState(manuscript.id), manuscript); + }); +}; + +export const usePostManuscript = () => { + const authorization = useRecoilValue(authorizationState); + const setManuscriptItem = useSetManuscriptItem(); + return async (payload: ManuscriptPostRequest) => { + const manuscript = await createManuscript(payload, authorization); + setManuscriptItem(manuscript); + return manuscript; + }; +}; diff --git a/apps/crn-frontend/src/network/teams/useManuscriptToast.tsx b/apps/crn-frontend/src/network/teams/useManuscriptToast.tsx new file mode 100644 index 0000000000..ef755831fa --- /dev/null +++ b/apps/crn-frontend/src/network/teams/useManuscriptToast.tsx @@ -0,0 +1,5 @@ +import { useContext } from 'react'; + +import { ManuscriptToastContext } from './ManuscriptToastProvider'; + +export const useManuscriptToast = () => useContext(ManuscriptToastContext); diff --git a/apps/crn-server/src/app.ts b/apps/crn-server/src/app.ts index 61659e864c..c9ce51bc48 100644 --- a/apps/crn-server/src/app.ts +++ b/apps/crn-server/src/app.ts @@ -39,6 +39,7 @@ import EventController from './controllers/event.controller'; import GuideController from './controllers/guide.controller'; import InterestGroupController from './controllers/interest-group.controller'; import LabController from './controllers/lab.controller'; +import ManuscriptController from './controllers/manuscript.controller'; import NewsController from './controllers/news.controller'; import PageController from './controllers/page.controller'; import ReminderController from './controllers/reminder.controller'; @@ -56,6 +57,7 @@ import { EventContentfulDataProvider } from './data-providers/contentful/event.d import { ExternalAuthorContentfulDataProvider } from './data-providers/contentful/external-author.data-provider'; import { InterestGroupContentfulDataProvider } from './data-providers/contentful/interest-group.data-provider'; import { LabContentfulDataProvider } from './data-providers/contentful/lab.data-provider'; +import { ManuscriptContentfulDataProvider } from './data-providers/contentful/manuscript.data-provider'; import { NewsContentfulDataProvider } from './data-providers/contentful/news.data-provider'; import { PageContentfulDataProvider } from './data-providers/contentful/page.data-provider'; import { ReminderContentfulDataProvider } from './data-providers/contentful/reminder.data-provider'; @@ -74,6 +76,7 @@ import { GuideDataProvider, InterestGroupDataProvider, LabDataProvider, + ManuscriptDataProvider, NewsDataProvider, PageDataProvider, ReminderDataProvider, @@ -93,6 +96,7 @@ import { eventRouteFactory } from './routes/event.route'; import { guideRouteFactory } from './routes/guide.route'; import { interestGroupRouteFactory } from './routes/interest-group.route'; import { labRouteFactory } from './routes/lab.route'; +import { manuscriptRouteFactory } from './routes/manuscript.route'; import { newsRouteFactory } from './routes/news.route'; import { pageRouteFactory } from './routes/page.route'; import { reminderRouteFactory } from './routes/reminder.route'; @@ -244,6 +248,13 @@ export const appFactory = (libs: Libs = {}): Express => { libs.labDataProvider || new LabContentfulDataProvider(contentfulGraphQLClient); + const manuscriptDataProvider = + libs.manuscriptDataProvider || + new ManuscriptContentfulDataProvider( + contentfulGraphQLClient, + getContentfulRestClientFactory, + ); + // Controllers const analyticsController = libs.analyticsController || new AnalyticsController(analyticsDataProvider); @@ -289,6 +300,9 @@ export const appFactory = (libs: Libs = {}): Express => { ); const labController = libs.labController || new LabController(labDataProvider); + const manuscriptController = + libs.manuscriptController || + new ManuscriptController(manuscriptDataProvider); const workingGroupsController = libs.workingGroupController || new WorkingGroupController(workingGroupDataProvider); @@ -318,6 +332,7 @@ export const appFactory = (libs: Libs = {}): Express => { eventController, ); const labRoutes = labRouteFactory(labController); + const manuscriptRoutes = manuscriptRouteFactory(manuscriptController); const newsRoutes = newsRouteFactory(newsController); const pageRoutes = pageRouteFactory(pageController); const reminderRoutes = reminderRouteFactory(reminderController); @@ -382,6 +397,7 @@ export const appFactory = (libs: Libs = {}): Express => { app.use(eventRoutes); app.use(interestGroupRoutes); app.use(labRoutes); + app.use(manuscriptRoutes); app.use(newsRoutes); app.use(reminderRoutes); app.use(researchOutputRoutes); @@ -418,6 +434,7 @@ export type Libs = { eventController?: EventController; interestGroupController?: InterestGroupController; labController?: LabController; + manuscriptController?: ManuscriptController; newsController?: NewsController; pageController?: PageController; reminderController?: ReminderController; @@ -435,6 +452,7 @@ export type Libs = { externalAuthorDataProvider?: ExternalAuthorDataProvider; interestGroupDataProvider?: InterestGroupDataProvider; labDataProvider?: LabDataProvider; + manuscriptDataProvider?: ManuscriptDataProvider; newsDataProvider?: NewsDataProvider; pageDataProvider?: PageDataProvider; reminderDataProvider?: ReminderDataProvider; diff --git a/apps/crn-server/src/controllers/manuscript.controller.ts b/apps/crn-server/src/controllers/manuscript.controller.ts new file mode 100644 index 0000000000..a54cda1746 --- /dev/null +++ b/apps/crn-server/src/controllers/manuscript.controller.ts @@ -0,0 +1,34 @@ +import { NotFoundError } from '@asap-hub/errors'; +import { + ManuscriptCreateDataObject, + ManuscriptResponse, +} from '@asap-hub/model'; + +import { ManuscriptDataProvider } from '../data-providers/types'; + +export default class ManuscriptController { + constructor(private manuscriptDataProvider: ManuscriptDataProvider) {} + + async fetchById(manuscriptId: string): Promise { + const manuscript = + await this.manuscriptDataProvider.fetchById(manuscriptId); + + if (!manuscript) { + throw new NotFoundError( + undefined, + `Manuscript with id ${manuscriptId} not found`, + ); + } + + return manuscript; + } + + async create( + manuscriptCreateData: ManuscriptCreateDataObject, + ): Promise { + const manuscriptId = + await this.manuscriptDataProvider.create(manuscriptCreateData); + + return this.fetchById(manuscriptId); + } +} 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 new file mode 100644 index 0000000000..9a02cfd0bb --- /dev/null +++ b/apps/crn-server/src/data-providers/contentful/manuscript.data-provider.ts @@ -0,0 +1,62 @@ +import { + addLocaleToFields, + Environment, + FetchManuscriptByIdQuery, + FetchManuscriptByIdQueryVariables, + FETCH_MANUSCRIPT_BY_ID, + GraphQLClient, +} from '@asap-hub/contentful'; +import { + ListResponse, + ManuscriptCreateDataObject, + ManuscriptDataObject, +} from '@asap-hub/model'; + +import { ManuscriptDataProvider } from '../types'; + +type ManuscriptItem = NonNullable; + +export class ManuscriptContentfulDataProvider + implements ManuscriptDataProvider +{ + constructor( + private contentfulClient: GraphQLClient, + private getRestClient: () => Promise, + ) {} + + async fetch(): Promise> { + throw new Error('Method not implemented.'); + } + + async fetchById(id: string): Promise { + const { manuscripts } = await this.contentfulClient.request< + FetchManuscriptByIdQuery, + FetchManuscriptByIdQueryVariables + >(FETCH_MANUSCRIPT_BY_ID, { id }); + + if (!manuscripts) { + return null; + } + + return parseGraphQLManuscript(manuscripts); + } + + async create(input: ManuscriptCreateDataObject): Promise { + const environment = await this.getRestClient(); + + const manuscriptEntry = await environment.createEntry('manuscripts', { + fields: addLocaleToFields(input), + }); + + await manuscriptEntry.publish(); + + return manuscriptEntry.sys.id; + } +} + +const parseGraphQLManuscript = ( + manuscripts: ManuscriptItem, +): ManuscriptDataObject => ({ + id: manuscripts.sys.id, + title: manuscripts.title || '', +}); diff --git a/apps/crn-server/src/data-providers/types/index.ts b/apps/crn-server/src/data-providers/types/index.ts index 130724aa2f..4d4be3e8df 100644 --- a/apps/crn-server/src/data-providers/types/index.ts +++ b/apps/crn-server/src/data-providers/types/index.ts @@ -5,6 +5,7 @@ export * from './discover.data-provider.types'; export * from './guide.data-provider.types'; export * from './interest-groups.data-provider.types'; export * from './lab.data-provider.types'; +export * from './manuscript.data-provider.types'; export * from './news.data-provider.types'; export * from './pages.data-provider.types'; export * from './reminders.data-provider.types'; diff --git a/apps/crn-server/src/data-providers/types/manuscript.data-provider.types.ts b/apps/crn-server/src/data-providers/types/manuscript.data-provider.types.ts new file mode 100644 index 0000000000..4901565dc2 --- /dev/null +++ b/apps/crn-server/src/data-providers/types/manuscript.data-provider.types.ts @@ -0,0 +1,12 @@ +import { + ManuscriptCreateDataObject, + ManuscriptDataObject, + DataProvider, +} from '@asap-hub/model'; + +export type ManuscriptDataProvider = DataProvider< + ManuscriptDataObject, + ManuscriptDataObject, + null, + ManuscriptCreateDataObject +>; diff --git a/apps/crn-server/src/routes/manuscript.route.ts b/apps/crn-server/src/routes/manuscript.route.ts new file mode 100644 index 0000000000..c1b4d48922 --- /dev/null +++ b/apps/crn-server/src/routes/manuscript.route.ts @@ -0,0 +1,43 @@ +import { ManuscriptResponse } from '@asap-hub/model'; +import Boom from '@hapi/boom'; +import { Response, Router } from 'express'; + +import ManuscriptController from '../controllers/manuscript.controller'; +import { + validateManuscriptParameters, + validateManuscriptPostRequestParameters, +} from '../validation/manuscript.validation'; + +export const manuscriptRouteFactory = ( + manuscriptController: ManuscriptController, +): Router => { + const manuscriptRoutes = Router(); + + manuscriptRoutes.get<{ manuscriptId: string }>( + '/manuscripts/:manuscriptId', + async (req, res: Response) => { + const { params, loggedInUser } = req; + + if (!loggedInUser) throw Boom.forbidden(); + + const { manuscriptId } = validateManuscriptParameters(params); + + const result = await manuscriptController.fetchById(manuscriptId); + + res.json(result); + }, + ); + + manuscriptRoutes.post('/manuscripts', async (req, res) => { + const { body, loggedInUser } = req; + const createRequest = validateManuscriptPostRequestParameters(body); + + if (!loggedInUser) throw Boom.forbidden(); + + const manuscript = await manuscriptController.create(createRequest); + + res.status(201).json(manuscript); + }); + + return manuscriptRoutes; +}; diff --git a/apps/crn-server/src/validation/manuscript.validation.ts b/apps/crn-server/src/validation/manuscript.validation.ts new file mode 100644 index 0000000000..99cfa131a7 --- /dev/null +++ b/apps/crn-server/src/validation/manuscript.validation.ts @@ -0,0 +1,33 @@ +import { manuscriptPostRequestSchema } from '@asap-hub/model'; +import { validateInput } from '@asap-hub/server-common'; +import { JSONSchemaType } from 'ajv'; + +type ManuscriptParameters = { + manuscriptId: string; +}; + +const manuscriptParametersValidationSchema: JSONSchemaType = + { + type: 'object', + properties: { + manuscriptId: { type: 'string' }, + }, + required: ['manuscriptId'], + additionalProperties: false, + }; + +export const validateManuscriptParameters = validateInput( + manuscriptParametersValidationSchema, + { + skipNull: false, + coerce: false, + }, +); + +export const validateManuscriptPostRequestParameters = validateInput( + manuscriptPostRequestSchema, + { + skipNull: true, + coerce: true, + }, +); diff --git a/apps/crn-server/test/controllers/manuscript.controller.test.ts b/apps/crn-server/test/controllers/manuscript.controller.test.ts new file mode 100644 index 0000000000..a7adad3f8e --- /dev/null +++ b/apps/crn-server/test/controllers/manuscript.controller.test.ts @@ -0,0 +1,63 @@ +import { NotFoundError, GenericError } from '@asap-hub/errors'; +import ManuscriptController from '../../src/controllers/manuscript.controller'; +import { + getManuscriptDataObject, + getManuscriptResponse, + getManuscriptCreateDataObject, +} from '../fixtures/manuscript.fixtures'; +import { getDataProviderMock } from '../mocks/data-provider.mock'; + +describe('Manuscript controller', () => { + const manuscriptDataProviderMock = getDataProviderMock(); + const manuscriptController = new ManuscriptController( + manuscriptDataProviderMock, + ); + + describe('Fetch-by-ID method', () => { + test('Should throw when working-group is not found', async () => { + manuscriptDataProviderMock.fetchById.mockResolvedValueOnce(null); + + await expect(manuscriptController.fetchById('not-found')).rejects.toThrow( + NotFoundError, + ); + }); + + test('Should return the manuscript when it finds it', async () => { + manuscriptDataProviderMock.fetchById.mockResolvedValueOnce( + getManuscriptDataObject(), + ); + const result = await manuscriptController.fetchById('manuscript-id'); + + expect(result).toEqual(getManuscriptResponse()); + }); + }); + + describe('Create method', () => { + test('Should throw when fails to create the manuscript', async () => { + manuscriptDataProviderMock.create.mockRejectedValueOnce( + new GenericError(), + ); + + await expect( + manuscriptController.create(getManuscriptCreateDataObject()), + ).rejects.toThrow(GenericError); + }); + + test('Should create the new manuscript and return it', async () => { + const manuscriptId = 'manuscript-id-1'; + manuscriptDataProviderMock.create.mockResolvedValueOnce(manuscriptId); + manuscriptDataProviderMock.fetchById.mockResolvedValueOnce( + getManuscriptResponse(), + ); + + const result = await manuscriptController.create( + getManuscriptCreateDataObject(), + ); + + expect(result).toEqual(getManuscriptResponse()); + expect(manuscriptDataProviderMock.create).toHaveBeenCalledWith( + getManuscriptCreateDataObject(), + ); + }); + }); +}); diff --git a/apps/crn-server/test/data-providers/contentful/manuscript.data-provider.test.ts b/apps/crn-server/test/data-providers/contentful/manuscript.data-provider.test.ts new file mode 100644 index 0000000000..404616bdfc --- /dev/null +++ b/apps/crn-server/test/data-providers/contentful/manuscript.data-provider.test.ts @@ -0,0 +1,105 @@ +import { + Entry, + Environment, + getContentfulGraphqlClientMockServer, +} from '@asap-hub/contentful'; +import { GraphQLError } from 'graphql'; + +import { ManuscriptContentfulDataProvider } from '../../../src/data-providers/contentful/manuscript.data-provider'; +import { + getContentfulGraphqlManuscripts, + getManuscriptCreateDataObject, + getManuscriptDataObject, +} from '../../fixtures/manuscript.fixtures'; +import { getContentfulGraphqlClientMock } from '../../mocks/contentful-graphql-client.mock'; +import { getContentfulEnvironmentMock } from '../../mocks/contentful-rest-client.mock'; + +describe('Manuscripts Contentful Data Provider', () => { + const contentfulGraphqlClientMock = getContentfulGraphqlClientMock(); + const environmentMock = getContentfulEnvironmentMock(); + const contentfulRestClientMock: () => Promise = () => + Promise.resolve(environmentMock); + + const manuscriptDataProvider = new ManuscriptContentfulDataProvider( + contentfulGraphqlClientMock, + contentfulRestClientMock, + ); + + const contentfulGraphqlClientMockServer = + getContentfulGraphqlClientMockServer({ + Manuscripts: () => getContentfulGraphqlManuscripts(), + }); + + const manuscriptDataProviderMockGraphql = + new ManuscriptContentfulDataProvider( + contentfulGraphqlClientMockServer, + contentfulRestClientMock, + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Fetch method', () => { + test('should throw an error', async () => { + await expect(manuscriptDataProvider.fetch()).rejects.toThrow( + 'Method not implemented.', + ); + }); + }); + + describe('Fetch-by-id', () => { + test('Should fetch the manuscript from Contentful GraphQl', async () => { + const manuscriptId = 'manuscript-id-1'; + const result = + await manuscriptDataProviderMockGraphql.fetchById(manuscriptId); + + expect(result).toMatchObject(getManuscriptDataObject()); + }); + + test('returns null if query does not return a result', async () => { + contentfulGraphqlClientMock.request.mockResolvedValue({ + manuscripts: null, + }); + + const result = await manuscriptDataProvider.fetchById('1'); + expect(result).toBeNull(); + }); + + test('Should throw an error with a specific error message when the graphql client throws one', async () => { + const id = 'some-id'; + contentfulGraphqlClientMock.request.mockRejectedValueOnce( + new GraphQLError('some error message'), + ); + + await expect(manuscriptDataProvider.fetchById(id)).rejects.toThrow( + 'some error message', + ); + }); + }); + + describe('Create', () => { + test('can create a manuscript', async () => { + const manuscriptId = 'manuscript-id-1'; + const publish = jest.fn(); + environmentMock.createEntry.mockResolvedValue({ + sys: { id: manuscriptId }, + publish, + } as unknown as Entry); + + const result = await manuscriptDataProvider.create( + getManuscriptCreateDataObject(), + ); + + expect(environmentMock.createEntry).toHaveBeenCalledWith('manuscripts', { + fields: { + title: { + 'en-US': 'Manuscript Title', + }, + }, + }); + expect(publish).toHaveBeenCalled(); + expect(result).toEqual(manuscriptId); + }); + }); +}); diff --git a/apps/crn-server/test/fixtures/manuscript.fixtures.ts b/apps/crn-server/test/fixtures/manuscript.fixtures.ts new file mode 100644 index 0000000000..70cb62cf8e --- /dev/null +++ b/apps/crn-server/test/fixtures/manuscript.fixtures.ts @@ -0,0 +1,37 @@ +import { FetchManuscriptByIdQuery } from '@asap-hub/contentful'; +import { + ManuscriptCreateDataObject, + ManuscriptDataObject, + ManuscriptResponse, +} from '@asap-hub/model'; + +export const getManuscriptDataObject = ( + data: Partial = {}, +): ManuscriptDataObject => ({ + id: 'manuscript-id-1', + title: 'Manuscript Title', + ...data, +}); + +export const getManuscriptResponse = ( + data: Partial = {}, +): ManuscriptResponse => getManuscriptDataObject(data) as ManuscriptResponse; + +export const getContentfulGraphqlManuscripts = ( + props: Partial< + NonNullable['manuscripts']> + > = {}, +): NonNullable['manuscripts']> => ({ + sys: { + id: 'manuscript-id-1', + }, + title: 'Manuscript Title', + + ...props, +}); + +export const getManuscriptCreateDataObject = (): ManuscriptCreateDataObject => { + const { title } = getManuscriptDataObject(); + + return { title }; +}; diff --git a/apps/crn-server/test/mocks/manuscript.controller.mock.ts b/apps/crn-server/test/mocks/manuscript.controller.mock.ts new file mode 100644 index 0000000000..5afb8a9eb6 --- /dev/null +++ b/apps/crn-server/test/mocks/manuscript.controller.mock.ts @@ -0,0 +1,6 @@ +import ManuscriptController from '../../src/controllers/manuscript.controller'; + +export const manuscriptControllerMock = { + fetchById: jest.fn(), + create: jest.fn(), +} as unknown as jest.Mocked; diff --git a/apps/crn-server/test/routes/manuscript.route.test.ts b/apps/crn-server/test/routes/manuscript.route.test.ts new file mode 100644 index 0000000000..c2622769db --- /dev/null +++ b/apps/crn-server/test/routes/manuscript.route.test.ts @@ -0,0 +1,116 @@ +import { createUserResponse } from '@asap-hub/fixtures'; +import { UserResponse } from '@asap-hub/model'; +import { AuthHandler } from '@asap-hub/server-common'; +import Boom from '@hapi/boom'; +import supertest from 'supertest'; + +import { appFactory } from '../../src/app'; +import { + getManuscriptCreateDataObject, + getManuscriptResponse, +} from '../fixtures/manuscript.fixtures'; +import { loggerMock } from '../mocks/logger.mock'; +import { manuscriptControllerMock } from '../mocks/manuscript.controller.mock'; + +describe('/manuscripts/ route', () => { + const userMockFactory = jest.fn(); + const authHandlerMock: AuthHandler = (req, _res, next) => { + req.loggedInUser = userMockFactory(); + next(); + }; + + const app = appFactory({ + manuscriptController: manuscriptControllerMock, + authHandler: authHandlerMock, + logger: loggerMock, + }); + + beforeEach(() => { + userMockFactory.mockReturnValue(createUserResponse()); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /manuscripts/{id}', () => { + test('Should return a 404 error when manuscript is not found', async () => { + manuscriptControllerMock.fetchById.mockRejectedValueOnce(Boom.notFound()); + + const response = await supertest(app).get('/manuscripts/123'); + + expect(response.status).toBe(404); + }); + + test('Should return 403 when not allowed to get the manuscript', async () => { + userMockFactory.mockReturnValueOnce({ + ...createUserResponse(), + onboarded: false, + }); + + const response = await supertest(app).get('/manuscripts/123'); + + expect(response.status).toEqual(403); + }); + + test('Should return the result correctly', async () => { + const manuscriptResponse = getManuscriptResponse(); + + manuscriptControllerMock.fetchById.mockResolvedValueOnce( + manuscriptResponse, + ); + + const response = await supertest(app).get('/manuscripts/123'); + + expect(response.body).toEqual(manuscriptResponse); + }); + + test('Should call the controller with the right parameter', async () => { + const manuscriptId = 'abc123'; + + await supertest(app).get(`/manuscripts/${manuscriptId}`); + + expect(manuscriptControllerMock.fetchById).toHaveBeenCalledWith( + manuscriptId, + ); + }); + }); + + describe('POST /manuscripts/', () => { + const manuscriptResponse = getManuscriptResponse(); + + test('Should return 403 when not allowed to create a manuscript', async () => { + const createManuscriptRequest = getManuscriptCreateDataObject(); + + userMockFactory.mockReturnValueOnce({ + ...createUserResponse(), + onboarded: false, + }); + + const response = await supertest(app) + .post('/manuscripts') + .send(createManuscriptRequest) + .set('Accept', 'application/json'); + + expect(response.status).toEqual(403); + }); + + test('Should return a 201 when is hit', async () => { + const createManuscriptRequest = getManuscriptCreateDataObject(); + + manuscriptControllerMock.create.mockResolvedValueOnce(manuscriptResponse); + + const response = await supertest(app) + .post('/manuscripts') + .send(createManuscriptRequest) + .set('Accept', 'application/json'); + + expect(response.status).toBe(201); + expect(manuscriptControllerMock.create).toHaveBeenCalledWith( + createManuscriptRequest, + ); + + expect(response.body).toEqual(manuscriptResponse); + }); + }); +}); diff --git a/packages/contentful/migrations/crn/manuscripts/20240510095040-create-content-model.js b/packages/contentful/migrations/crn/manuscripts/20240510095040-create-content-model.js new file mode 100644 index 0000000000..0ab5d5b5cf --- /dev/null +++ b/packages/contentful/migrations/crn/manuscripts/20240510095040-create-content-model.js @@ -0,0 +1,25 @@ +module.exports.description = 'Create manuscripts content model'; + +module.exports.up = (migration) => { + const manuscripts = migration + .createContentType('manuscripts') + .name('Manuscripts') + .description('') + .displayField('title'); + + manuscripts + .createField('title') + .name('Title') + .type('Symbol') + .localized(false) + .required(true) + .validations([]) + .disabled(false) + .omitted(false); + + manuscripts.changeFieldControl('title', 'builtin', 'singleLine', {}); +}; + +module.exports.down = (migration) => { + migration.deleteContentType('manuscripts'); +}; diff --git a/packages/contentful/src/crn/autogenerated-gql/gql.ts b/packages/contentful/src/crn/autogenerated-gql/gql.ts index 58313314ec..e5d9f6d703 100644 --- a/packages/contentful/src/crn/autogenerated-gql/gql.ts +++ b/packages/contentful/src/crn/autogenerated-gql/gql.ts @@ -67,6 +67,10 @@ 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 }\n': + types.ManuscriptsContentFragmentDoc, + '\n query FetchManuscriptById($id: String!) {\n manuscripts(id: $id) {\n ...ManuscriptsContent\n }\n }\n \n': + types.FetchManuscriptByIdDocument, '\n fragment NewsContent on News {\n sys {\n id\n firstPublishedAt\n }\n title\n shortText\n frequency\n link\n linkText\n thumbnail {\n url\n }\n text {\n json\n links {\n entries {\n inline {\n sys {\n id\n }\n __typename\n ... on Media {\n url\n }\n }\n }\n assets {\n block {\n sys {\n id\n }\n url\n description\n contentType\n width\n height\n }\n }\n }\n }\n tagsCollection(limit: 20) {\n items {\n name\n }\n }\n publishDate\n }\n': types.NewsContentFragmentDoc, '\n query FetchNewsById($id: String!) {\n news(id: $id) {\n ...NewsContent\n }\n }\n \n': @@ -299,6 +303,18 @@ export function gql( export function gql( source: '\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', ): (typeof documents)['\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']; +/** + * 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 }\n', +): (typeof documents)['\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n }\n']; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql( + source: '\n query FetchManuscriptById($id: String!) {\n manuscripts(id: $id) {\n ...ManuscriptsContent\n }\n }\n \n', +): (typeof documents)['\n query FetchManuscriptById($id: String!) {\n manuscripts(id: $id) {\n ...ManuscriptsContent\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 7e749f290d..f318b2b9dd 100644 --- a/packages/contentful/src/crn/autogenerated-gql/graphql.ts +++ b/packages/contentful/src/crn/autogenerated-gql/graphql.ts @@ -3387,6 +3387,69 @@ export enum LabsOrder { SysPublishedVersionDesc = 'sys_publishedVersion_DESC', } +/** [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/manuscripts) */ +export type Manuscripts = Entry & { + contentfulMetadata: ContentfulMetadata; + linkedFrom?: Maybe; + sys: Sys; + title?: Maybe; +}; + +/** [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/manuscripts) */ +export type ManuscriptsLinkedFromArgs = { + allowedLocales?: InputMaybe>>; +}; + +/** [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/manuscripts) */ +export type ManuscriptsTitleArgs = { + locale?: InputMaybe; +}; + +export type ManuscriptsCollection = { + items: Array>; + limit: Scalars['Int']; + skip: Scalars['Int']; + total: Scalars['Int']; +}; + +export type ManuscriptsFilter = { + AND?: InputMaybe>>; + OR?: InputMaybe>>; + contentfulMetadata?: InputMaybe; + sys?: InputMaybe; + title?: InputMaybe; + title_contains?: InputMaybe; + title_exists?: InputMaybe; + title_in?: InputMaybe>>; + title_not?: InputMaybe; + title_not_contains?: InputMaybe; + title_not_in?: InputMaybe>>; +}; + +export type ManuscriptsLinkingCollections = { + entryCollection?: Maybe; +}; + +export type ManuscriptsLinkingCollectionsEntryCollectionArgs = { + limit?: InputMaybe; + locale?: InputMaybe; + preview?: InputMaybe; + skip?: InputMaybe; +}; + +export enum ManuscriptsOrder { + 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', + TitleAsc = 'title_ASC', + TitleDesc = 'title_DESC', +} + /** Videos and PDFs [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/media) */ export type Media = Entry & { contentfulMetadata: ContentfulMetadata; @@ -3995,6 +4058,8 @@ export type Query = { interestGroupsCollection?: Maybe; labs?: Maybe; labsCollection?: Maybe; + manuscripts?: Maybe; + manuscriptsCollection?: Maybe; media?: Maybe; mediaCollection?: Maybe; migration?: Maybe; @@ -4282,6 +4347,21 @@ export type QueryLabsCollectionArgs = { where?: InputMaybe; }; +export type QueryManuscriptsArgs = { + id: Scalars['String']; + locale?: InputMaybe; + preview?: InputMaybe; +}; + +export type QueryManuscriptsCollectionArgs = { + limit?: InputMaybe; + locale?: InputMaybe; + order?: InputMaybe>>; + preview?: InputMaybe; + skip?: InputMaybe; + where?: InputMaybe; +}; + export type QueryMediaArgs = { id: Scalars['String']; locale?: InputMaybe; @@ -10677,6 +10757,9 @@ export type FetchDashboardQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -10808,6 +10891,9 @@ export type FetchDashboardQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -10951,6 +11037,7 @@ export type FetchDiscoverQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -11097,6 +11184,7 @@ export type EventsContentFragment = Pick< }) | ({ __typename: 'InterestGroups' } & { sys: Pick }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -11161,6 +11249,7 @@ export type EventsContentFragment = Pick< }) | ({ __typename: 'InterestGroups' } & { sys: Pick }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -11225,6 +11314,7 @@ export type EventsContentFragment = Pick< }) | ({ __typename: 'InterestGroups' } & { sys: Pick }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -11412,6 +11502,7 @@ export type FetchEventByIdQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -11486,6 +11577,7 @@ export type FetchEventByIdQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -11560,6 +11652,7 @@ export type FetchEventByIdQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -11780,6 +11873,9 @@ export type FetchEventsQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -11880,6 +11976,9 @@ export type FetchEventsQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -11980,6 +12079,9 @@ export type FetchEventsQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -12239,6 +12341,9 @@ export type FetchEventsByUserIdQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick< Media, 'url' @@ -12356,6 +12461,9 @@ export type FetchEventsByUserIdQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick< Media, 'url' @@ -12473,6 +12581,9 @@ export type FetchEventsByUserIdQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick< Media, 'url' @@ -12766,6 +12877,9 @@ export type FetchEventsByExternalAuthorIdQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick< Media, 'url' @@ -12883,6 +12997,9 @@ export type FetchEventsByExternalAuthorIdQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick< Media, 'url' @@ -13000,6 +13117,9 @@ export type FetchEventsByExternalAuthorIdQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick< Media, 'url' @@ -13293,6 +13413,9 @@ export type FetchEventsByTeamIdQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick< Media, 'url' @@ -13410,6 +13533,9 @@ export type FetchEventsByTeamIdQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick< Media, 'url' @@ -13527,6 +13653,9 @@ export type FetchEventsByTeamIdQuery = { | ({ __typename: 'Labs' } & { sys: Pick; }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick< Media, 'url' @@ -14199,6 +14328,18 @@ export type FetchLabsQuery = { >; }; +export type ManuscriptsContentFragment = Pick & { + sys: Pick; +}; + +export type FetchManuscriptByIdQueryVariables = Exact<{ + id: Scalars['String']; +}>; + +export type FetchManuscriptByIdQuery = { + manuscripts?: Maybe & { sys: Pick }>; +}; + export type NewsContentFragment = Pick< News, 'title' | 'shortText' | 'frequency' | 'link' | 'linkText' | 'publishDate' @@ -14228,6 +14369,7 @@ export type NewsContentFragment = Pick< }) | ({ __typename: 'InterestGroups' } & { sys: Pick }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -14313,6 +14455,7 @@ export type FetchNewsByIdQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -14431,6 +14574,9 @@ export type FetchNewsQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -14526,6 +14672,7 @@ export type PageContentFragment = Pick< }) | ({ __typename: 'InterestGroups' } & { sys: Pick }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -14623,6 +14770,9 @@ export type FetchPagesQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -14909,6 +15059,7 @@ export type ResearchOutputsContentFragment = Pick< }) | ({ __typename: 'InterestGroups' } & { sys: Pick }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -15128,6 +15279,7 @@ export type FetchResearchOutputByIdQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -15379,6 +15531,9 @@ export type FetchResearchOutputsQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -15767,6 +15922,7 @@ export type TutorialsContentFragment = Pick< }) | ({ __typename: 'InterestGroups' } & { sys: Pick }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -15901,6 +16057,7 @@ export type FetchTutorialByIdQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -16068,6 +16225,9 @@ export type FetchTutorialsQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -16741,6 +16901,7 @@ export type WorkingGroupsContentFragment = Pick< }) | ({ __typename: 'InterestGroups' } & { sys: Pick }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -16904,6 +17065,7 @@ export type FetchWorkingGroupByIdQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { sys: Pick }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -17097,6 +17259,9 @@ export type FetchWorkingGroupsQuery = { sys: Pick; }) | ({ __typename: 'Labs' } & { sys: Pick }) + | ({ __typename: 'Manuscripts' } & { + sys: Pick; + }) | ({ __typename: 'Media' } & Pick & { sys: Pick; }) @@ -18940,6 +19105,35 @@ export const InterestGroupsContentFragmentDoc = { }, ], } as unknown as DocumentNode; +export const ManuscriptsContentFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'ManuscriptsContent' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Manuscripts' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'sys' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const NewsContentFragmentDoc = { kind: 'Document', definitions: [ @@ -26383,6 +26577,61 @@ export const FetchLabsDocument = { }, ], } as unknown as DocumentNode; +export const FetchManuscriptByIdDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'FetchManuscriptById' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'manuscripts' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'id' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'id' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'ManuscriptsContent' }, + }, + ], + }, + }, + ], + }, + }, + ...ManuscriptsContentFragmentDoc.definitions, + ], +} as unknown as DocumentNode< + FetchManuscriptByIdQuery, + FetchManuscriptByIdQueryVariables +>; export const FetchNewsByIdDocument = { kind: 'Document', definitions: [ diff --git a/packages/contentful/src/crn/queries/index.ts b/packages/contentful/src/crn/queries/index.ts index 3c9a877672..4709797f36 100644 --- a/packages/contentful/src/crn/queries/index.ts +++ b/packages/contentful/src/crn/queries/index.ts @@ -7,6 +7,7 @@ export * from './events.queries'; export * from './external-authors.queries'; export * from './interest-groups.queries'; export * from './labs.queries'; +export * from './manuscript.queries'; export * from './news.queries'; export * from './pages.queries'; export * from './reminders.queries'; diff --git a/packages/contentful/src/crn/queries/manuscript.queries.ts b/packages/contentful/src/crn/queries/manuscript.queries.ts new file mode 100644 index 0000000000..f72eb7d7f6 --- /dev/null +++ b/packages/contentful/src/crn/queries/manuscript.queries.ts @@ -0,0 +1,21 @@ +/* istanbul ignore file */ + +import { gql } from 'graphql-tag'; + +export const manuscriptContentQueryFragment = gql` + fragment ManuscriptsContent on Manuscripts { + sys { + id + } + title + } +`; + +export const FETCH_MANUSCRIPT_BY_ID = gql` + query FetchManuscriptById($id: String!) { + manuscripts(id: $id) { + ...ManuscriptsContent + } + } + ${manuscriptContentQueryFragment} +`; diff --git a/packages/contentful/src/crn/schema/autogenerated-schema.graphql b/packages/contentful/src/crn/schema/autogenerated-schema.graphql index 4a9bb1acda..40e8176ca7 100644 --- a/packages/contentful/src/crn/schema/autogenerated-schema.graphql +++ b/packages/contentful/src/crn/schema/autogenerated-schema.graphql @@ -2455,6 +2455,52 @@ enum LabsOrder { sys_publishedVersion_DESC } +"""[See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/manuscripts)""" +type Manuscripts implements Entry { + contentfulMetadata: ContentfulMetadata! + linkedFrom(allowedLocales: [String]): ManuscriptsLinkingCollections + sys: Sys! + title(locale: String): String +} + +type ManuscriptsCollection { + items: [Manuscripts]! + limit: Int! + skip: Int! + total: Int! +} + +input ManuscriptsFilter { + AND: [ManuscriptsFilter] + OR: [ManuscriptsFilter] + contentfulMetadata: ContentfulMetadataFilter + sys: SysFilter + title: String + title_contains: String + title_exists: Boolean + title_in: [String] + title_not: String + title_not_contains: String + title_not_in: [String] +} + +type ManuscriptsLinkingCollections { + entryCollection(limit: Int = 100, locale: String, preview: Boolean, skip: Int = 0): EntryCollection +} + +enum ManuscriptsOrder { + sys_firstPublishedAt_ASC + sys_firstPublishedAt_DESC + sys_id_ASC + sys_id_DESC + sys_publishedAt_ASC + sys_publishedAt_DESC + sys_publishedVersion_ASC + sys_publishedVersion_DESC + title_ASC + title_DESC +} + """Videos and PDFs [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/media)""" type Media implements Entry { contentfulMetadata: ContentfulMetadata! @@ -2902,6 +2948,8 @@ type Query { interestGroupsCollection(limit: Int = 100, locale: String, order: [InterestGroupsOrder], preview: Boolean, skip: Int = 0, where: InterestGroupsFilter): InterestGroupsCollection labs(id: String!, locale: String, preview: Boolean): Labs labsCollection(limit: Int = 100, locale: String, order: [LabsOrder], preview: Boolean, skip: Int = 0, where: LabsFilter): LabsCollection + manuscripts(id: String!, locale: String, preview: Boolean): Manuscripts + manuscriptsCollection(limit: Int = 100, locale: String, order: [ManuscriptsOrder], preview: Boolean, skip: Int = 0, where: ManuscriptsFilter): ManuscriptsCollection media(id: String!, locale: String, preview: Boolean): Media mediaCollection(limit: Int = 100, locale: String, order: [MediaOrder], preview: Boolean, skip: Int = 0, where: MediaFilter): MediaCollection migration(id: String!, locale: String, preview: Boolean): Migration diff --git a/packages/fixtures/src/index.ts b/packages/fixtures/src/index.ts index 75b1b031f7..8cb76d5e0a 100644 --- a/packages/fixtures/src/index.ts +++ b/packages/fixtures/src/index.ts @@ -7,6 +7,7 @@ export * as gp2 from './gp2'; export * from './interest-groups'; export * from './guides'; export * from './labs'; +export * from './manuscripts'; export * from './news'; export * from './pages'; export * from './reminder'; diff --git a/packages/fixtures/src/manuscripts.ts b/packages/fixtures/src/manuscripts.ts new file mode 100644 index 0000000000..86a6db20e8 --- /dev/null +++ b/packages/fixtures/src/manuscripts.ts @@ -0,0 +1,8 @@ +import { ManuscriptResponse } from '@asap-hub/model'; + +export const createManuscriptResponse = ( + itemIndex = 0, +): ManuscriptResponse => ({ + id: `manuscript_${itemIndex}`, + title: `Manuscript ${itemIndex + 1}`, +}); diff --git a/packages/flags/src/index.ts b/packages/flags/src/index.ts index df0d1616d7..54174eb54f 100644 --- a/packages/flags/src/index.ts +++ b/packages/flags/src/index.ts @@ -3,7 +3,8 @@ export type Flag = | 'VERSION_RESEARCH_OUTPUT' | 'DISPLAY_EVENTS' | 'DISPLAY_ANALYTICS_PRODUCTIVITY' - | 'DISPLAY_ANALYTICS_COLLABORATION'; + | 'DISPLAY_ANALYTICS_COLLABORATION' + | 'DISPLAY_MANUSCRIPTS'; export type Flags = Partial>; let overrides: Flags = { @@ -12,6 +13,7 @@ let overrides: Flags = { DISPLAY_EVENTS: false, DISPLAY_ANALYTICS_PRODUCTIVITY: false, DISPLAY_ANALYTICS_COLLABORATION: false, + DISPLAY_MANUSCRIPTS: false, }; const envDefaults: Record = { diff --git a/packages/model/src/index.ts b/packages/model/src/index.ts index cb00ece4ed..bfde0fb8fc 100644 --- a/packages/model/src/index.ts +++ b/packages/model/src/index.ts @@ -15,6 +15,7 @@ export * as gp2 from './gp2'; export * from './interest-group'; export * from './guide'; export * from './lab'; +export * from './manuscript'; export * from './news'; export * from './page'; export * from './reminder'; diff --git a/packages/model/src/manuscript.ts b/packages/model/src/manuscript.ts new file mode 100644 index 0000000000..725799d86a --- /dev/null +++ b/packages/model/src/manuscript.ts @@ -0,0 +1,22 @@ +import { JSONSchemaType } from 'ajv'; + +export type ManuscriptDataObject = { + id: string; + title: string; +}; + +export type ManuscriptResponse = ManuscriptDataObject; + +export type ManuscriptPostRequest = Pick; + +export type ManuscriptCreateDataObject = ManuscriptPostRequest; + +export const manuscriptPostRequestSchema: JSONSchemaType = + { + type: 'object', + properties: { + title: { type: 'string' }, + }, + required: ['title'], + additionalProperties: false, + }; diff --git a/packages/react-components/package.json b/packages/react-components/package.json index a6ca4f66b1..7a4d2fea71 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -39,6 +39,7 @@ "hast-util-sanitize": "3.0.2", "ismounted": "^0.1.8", "ramda": "0.27.1", + "react-hook-form": "^7.51.4", "react-router-hash-link": "2.4.3", "react-select": "4.3.1", "react-sortable-hoc": "2.0.0", diff --git a/packages/react-components/src/atoms/Button.tsx b/packages/react-components/src/atoms/Button.tsx index 43a01ca39a..c221286024 100644 --- a/packages/react-components/src/atoms/Button.tsx +++ b/packages/react-components/src/atoms/Button.tsx @@ -42,6 +42,7 @@ interface LinkStyleButtonProps { readonly fullWidth?: undefined; } type ButtonProps = (NormalButtonProps | LinkStyleButtonProps) & { + readonly preventDefault?: boolean; readonly submit?: boolean; readonly children?: React.ReactNode; readonly overrideStyles?: SerializedStyles; @@ -54,6 +55,7 @@ const Button: React.FC = ({ linkStyle = false, active = false, fullWidth = false, + preventDefault = true, noMargin, theme = defaultThemeVariant, submit = primary, @@ -67,7 +69,9 @@ const Button: React.FC = ({ disabled={!enabled} onClick={(event) => { onClick(); - event.preventDefault(); + if (preventDefault) { + event.preventDefault(); + } }} css={({ colors }) => [ linkStyle diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index 83d9b96e77..0e030ea210 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -134,6 +134,7 @@ export { JoinEvent, LeadershipMembershipTable, MainNavigation, + ManuscriptHeader, MenuHeader, NewsCard, NewsSection, @@ -226,6 +227,7 @@ export { LoadingLayout, LoadingContentBody, LoadingContentHeader, + ManuscriptForm, NetworkInterestGroups, NetworkPage, NetworkPeople, diff --git a/packages/react-components/src/molecules/FormCard.tsx b/packages/react-components/src/molecules/FormCard.tsx index 660291d10f..9cb4218308 100644 --- a/packages/react-components/src/molecules/FormCard.tsx +++ b/packages/react-components/src/molecules/FormCard.tsx @@ -8,6 +8,10 @@ interface FormCardProps { description?: string; } +const cardStyles = css({ + paddingTop: `${14 / perRem}em`, +}); + const descriptionStyles = css({ paddingTop: 0, paddingBottom: 0, @@ -22,7 +26,7 @@ const FormCard: React.FC = ({ title, description, }) => ( - +
{title}
diff --git a/packages/react-components/src/organisms/ManuscriptHeader.tsx b/packages/react-components/src/organisms/ManuscriptHeader.tsx new file mode 100644 index 0000000000..29d031fe5f --- /dev/null +++ b/packages/react-components/src/organisms/ManuscriptHeader.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { css } from '@emotion/react'; + +import { Display, Paragraph } from '../atoms'; +import { perRem } from '../pixels'; +import { paper, steel } from '../colors'; +import { contentSidePaddingWithNavigation } from '../layout'; + +const headerStyles = css({ + padding: `${36 / perRem}em ${contentSidePaddingWithNavigation(8)} ${ + 60 / perRem + }em `, + background: paper.rgb, + boxShadow: `0 2px 4px -2px ${steel.rgb}`, + marginBottom: `${30 / perRem}em`, + display: 'flex', + justifyContent: 'center', +}); + +const contentStyles = css({ + display: 'flex', + flexDirection: 'column', + maxWidth: `${800 / perRem}em`, + width: '100%', + justifyContent: 'center', +}); + +const ManuscriptHeader: React.FC = () => ( +
+
+ Submit a Manuscript +
+ + Submit your manuscript to receive a compliance report and find out + which areas need to be improved before publishing your article. + +
+
+
+); + +export default ManuscriptHeader; diff --git a/packages/react-components/src/organisms/Toast.tsx b/packages/react-components/src/organisms/Toast.tsx index 6d7cb76ac1..31518bf802 100644 --- a/packages/react-components/src/organisms/Toast.tsx +++ b/packages/react-components/src/organisms/Toast.tsx @@ -139,7 +139,11 @@ const Toast: React.FC = ({ }) => (
{onClose && ( - )} diff --git a/packages/react-components/src/organisms/__tests__/ManuscriptHeader.test.tsx b/packages/react-components/src/organisms/__tests__/ManuscriptHeader.test.tsx new file mode 100644 index 0000000000..75c3df5555 --- /dev/null +++ b/packages/react-components/src/organisms/__tests__/ManuscriptHeader.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@testing-library/react'; + +import ManuscriptHeader from '../ManuscriptHeader'; + +it('renders the manuscript header content', () => { + render(); + expect( + screen.getByRole('heading', { name: /Submit a Manuscript/i }), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Submit your manuscript to receive a compliance report and find out which areas need to be improved before publishing your article.', + ), + ).toBeInTheDocument(); +}); diff --git a/packages/react-components/src/organisms/index.ts b/packages/react-components/src/organisms/index.ts index fc4c2555f6..924f697ff3 100644 --- a/packages/react-components/src/organisms/index.ts +++ b/packages/react-components/src/organisms/index.ts @@ -28,6 +28,7 @@ export { default as InterestGroupTeamsTabbedCard } from './InterestGroupTeamsTab export { default as JoinEvent } from './JoinEvent'; export { default as LeadershipMembershipTable } from './LeadershipMembershipTable'; export { default as MainNavigation } from './MainNavigation'; +export { default as ManuscriptHeader } from './ManuscriptHeader'; export { default as MenuHeader } from './MenuHeader'; export { default as NewsCard } from './NewsCard'; export { default as NewsSection } from './NewsSection'; diff --git a/packages/react-components/src/templates/ManuscriptForm.tsx b/packages/react-components/src/templates/ManuscriptForm.tsx new file mode 100644 index 0000000000..0d16b97576 --- /dev/null +++ b/packages/react-components/src/templates/ManuscriptForm.tsx @@ -0,0 +1,127 @@ +import { ManuscriptPostRequest, ManuscriptResponse } from '@asap-hub/model'; +import { css } from '@emotion/react'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useHistory } from 'react-router-dom'; +import { FormCard, LabeledTextField } from '..'; +import { Button } from '../atoms'; +import { defaultPageLayoutPaddingStyle } from '../layout'; +import { mobileScreen, rem } from '../pixels'; + +const mainStyles = css({ + display: 'flex', + justifyContent: 'center', + padding: defaultPageLayoutPaddingStyle, +}); + +const contentStyles = css({ + display: 'grid', + gridTemplateColumns: '1fr', + width: '100%', + maxWidth: rem(800), + justifyContent: 'center', + gridAutoFlow: 'row', + rowGap: rem(36), +}); + +const buttonsOuterContainerStyles = css({ + display: 'flex', + justifyContent: 'end', + [`@media (max-width: ${mobileScreen.max}px)`]: { + width: '100%', + }, +}); + +const buttonsInnerContainerStyles = css({ + display: 'flex', + flexDirection: 'row', + gap: rem(24), + [`@media (max-width: ${mobileScreen.max}px)`]: { + flexDirection: 'column-reverse', + width: '100%', + }, +}); + +type ManuscriptFormProps = { + onSave: (output: ManuscriptPostRequest) => Promise; + onSuccess: () => void; +}; + +const ManuscriptForm: React.FC = ({ + onSave, + onSuccess, +}) => { + const history = useHistory(); + + const methods = useForm({ + mode: 'onBlur', + defaultValues: { + title: '', + }, + }); + + const { + handleSubmit, + control, + formState: { isSubmitting }, + } = methods; + + const onSubmit = async (data: ManuscriptPostRequest) => { + await onSave(data); + + onSuccess(); + }; + + return ( +
+
+
+ + ( + + validationState.valueMissing ? 'Please enter a title.' : '' + } + required + value={value} + onChange={onChange} + enabled={!isSubmitting} + /> + )} + /> + + +
+
+ + +
+
+
+
+
+ ); +}; + +export default ManuscriptForm; diff --git a/packages/react-components/src/templates/TeamProfileWorkspace.tsx b/packages/react-components/src/templates/TeamProfileWorkspace.tsx index 45807fff83..ae1de6cc4a 100644 --- a/packages/react-components/src/templates/TeamProfileWorkspace.tsx +++ b/packages/react-components/src/templates/TeamProfileWorkspace.tsx @@ -1,12 +1,14 @@ +import { isEnabled } from '@asap-hub/flags'; import { TeamResponse, TeamTool } from '@asap-hub/model'; -import { css } from '@emotion/react'; import { network } from '@asap-hub/routing'; +import { css } from '@emotion/react'; -import { Card, Display, Link, Caption, Headline2, Paragraph } from '../atoms'; -import { perRem, mobileScreen } from '../pixels'; -import { ToolCard } from '../organisms'; -import { mailToSupport, createMailTo } from '../mail'; +import { Caption, Card, Display, Headline2, Link, Paragraph } from '../atoms'; import { formatDateAndTime } from '../date'; +import { plusIcon } from '../icons'; +import { createMailTo, mailToSupport } from '../mail'; +import { ToolCard } from '../organisms'; +import { mobileScreen, perRem, rem } from '../pixels'; const containerStyles = css({ display: 'grid', @@ -19,6 +21,24 @@ const newToolStyles = css({ display: 'block', }, }); + +const complianceContainerStyles = css({ + display: 'flex', + flexDirection: 'column', +}); + +const complianceHeaderStyles = css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', +}); + +const manuscriptButtonStyles = css({ + flexGrow: 0, + alignSelf: 'center', + gap: rem(8), +}); + const toolContainerStyles = css({ listStyle: 'none', margin: 0, @@ -48,8 +68,35 @@ const TeamProfileWorkspace: React.FC = ({ .team({ teamId: id }) .workspace({}) .tools({}); + + const manuscriptRoute = network({}) + .teams({}) + .team({ teamId: id }) + .workspace({}) + .createManuscript({}).$; + return (
+ {isEnabled('DISPLAY_MANUSCRIPTS') && ( + +
+
+ Compliance +
+ + {plusIcon} Share Manuscript + +
+
+ + Submit your manuscripts to receive a compliance report and find + out which areas need to be improved before publishing your + article. + +
+
+ )} + Collaboration Tools (Team Only) diff --git a/packages/react-components/src/templates/__tests__/ManuscriptForm.test.tsx b/packages/react-components/src/templates/__tests__/ManuscriptForm.test.tsx new file mode 100644 index 0000000000..86dad8800b --- /dev/null +++ b/packages/react-components/src/templates/__tests__/ManuscriptForm.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ComponentProps } from 'react'; +import { MemoryRouter, Route, Router, StaticRouter } from 'react-router-dom'; +import { createMemoryHistory, History } from 'history'; + +import userEvent from '@testing-library/user-event'; +import ManuscriptForm from '../ManuscriptForm'; + +let history!: History; + +beforeEach(() => { + history = createMemoryHistory(); +}); + +const defaultProps: ComponentProps = { + onSave: jest.fn(() => Promise.resolve()), + onSuccess: jest.fn(), +}; + +it('renders the form', async () => { + render( + + + , + ); + expect( + screen.getByRole('heading', { name: /What are you sharing/i }), + ).toBeVisible(); + expect(screen.getByRole('button', { name: /Submit/i })).toBeVisible(); +}); + +it('title is sent on form submission', async () => { + const onSave = jest.fn(); + render( + + + , + ); + + userEvent.type( + screen.getByRole('textbox', { name: /Title of Manuscript/i }), + 'manuscript title', + ); + userEvent.click(screen.getByRole('button', { name: /Submit/i })); + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith({ title: 'manuscript title' }); + }); +}); + +it('displays error message when manuscript title is missing', async () => { + render( + + + , + ); + + const input = screen.getByRole('textbox', { name: /Title of Manuscript/i }); + fireEvent.focusOut(input); + expect(screen.getByText(/Please enter a title/i)).toBeVisible(); + + userEvent.type(input, 'title'); + fireEvent.focusOut(input); + expect(screen.queryByText(/Please enter a title/i)).toBeNull(); +}); + +it('does not submit when required values are missing', async () => { + const onSave = jest.fn(); + render( + + + , + ); + + const submitButton = screen.getByRole('button', { name: /Submit/i }); + + userEvent.click(submitButton); + + await waitFor(() => { + expect(submitButton).toBeEnabled(); + }); + + expect( + screen.getByRole('textbox', { name: /Title of Manuscript/i }), + ).toBeInvalid(); + expect(onSave).not.toHaveBeenCalled(); +}); + +it('should go back when cancel button is clicked', () => { + const { getByText } = render( + + + + + , + { wrapper: MemoryRouter }, + ); + + history.push('/another-url'); + history.push('/form'); + + const cancelButton = getByText(/cancel/i); + expect(cancelButton).toBeInTheDocument(); + userEvent.click(cancelButton); + + expect(history.location.pathname).toBe('/another-url'); +}); diff --git a/packages/react-components/src/templates/__tests__/TeamProfileWorkspace.test.tsx b/packages/react-components/src/templates/__tests__/TeamProfileWorkspace.test.tsx index b6cc0f97dc..6b0f22cec4 100644 --- a/packages/react-components/src/templates/__tests__/TeamProfileWorkspace.test.tsx +++ b/packages/react-components/src/templates/__tests__/TeamProfileWorkspace.test.tsx @@ -1,14 +1,17 @@ -import { ComponentProps } from 'react'; +import { createTeamResponse } from '@asap-hub/fixtures'; +import { disable, enable } from '@asap-hub/flags'; import { - render, getByText as getChildByText, + render, waitFor, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createTeamResponse } from '@asap-hub/fixtures'; +import { ComponentProps } from 'react'; import TeamProfileWorkspace from '../TeamProfileWorkspace'; +beforeEach(jest.clearAllMocks); + const team: ComponentProps = { ...createTeamResponse({ teamMembers: 1, tools: 0 }), tools: [], @@ -16,9 +19,21 @@ const team: ComponentProps = { it('renders the team workspace page', () => { const { getByRole } = render(); - expect(getByRole('heading').textContent).toEqual( - 'Collaboration Tools (Team Only)', + expect( + getByRole('heading', { name: 'Collaboration Tools (Team Only)' }), + ).toBeInTheDocument(); +}); + +it('renders compliance section when feature flag is enabled', () => { + enable('DISPLAY_MANUSCRIPTS'); + const { getByRole, queryByRole, rerender } = render( + , ); + expect(getByRole('heading', { name: 'Compliance' })).toBeInTheDocument(); + + disable('DISPLAY_MANUSCRIPTS'); + rerender(); + expect(queryByRole('heading', { name: 'Compliance' })).toBeNull(); }); it('renders contact project manager when point of contact provided', () => { diff --git a/packages/react-components/src/templates/index.ts b/packages/react-components/src/templates/index.ts index f3ddc30ae0..51a17a2e46 100644 --- a/packages/react-components/src/templates/index.ts +++ b/packages/react-components/src/templates/index.ts @@ -34,6 +34,7 @@ export { LoadingContentBody, LoadingContentHeader, } from './LoadingLayout'; +export { default as ManuscriptForm } from './ManuscriptForm'; export { default as NetworkInterestGroups } from './NetworkInterestGroups'; export { default as NetworkPage } from './NetworkPage'; export { default as NetworkPageHeader } from './NetworkPageHeader'; diff --git a/packages/routing/src/network.ts b/packages/routing/src/network.ts index 154199e8d5..50a66e9a22 100644 --- a/packages/routing/src/network.ts +++ b/packages/routing/src/network.ts @@ -91,7 +91,8 @@ const team = (() => { const tool = route('/:toolIndex', { toolIndex: stringParser }, {}); const tools = route('/tools', {}, { tool }); - const workspace = route('/workspace', {}, { tools }); + const createManuscript = route('/create-manuscript', {}, {}); + const workspace = route('/workspace', {}, { tools, createManuscript }); const createOutput = route( '/create-output/:outputDocumentType', { outputDocumentType: outputDocumentTypeParser }, diff --git a/yarn.lock b/yarn.lock index 1fa067b928..470a34bc24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -956,6 +956,7 @@ __metadata: react-app-polyfill: 3.0.0 react-dom: 17.0.2 react-error-boundary: 3.1.4 + react-hook-form: ^7.51.4 react-router-dom: 5.3.4 react-router-last-location: 2.0.1 react-test-renderer: 17.0.2 @@ -1681,6 +1682,7 @@ __metadata: ramda: 0.27.1 react: 17.0.2 react-dom: 17.0.2 + react-hook-form: ^7.51.4 react-router-dom: 5.3.4 react-router-hash-link: 2.4.3 react-select: 4.3.1 @@ -39603,6 +39605,15 @@ __metadata: languageName: node linkType: hard +"react-hook-form@npm:^7.51.4": + version: 7.51.4 + resolution: "react-hook-form@npm:7.51.4" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + checksum: b3587c23425025cc4ab9d4de71420aeb9b28809a9183691584ecbbf5bb3d85ab8c232afb01424efed11a761c9c726521a230d4e092c7ad6bb70a011a7ba0acf7 + languageName: node + linkType: hard + "react-input-autosize@npm:^3.0.0": version: 3.0.0 resolution: "react-input-autosize@npm:3.0.0"