diff --git a/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx b/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx index babeb1bec6..695271d5e5 100644 --- a/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx +++ b/apps/crn-frontend/src/network/teams/ManuscriptToastProvider.tsx @@ -1,8 +1,10 @@ import { Toast } from '@asap-hub/react-components'; import React, { createContext, useState } from 'react'; +type FormType = 'manuscript' | 'compliance-report' | ''; + type ManuscriptToastContextData = { - setShowSuccessBanner: React.Dispatch>; + setFormType: React.Dispatch>; }; export const ManuscriptToastContext = createContext( @@ -14,17 +16,19 @@ export const ManuscriptToastProvider = ({ }: { children: React.ReactNode; }) => { - const [showSuccessBanner, setShowSuccessBanner] = useState(false); + const [formType, setFormType] = useState(''); + + const formTypeMapping = { + manuscript: 'Manuscript', + 'compliance-report': 'Compliance Report', + }; return ( - + <> - {showSuccessBanner && ( - setShowSuccessBanner(false)} - > - Manuscript submitted successfully. + {!!formType && ( + setFormType('')}> + {formTypeMapping[formType]} submitted successfully. )} {children} diff --git a/apps/crn-frontend/src/network/teams/TeamComplianceReport.tsx b/apps/crn-frontend/src/network/teams/TeamComplianceReport.tsx new file mode 100644 index 0000000000..b15c14a45f --- /dev/null +++ b/apps/crn-frontend/src/network/teams/TeamComplianceReport.tsx @@ -0,0 +1,60 @@ +import { Frame } from '@asap-hub/frontend-utils'; +import { + ComplianceReportForm, + ComplianceReportHeader, + NotFoundPage, + usePushFromHere, +} from '@asap-hub/react-components'; +import { network } from '@asap-hub/routing'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useParams } from 'react-router-dom'; +import { useSetRecoilState } from 'recoil'; +import { + refreshTeamState, + useManuscriptById, + usePostComplianceReport, +} from './state'; +import { useManuscriptToast } from './useManuscriptToast'; + +type TeamComplianceReportProps = { + teamId: string; +}; +const TeamComplianceReport: React.FC = ({ + teamId, +}) => { + const { manuscriptId } = useParams<{ manuscriptId: string }>(); + const manuscript = useManuscriptById(manuscriptId); + const { setFormType } = useManuscriptToast(); + + const pushFromHere = usePushFromHere(); + + const setRefreshTeamState = useSetRecoilState(refreshTeamState(teamId)); + const form = useForm(); + const createComplianceReport = usePostComplianceReport(); + + if (manuscript && manuscript.versions[0]) { + const onSuccess = () => { + const path = network({}).teams({}).team({ teamId }).workspace({}).$; + setFormType('compliance-report'); + setRefreshTeamState((value) => value + 1); + pushFromHere(path); + }; + + return ( + + + + + + + ); + } + + return ; +}; +export default TeamComplianceReport; diff --git a/apps/crn-frontend/src/network/teams/TeamManuscript.tsx b/apps/crn-frontend/src/network/teams/TeamManuscript.tsx index 8cdacbef4a..8c898f1383 100644 --- a/apps/crn-frontend/src/network/teams/TeamManuscript.tsx +++ b/apps/crn-frontend/src/network/teams/TeamManuscript.tsx @@ -30,7 +30,7 @@ const TeamManuscript: React.FC = ({ teamId }) => { const team = useTeamById(teamId); const { eligibilityReasons } = useEligibilityReason(); - const { setShowSuccessBanner } = useManuscriptToast(); + const { setFormType } = useManuscriptToast(); const form = useForm(); const createManuscript = usePostManuscript(); const handleFileUpload = useUploadManuscriptFile(); @@ -42,7 +42,7 @@ const TeamManuscript: React.FC = ({ teamId }) => { const onSuccess = () => { const path = network({}).teams({}).team({ teamId }).workspace({}).$; - setShowSuccessBanner(true); + setFormType('manuscript'); setRefreshTeamState((value) => value + 1); pushFromHere(path); }; diff --git a/apps/crn-frontend/src/network/teams/TeamProfile.tsx b/apps/crn-frontend/src/network/teams/TeamProfile.tsx index 65acb9cd83..bcf05a0591 100644 --- a/apps/crn-frontend/src/network/teams/TeamProfile.tsx +++ b/apps/crn-frontend/src/network/teams/TeamProfile.tsx @@ -19,9 +19,10 @@ import { useUpcomingAndPastEvents } from '../events'; import ProfileSwitch from '../ProfileSwitch'; import { ManuscriptToastProvider } from './ManuscriptToastProvider'; -import { useTeamById } from './state'; +import { useCanShareComplianceReport, useTeamById } from './state'; import TeamManuscript from './TeamManuscript'; import { EligibilityReasonProvider } from './EligibilityReasonProvider'; +import TeamComplianceReport from './TeamComplianceReport'; const loadAbout = () => import(/* webpackChunkName: "network-team-about" */ './About'); @@ -68,7 +69,6 @@ const TeamProfile: FC = ({ currentTime }) => { const { path } = useRouteMatch(); const route = network({}).teams({}).team; const [teamListElementId] = useState(`team-list-${uuid()}`); - const { teamId } = useRouteParams(route); const team = useTeamById(teamId); @@ -89,6 +89,8 @@ const TeamProfile: FC = ({ currentTime }) => { const canDuplicateResearchOutput = useCanDuplicateResearchOutput('teams', [ teamId, ]); + + const canCreateComplianceReport = useCanShareComplianceReport(); const [upcomingEvents, pastEvents] = useUpcomingAndPastEvents(currentTime, { teamId, }); @@ -147,6 +149,18 @@ const TeamProfile: FC = ({ currentTime }) => { + {canCreateComplianceReport && ( + + + + + + )} {canShareResearchOutput && ( diff --git a/apps/crn-frontend/src/network/teams/Workspace.tsx b/apps/crn-frontend/src/network/teams/Workspace.tsx index 9d9d72ac5e..ee7d6f654a 100644 --- a/apps/crn-frontend/src/network/teams/Workspace.tsx +++ b/apps/crn-frontend/src/network/teams/Workspace.tsx @@ -9,7 +9,7 @@ import { TeamTool, TeamResponse } from '@asap-hub/model'; import { network, useRouteParams } from '@asap-hub/routing'; import { ToastContext } from '@asap-hub/react-context'; -import { usePatchTeamById } from './state'; +import { useCanShareComplianceReport, usePatchTeamById } from './state'; import { useEligibilityReason } from './useEligibilityReason'; interface WorkspaceProps { @@ -19,6 +19,7 @@ const Workspace: React.FC = ({ team }) => { const route = network({}).teams({}).team({ teamId: team.id }).workspace({}); const { path } = useRouteMatch(); const { setEligibilityReasons } = useEligibilityReason(); + const canShareComplianceReport = useCanShareComplianceReport(); const [deleting, setDeleting] = useState(false); const patchTeam = usePatchTeamById(team.id); @@ -50,6 +51,7 @@ const Workspace: React.FC = ({ team }) => { setDeleting(false); } } + canShareComplianceReport={canShareComplianceReport} /> diff --git a/apps/crn-frontend/src/network/teams/__tests__/TeamComplianceReport.test.tsx b/apps/crn-frontend/src/network/teams/__tests__/TeamComplianceReport.test.tsx new file mode 100644 index 0000000000..c1d991ab74 --- /dev/null +++ b/apps/crn-frontend/src/network/teams/__tests__/TeamComplianceReport.test.tsx @@ -0,0 +1,150 @@ +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 { createComplianceReport, getManuscript } from '../api'; +import { ManuscriptToastProvider } from '../ManuscriptToastProvider'; +import { refreshTeamState } from '../state'; +import TeamComplianceReport from '../TeamComplianceReport'; + +const manuscriptResponse = { + id: 'manuscript-1', + title: 'The Manuscript', + versions: [{ id: 'manuscript-version-1' }], +}; +const complianceReportResponse = { id: 'compliance-report-1' }; + +const teamId = '42'; + +jest.mock('../api', () => ({ + createComplianceReport: jest.fn().mockResolvedValue(complianceReportResponse), + getManuscript: jest.fn().mockResolvedValue(manuscriptResponse), +})); + +const mockGetManuscript = getManuscript as jest.MockedFunction< + typeof getManuscript +>; + +beforeEach(() => { + jest.resetModules(); +}); + +const renderPage = async ( + user: ComponentProps['user'] = {}, + history = createMemoryHistory({ + initialEntries: [ + network({}) + .teams({}) + .team({ teamId }) + .workspace({}) + .createComplianceReport({ manuscriptId: manuscriptResponse.id }).$, + ], + }), +) => { + const path = + network.template + + network({}).teams.template + + network({}).teams({}).team.template + + network({}).teams({}).team({ teamId }).workspace.template + + network({}).teams({}).team({ teamId }).workspace({}).createComplianceReport + .template; + + const { container } = render( + { + set(refreshTeamState(teamId), Math.random()); + }} + > + + + + + + + + + + + + + + , + ); + await waitForElementToBeRemoved(() => screen.queryByText(/loading/i)); + return { container }; +}; + +it('renders compliance report form page', async () => { + const { container } = await renderPage(); + + expect(container).toHaveTextContent( + 'Share the compliance report associated with this manuscript.', + ); + expect(container).toHaveTextContent('Title of Manuscript'); +}); + +it('can publish a form when the data is valid and navigates to team workspace', async () => { + const url = 'https://compliancereport.com'; + const description = 'compliance report description'; + const history = createMemoryHistory({ + initialEntries: [ + network({}) + .teams({}) + .team({ teamId }) + .workspace({}) + .createComplianceReport({ manuscriptId: manuscriptResponse.id }).$, + ], + }); + + await renderPage({}, history); + + userEvent.type(screen.getByRole('textbox', { name: /url/i }), url); + + userEvent.type( + screen.getByRole('textbox', { + name: /Compliance Report Description/i, + }), + description, + ); + + const shareButton = screen.getByRole('button', { name: /Share/i }); + await waitFor(() => expect(shareButton).toBeEnabled()); + + userEvent.click(shareButton); + + await waitFor(() => { + expect(createComplianceReport).toHaveBeenCalledWith( + { + url, + description, + manuscriptVersionId: manuscriptResponse.versions[0]!.id, + }, + expect.anything(), + ); + expect(history.location.pathname).toBe( + `/network/teams/${teamId}/workspace`, + ); + }); +}); + +it('renders not found when the manuscript hook does not return a manuscript with a version', async () => { + mockGetManuscript.mockResolvedValue(undefined); + await renderPage(); + + expect(screen.getByRole('heading').textContent).toContain( + 'Sorry! We can’t seem to find that page.', + ); +}); 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 843a7a20a1..4ccf9e31a0 100644 --- a/apps/crn-frontend/src/network/teams/__tests__/TeamProfile.test.tsx +++ b/apps/crn-frontend/src/network/teams/__tests__/TeamProfile.test.tsx @@ -5,6 +5,7 @@ import { import { createListEventResponse, createListResearchOutputResponse, + createManuscriptResponse, createResearchOutputResponse, createTeamResponse, createUserResponse, @@ -38,6 +39,12 @@ import { ManuscriptToastProvider } from '../ManuscriptToastProvider'; import { refreshTeamState } from '../state'; import TeamProfile from '../TeamProfile'; +const manuscriptResponse = { + id: 'manuscript-1', + title: 'The Manuscript', + versions: [{ id: 'manuscript-version-1' }], +}; + jest.mock('../api', () => ({ ...jest.requireActual('../api'), getTeam: jest.fn(), @@ -50,6 +57,7 @@ jest.mock('../api', () => ({ createManuscript: jest .fn() .mockResolvedValue({ title: 'A manuscript', id: '1' }), + getManuscript: jest.fn().mockResolvedValue(manuscriptResponse), })); jest.mock('../interest-groups/api'); @@ -287,6 +295,7 @@ it('does not allow navigating to the workspace tab when team tools are not avail screen.queryByText(/workspace/i, { selector: 'nav *' }), ).not.toBeInTheDocument(); }); + describe('Share Output', () => { it('shows share outputs button and page when the user has permissions user clicks an option', async () => { const teamResponse = createTeamResponse(); @@ -481,6 +490,86 @@ describe('Duplicate Output', () => { }); }); +describe('Create Compliance Report', () => { + it('allows a user who is an ASAP staff to view Share Compliance Report button', async () => { + enable('DISPLAY_MANUSCRIPTS'); + const teamResponse = createTeamResponse(); + const userResponse = createUserResponse({}, 1); + + teamResponse.manuscripts = [createManuscriptResponse()]; + userResponse.role = 'Staff'; + + const history = createMemoryHistory({ + initialEntries: [ + network({}).teams({}).team({ teamId: teamResponse.id }).workspace({}).$, + ], + }); + await renderPage( + teamResponse, + { teamId: teamResponse.id, currentTime: new Date() }, + { + ...userResponse, + teams: [ + { + ...userResponse.teams[0], + id: teamResponse.id, + role: 'Key Personnel', + }, + ], + }, + history, + ); + + expect( + screen.getByRole('button', { name: /Share Compliance Report Icon/ }), + ).toBeInTheDocument(); + }); + + it('allows a user who is an ASAP staff to create a compliance report', async () => { + enable('DISPLAY_MANUSCRIPTS'); + const teamResponse = createTeamResponse(); + const userResponse = createUserResponse({}, 1); + const teamManuscript = createManuscriptResponse(); + teamResponse.manuscripts = [teamManuscript]; + userResponse.role = 'Staff'; + + const history = createMemoryHistory({ + initialEntries: [ + network({}).teams({}).team({ teamId: teamResponse.id }).workspace({}).$, + ], + }); + await renderPage( + teamResponse, + { teamId: teamResponse.id, currentTime: new Date() }, + { + ...userResponse, + teams: [ + { + ...userResponse.teams[0], + id: teamResponse.id, + role: 'Key Personnel', + }, + ], + }, + history, + ); + + userEvent.click( + screen.getByRole('button', { name: /Share Compliance Report Icon/ }), + ); + + expect( + await screen.findByText( + /Share the compliance report associated with this manuscript./, + ), + ).toBeInTheDocument(); + + expect(history.location.pathname).toEqual( + `/network/teams/${teamResponse.id}/workspace/create-compliance-report/${teamManuscript.id}`, + ); + }); +}); + it('renders the 404 page for a missing team', async () => { await renderPage({ ...createTeamResponse(), id: '42' }, { teamId: '1337' }); expect(screen.getByText(/sorry.+page/i)).toBeVisible(); 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 73d558d9a9..b61f8ba02a 100644 --- a/apps/crn-frontend/src/network/teams/__tests__/api.test.ts +++ b/apps/crn-frontend/src/network/teams/__tests__/api.test.ts @@ -6,6 +6,7 @@ import { } from '@asap-hub/fixtures'; import { GetListOptions } from '@asap-hub/frontend-utils'; import { + ComplianceReportPostRequest, ManuscriptFileResponse, ManuscriptPostRequest, ResearchOutputPostRequest, @@ -16,6 +17,7 @@ import nock from 'nock'; import { API_BASE_URL } from '../../../config'; import { CARD_VIEW_PAGE_SIZE } from '../../../hooks'; import { + createComplianceReport, createManuscript, createResearchOutput, getLabs, @@ -417,3 +419,32 @@ describe('Manuscript', () => { }); }); }); + +describe('Compliance Report', () => { + describe('POST', () => { + const payload: ComplianceReportPostRequest = { + url: 'https://compliancereport.com', + description: 'Compliance report description', + manuscriptVersionId: 'manuscript-version-1', + }; + + it('makes an authorized POST request to create a compliance report', async () => { + nock(API_BASE_URL, { reqheaders: { authorization: 'Bearer x' } }) + .post('/compliance-reports', payload) + .reply(201, { id: 123 }); + + await createComplianceReport(payload, 'Bearer x'); + expect(nock.isDone()).toBe(true); + }); + + it('errors for an error status', async () => { + nock(API_BASE_URL).post('/compliance-reports').reply(500, {}); + + await expect( + createComplianceReport(payload, 'Bearer x'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create compliance report. Expected status 201. Received status 500."`, + ); + }); + }); +}); diff --git a/apps/crn-frontend/src/network/teams/api.ts b/apps/crn-frontend/src/network/teams/api.ts index 695ab554ff..e3c33145bb 100644 --- a/apps/crn-frontend/src/network/teams/api.ts +++ b/apps/crn-frontend/src/network/teams/api.ts @@ -4,6 +4,8 @@ import { GetListOptions, } from '@asap-hub/frontend-utils'; import { + ComplianceReportPostRequest, + ComplianceReportResponse, ListLabsResponse, ListTeamResponse, ManuscriptFileResponse, @@ -235,3 +237,27 @@ export const uploadManuscriptFile = async ( return resp.json(); }; + +export const createComplianceReport = async ( + complianceReport: ComplianceReportPostRequest, + authorization: string, +): Promise => { + const resp = await fetch(`${API_BASE_URL}/compliance-reports`, { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + ...createSentryHeaders(), + }, + body: JSON.stringify(complianceReport), + }); + const response = await resp.json(); + if (!resp.ok) { + throw new BackendError( + `Failed to create compliance report. 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 4671efb433..9bdeacd1bb 100644 --- a/apps/crn-frontend/src/network/teams/state.ts +++ b/apps/crn-frontend/src/network/teams/state.ts @@ -7,7 +7,9 @@ import { ManuscriptPostRequest, ManuscriptResponse, ManuscriptFileType, + ComplianceReportPostRequest, } from '@asap-hub/model'; +import { useCurrentUserCRN } from '@asap-hub/react-context'; import { atom, atomFamily, @@ -22,7 +24,9 @@ import useDeepCompareEffect from 'use-deep-compare-effect'; import { authorizationState } from '../../auth/state'; import { CARD_VIEW_PAGE_SIZE } from '../../hooks'; import { + createComplianceReport, createManuscript, + getManuscript, getTeam, getTeams, patchTeam, @@ -154,14 +158,31 @@ export const refreshManuscriptState = atomFamily({ default: 0, }); +const fetchManuscriptState = selectorFamily< + ManuscriptResponse | undefined, + string +>({ + key: 'fetchManuscript', + get: + (id) => + ({ get }) => { + get(refreshManuscriptState(id)); + const authorization = get(authorizationState); + return getManuscript(id, authorization); + }, +}); + export const manuscriptState = atomFamily< ManuscriptResponse | undefined, string >({ key: 'manuscript', - default: undefined, + default: fetchManuscriptState, }); +export const useManuscriptById = (id: string) => + useRecoilValue(manuscriptState(id)); + export const useSetManuscriptItem = () => { const [refresh, setRefresh] = useRecoilState(refreshManuscriptIndex); return useRecoilCallback(({ set }) => (manuscript: ManuscriptResponse) => { @@ -180,6 +201,22 @@ export const usePostManuscript = () => { }; }; +export const usePostComplianceReport = () => { + const authorization = useRecoilValue(authorizationState); + return async (payload: ComplianceReportPostRequest) => { + const complianceReport = await createComplianceReport( + payload, + authorization, + ); + return complianceReport; + }; +}; + +export const useCanShareComplianceReport = (): boolean => { + const { role } = useCurrentUserCRN() ?? {}; + return role === 'Staff'; +}; + export const useUploadManuscriptFile = () => { const authorization = useRecoilValue(authorizationState); diff --git a/apps/crn-server/src/app.ts b/apps/crn-server/src/app.ts index 99d59eec57..aa1376d0ad 100644 --- a/apps/crn-server/src/app.ts +++ b/apps/crn-server/src/app.ts @@ -33,6 +33,7 @@ import { } from './config'; import AnalyticsController from './controllers/analytics.controller'; import CalendarController from './controllers/calendar.controller'; +import ComplianceReportController from './controllers/compliance-report.controller'; import DashboardController from './controllers/dashboard.controller'; import DiscoverController from './controllers/discover.controller'; import EventController from './controllers/event.controller'; @@ -51,6 +52,7 @@ import UserController from './controllers/user.controller'; import WorkingGroupController from './controllers/working-group.controller'; import { AssetContentfulDataProvider } from './data-providers/contentful/asset.data-provider'; import { CalendarContentfulDataProvider } from './data-providers/contentful/calendar.data-provider'; +import { ComplianceReportContentfulDataProvider } from './data-providers/contentful/compliance-report.data-provider'; import { DashboardContentfulDataProvider } from './data-providers/contentful/dashboard.data-provider'; import { DiscoverContentfulDataProvider } from './data-providers/contentful/discover.data-provider'; import { EventContentfulDataProvider } from './data-providers/contentful/event.data-provider'; @@ -71,6 +73,7 @@ import { WorkingGroupContentfulDataProvider } from './data-providers/contentful/ import { GuideContentfulDataProvider } from './data-providers/contentful/guide.data-provider'; import { AssetDataProvider, + ComplianceReportDataProvider, DashboardDataProvider, DiscoverDataProvider, GuideDataProvider, @@ -113,6 +116,7 @@ import { ExternalAuthorDataProvider } from './data-providers/types/external-auth import { TeamDataProvider } from './data-providers/types/teams.data-provider.types'; import { AnalyticsContentfulDataProvider } from './data-providers/contentful/analytics.data-provider'; import { GenerativeContentDataProvider } from './data-providers/contentful/generative-content.data-provider'; +import { complianceReportRouteFactory } from './routes/compliance-report.route'; export const appFactory = (libs: Libs = {}): Express => { const app = express(); @@ -204,6 +208,10 @@ export const appFactory = (libs: Libs = {}): Express => { getContentfulRestClientFactory, ); + const complianceReportDataProvider = + libs.complianceReportDataProvider || + new ComplianceReportContentfulDataProvider(getContentfulRestClientFactory); + const workingGroupDataProvider = libs.workingGroupDataProvider || new WorkingGroupContentfulDataProvider( @@ -263,6 +271,9 @@ export const appFactory = (libs: Libs = {}): Express => { libs.analyticsController || new AnalyticsController(analyticsDataProvider); const calendarController = libs.calendarController || new CalendarController(calendarDataProvider); + const complianceReportController = + libs.complianceReportController || + new ComplianceReportController(complianceReportDataProvider); const dashboardController = libs.dashboardController || new DashboardController(dashboardDataProvider); const newsController = @@ -331,6 +342,9 @@ export const appFactory = (libs: Libs = {}): Express => { // Routes const analyticsRoutes = analyticsRouteFactory(analyticsController); const calendarRoutes = calendarRouteFactory(calendarController); + const complianceReportRoutes = complianceReportRouteFactory( + complianceReportController, + ); const dashboardRoutes = dashboardRouteFactory(dashboardController); const discoverRoutes = discoverRouteFactory(discoverController); const guideRoutes = guideRouteFactory(guideController); @@ -399,6 +413,7 @@ export const appFactory = (libs: Libs = {}): Express => { */ app.use(analyticsRoutes); app.use(calendarRoutes); + app.use(complianceReportRoutes); app.use(dashboardRoutes); app.use(discoverRoutes); app.use(guideRoutes); @@ -436,6 +451,7 @@ export type Libs = { analyticsDataProvider?: AnalyticsContentfulDataProvider; analyticsController?: AnalyticsController; calendarController?: CalendarController; + complianceReportController?: ComplianceReportController; dashboardController?: DashboardController; discoverController?: DiscoverController; guideController?: GuideController; @@ -454,6 +470,7 @@ export type Libs = { workingGroupController?: WorkingGroupController; assetDataProvider?: AssetDataProvider; calendarDataProvider?: CalendarDataProvider; + complianceReportDataProvider?: ComplianceReportDataProvider; dashboardDataProvider?: DashboardDataProvider; discoverDataProvider?: DiscoverDataProvider; guideDataProvider?: GuideDataProvider; diff --git a/apps/crn-server/src/controllers/compliance-report.controller.ts b/apps/crn-server/src/controllers/compliance-report.controller.ts new file mode 100644 index 0000000000..ff0ebf2cf3 --- /dev/null +++ b/apps/crn-server/src/controllers/compliance-report.controller.ts @@ -0,0 +1,15 @@ +import { ComplianceReportCreateDataObject } from '@asap-hub/model'; + +import { ComplianceReportDataProvider } from '../data-providers/types'; + +export default class ComplianceReportController { + constructor( + private complianceReportDataProvider: ComplianceReportDataProvider, + ) {} + + async create( + complianceReportCreateData: ComplianceReportCreateDataObject, + ): Promise { + return this.complianceReportDataProvider.create(complianceReportCreateData); + } +} diff --git a/apps/crn-server/src/data-providers/contentful/compliance-report.data-provider.ts b/apps/crn-server/src/data-providers/contentful/compliance-report.data-provider.ts new file mode 100644 index 0000000000..765659bfcc --- /dev/null +++ b/apps/crn-server/src/data-providers/contentful/compliance-report.data-provider.ts @@ -0,0 +1,47 @@ +import { + addLocaleToFields, + Environment, + getLinkEntity, +} from '@asap-hub/contentful'; +import { + ComplianceReportCreateDataObject, + ComplianceReportDataObject, + ListResponse, +} from '@asap-hub/model'; + +import { ComplianceReportDataProvider } from '../types'; + +export class ComplianceReportContentfulDataProvider + implements ComplianceReportDataProvider +{ + constructor(private getRestClient: () => Promise) {} + + async fetch(): Promise> { + throw new Error('Method not implemented.'); + } + + async fetchById(): Promise { + throw new Error('Method not implemented.'); + } + + async create(input: ComplianceReportCreateDataObject): Promise { + const environment = await this.getRestClient(); + + const { manuscriptVersionId, ...payload } = input; + + const complianceReport = await environment.createEntry( + 'complianceReports', + { + fields: { + ...addLocaleToFields({ + ...payload, + manuscriptVersion: getLinkEntity(manuscriptVersionId), + }), + }, + }, + ); + + await complianceReport.publish(); + return complianceReport.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 105bc3eb4c..f5593e497f 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 @@ -25,6 +25,13 @@ import { parseUserDisplayName } from '@asap-hub/server-common'; import { ManuscriptDataProvider } from '../types'; type ManuscriptItem = NonNullable; +type ComplianceReport = NonNullable< + NonNullable< + NonNullable< + NonNullable['items'][number] + >['linkedFrom'] + >['complianceReportsCollection'] +>['items'][number]; export class ManuscriptContentfulDataProvider implements ManuscriptDataProvider @@ -144,6 +151,7 @@ export const parseGraphqlManuscriptVersion = ( ): ManuscriptVersion[] => versions .map((version) => ({ + id: version?.sys.id, type: version?.type, lifecycle: version?.lifecycle, manuscriptFile: { @@ -227,6 +235,9 @@ export const parseGraphqlManuscriptVersion = ( id: labItem?.sys.id, name: labItem?.name, })), + complianceReport: parseComplianceReport( + version?.linkedFrom?.complianceReportsCollection?.items[0], + ), })) .filter( (version) => @@ -239,3 +250,11 @@ export const parseGraphqlManuscriptVersion = ( )) || false, ) as ManuscriptVersion[]; + +const parseComplianceReport = ( + complianceReport: ComplianceReport | undefined, +) => + complianceReport && { + url: complianceReport.url, + description: complianceReport.description, + }; diff --git a/apps/crn-server/src/data-providers/types/compliance-report.data-provider.types.ts b/apps/crn-server/src/data-providers/types/compliance-report.data-provider.types.ts new file mode 100644 index 0000000000..fd00bedfe5 --- /dev/null +++ b/apps/crn-server/src/data-providers/types/compliance-report.data-provider.types.ts @@ -0,0 +1,12 @@ +import { + ComplianceReportCreateDataObject, + ComplianceReportDataObject, + DataProvider, +} from '@asap-hub/model'; + +export type ComplianceReportDataProvider = DataProvider< + ComplianceReportDataObject, + ComplianceReportDataObject, + null, + ComplianceReportCreateDataObject +>; diff --git a/apps/crn-server/src/data-providers/types/index.ts b/apps/crn-server/src/data-providers/types/index.ts index 4d4be3e8df..26f1bd1efc 100644 --- a/apps/crn-server/src/data-providers/types/index.ts +++ b/apps/crn-server/src/data-providers/types/index.ts @@ -1,5 +1,6 @@ export * from './analytics.data-provider.types'; export * from './assets.data-provider.types'; +export * from './compliance-report.data-provider.types'; export * from './dashboard.data-provider.types'; export * from './discover.data-provider.types'; export * from './guide.data-provider.types'; diff --git a/apps/crn-server/src/routes/compliance-report.route.ts b/apps/crn-server/src/routes/compliance-report.route.ts new file mode 100644 index 0000000000..babbbbf7da --- /dev/null +++ b/apps/crn-server/src/routes/compliance-report.route.ts @@ -0,0 +1,28 @@ +import { isCMSAdministrator } from '@asap-hub/validation'; +import Boom from '@hapi/boom'; +import { Router } from 'express'; +import ComplianceReportController from '../controllers/compliance-report.controller'; +import { validateComplianceReportPostRequestParameters } from '../validation/compliance-report.validation'; + +export const complianceReportRouteFactory = ( + complianceReportController: ComplianceReportController, +): Router => { + const complianceReportRoutes = Router(); + + complianceReportRoutes.post('/compliance-reports', async (req, res) => { + const { body, loggedInUser } = req; + const createRequest = validateComplianceReportPostRequestParameters(body); + + if (!loggedInUser || !isCMSAdministrator(loggedInUser.role)) { + throw Boom.forbidden(); + } + + const complianceReport = await complianceReportController.create({ + ...createRequest, + }); + + res.status(201).json(complianceReport); + }); + + return complianceReportRoutes; +}; diff --git a/apps/crn-server/src/validation/compliance-report.validation.ts b/apps/crn-server/src/validation/compliance-report.validation.ts new file mode 100644 index 0000000000..ac50b5c7a5 --- /dev/null +++ b/apps/crn-server/src/validation/compliance-report.validation.ts @@ -0,0 +1,27 @@ +import { ComplianceReportPostRequest } from '@asap-hub/model'; +import { validateInput } from '@asap-hub/server-common'; +import { urlExpression } from '@asap-hub/validation'; +import { JSONSchemaType } from 'ajv'; + +const complianceReportPostRequestValidationSchema: JSONSchemaType = + { + type: 'object', + properties: { + description: { type: 'string' }, + url: { + type: 'string', + pattern: urlExpression, + }, + manuscriptVersionId: { type: 'string' }, + }, + required: ['description', 'url', 'manuscriptVersionId'], + additionalProperties: false, + }; + +export const validateComplianceReportPostRequestParameters = validateInput( + complianceReportPostRequestValidationSchema, + { + skipNull: true, + coerce: true, + }, +); diff --git a/apps/crn-server/test/controllers/compliance-report.controller.test.ts b/apps/crn-server/test/controllers/compliance-report.controller.test.ts new file mode 100644 index 0000000000..d77f335fe5 --- /dev/null +++ b/apps/crn-server/test/controllers/compliance-report.controller.test.ts @@ -0,0 +1,46 @@ +import { GenericError } from '@asap-hub/errors'; +import ComplianceReportController from '../../src/controllers/compliance-report.controller'; +import { ComplianceReportDataProvider } from '../../src/data-providers/types'; +import { getComplianceReportCreateDataObject } from '../fixtures/compliance-reports.fixtures'; +import { getDataProviderMock } from '../mocks/data-provider.mock'; + +describe('Compliance Report controller', () => { + const complianceReportDataProviderMock: jest.Mocked = + getDataProviderMock(); + + const complianceReportController = new ComplianceReportController( + complianceReportDataProviderMock, + ); + + describe('Create method', () => { + beforeEach(jest.clearAllMocks); + + test('Should throw when fails to create the compliance report', async () => { + complianceReportDataProviderMock.create.mockRejectedValueOnce( + new GenericError(), + ); + + await expect( + complianceReportController.create( + getComplianceReportCreateDataObject(), + ), + ).rejects.toThrow(GenericError); + }); + + test('Should create the new compliance report and return its id', async () => { + const complianceReportId = 'compliance-report-id-1'; + complianceReportDataProviderMock.create.mockResolvedValueOnce( + complianceReportId, + ); + + const result = await complianceReportController.create( + getComplianceReportCreateDataObject(), + ); + + expect(result).toEqual(complianceReportId); + expect(complianceReportDataProviderMock.create).toHaveBeenCalledWith( + getComplianceReportCreateDataObject(), + ); + }); + }); +}); diff --git a/apps/crn-server/test/data-providers/contentful/compliance-reports.data-provider.test.ts b/apps/crn-server/test/data-providers/contentful/compliance-reports.data-provider.test.ts new file mode 100644 index 0000000000..8a31679bdd --- /dev/null +++ b/apps/crn-server/test/data-providers/contentful/compliance-reports.data-provider.test.ts @@ -0,0 +1,82 @@ +import { Entry, Environment } from '@asap-hub/contentful'; + +import { when } from 'jest-when'; +import { ComplianceReportContentfulDataProvider } from '../../../src/data-providers/contentful/compliance-report.data-provider'; + +import { getComplianceReportCreateDataObject } from '../../fixtures/compliance-reports.fixtures'; +import { getContentfulEnvironmentMock } from '../../mocks/contentful-rest-client.mock'; + +describe('Compliance Reports Contentful Data Provider', () => { + const environmentMock = getContentfulEnvironmentMock(); + const contentfulRestClientMock: () => Promise = () => + Promise.resolve(environmentMock); + + const complianceReportDataProvider = + new ComplianceReportContentfulDataProvider(contentfulRestClientMock); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Create', () => { + test('can create a compliance report', async () => { + const complianceReportId = 'compliance-report-id-1'; + const complianceReportCreateDataObject = + getComplianceReportCreateDataObject(); + + const publish = jest.fn(); + + when(environmentMock.createEntry) + .calledWith('complianceReports', expect.anything()) + .mockResolvedValue({ + sys: { id: complianceReportId }, + publish, + } as unknown as Entry); + + const result = await complianceReportDataProvider.create({ + ...complianceReportCreateDataObject, + }); + + expect(environmentMock.createEntry).toHaveBeenCalledWith( + 'complianceReports', + { + fields: { + url: { + 'en-US': 'http://example.com', + }, + description: { + 'en-US': 'compliance report description', + }, + manuscriptVersion: { + 'en-US': { + sys: { + id: 'manuscript-version-1', + linkType: 'Entry', + type: 'Link', + }, + }, + }, + }, + }, + ); + expect(publish).toHaveBeenCalled(); + expect(result).toEqual(complianceReportId); + }); + }); + + describe('Fetch', () => { + test('should throw an error', async () => { + await expect(complianceReportDataProvider.fetch()).rejects.toThrow( + 'Method not implemented.', + ); + }); + }); + + describe('Fetch by ID', () => { + test('should throw an error', async () => { + await expect(complianceReportDataProvider.fetchById()).rejects.toThrow( + 'Method not implemented.', + ); + }); + }); +}); diff --git a/apps/crn-server/test/fixtures/compliance-reports.fixtures.ts b/apps/crn-server/test/fixtures/compliance-reports.fixtures.ts new file mode 100644 index 0000000000..2dae01c2b6 --- /dev/null +++ b/apps/crn-server/test/fixtures/compliance-reports.fixtures.ts @@ -0,0 +1,20 @@ +import { + ComplianceReportCreateDataObject, + ComplianceReportDataObject, +} from '@asap-hub/model'; + +export const getComplianceReportDataObject = + (): ComplianceReportDataObject => ({ + url: 'http://example.com', + description: 'compliance report description', + }); + +export const getComplianceReportCreateDataObject = + (): ComplianceReportCreateDataObject => { + const complianceReport = getComplianceReportDataObject(); + + return { + ...complianceReport, + manuscriptVersionId: 'manuscript-version-1', + }; + }; diff --git a/apps/crn-server/test/fixtures/manuscript.fixtures.ts b/apps/crn-server/test/fixtures/manuscript.fixtures.ts index d9ca2f9aa8..85c8bb36eb 100644 --- a/apps/crn-server/test/fixtures/manuscript.fixtures.ts +++ b/apps/crn-server/test/fixtures/manuscript.fixtures.ts @@ -17,6 +17,7 @@ export const getManuscriptDataObject = ( teamId: 'team-1', versions: [ { + id: 'version-1', lifecycle: 'Preprint', type: 'Original Research', createdBy: manuscriptAuthor, @@ -140,8 +141,9 @@ export const getManuscriptPostBody = (): ManuscriptPostRequest => { const { createdBy: _, createdDate: __, - publishedAt: ___, - teams: ____, + id: ___, + publishedAt: ____, + teams: _____, ...version } = versions[0]!; return { @@ -189,6 +191,7 @@ export const getManuscriptCreateDataObject = (): ManuscriptCreateDataObject => { teams: _, publishedAt: __, createdDate: ___, + id: ____, ...version } = versions[0]!; diff --git a/apps/crn-server/test/fixtures/teams.fixtures.ts b/apps/crn-server/test/fixtures/teams.fixtures.ts index c6798eff29..e01dc4fc93 100644 --- a/apps/crn-server/test/fixtures/teams.fixtures.ts +++ b/apps/crn-server/test/fixtures/teams.fixtures.ts @@ -213,6 +213,7 @@ export const getTeamDataObject = (): TeamDataObject => ({ title: 'Manuscript 1', versions: [ { + id: 'version-1', lifecycle: 'Preprint', type: 'Original Research', createdBy: manuscriptAuthor, @@ -243,6 +244,7 @@ export const getTeamDataObject = (): TeamDataObject => ({ title: 'Manuscript 2', versions: [ { + id: 'version-1', lifecycle: 'Preprint', type: 'Original Research', createdBy: manuscriptAuthor, diff --git a/apps/crn-server/test/mocks/compliance-report.controller.mock.ts b/apps/crn-server/test/mocks/compliance-report.controller.mock.ts new file mode 100644 index 0000000000..550102e3a3 --- /dev/null +++ b/apps/crn-server/test/mocks/compliance-report.controller.mock.ts @@ -0,0 +1,5 @@ +import ComplianceReportController from '../../src/controllers/compliance-report.controller'; + +export const complianceReportControllerMock = { + create: jest.fn(), +} as unknown as jest.Mocked; diff --git a/apps/crn-server/test/routes/compliance-report.route.test.ts b/apps/crn-server/test/routes/compliance-report.route.test.ts new file mode 100644 index 0000000000..1031607d55 --- /dev/null +++ b/apps/crn-server/test/routes/compliance-report.route.test.ts @@ -0,0 +1,129 @@ +import { createUserResponse } from '@asap-hub/fixtures'; +import { UserResponse } from '@asap-hub/model'; +import { AuthHandler } from '@asap-hub/server-common'; +import supertest from 'supertest'; + +import { appFactory } from '../../src/app'; +import { getComplianceReportCreateDataObject } from '../fixtures/compliance-reports.fixtures'; +import { loggerMock } from '../mocks/logger.mock'; +import { complianceReportControllerMock } from '../mocks/compliance-report.controller.mock'; + +describe('/compliance-reports/ route', () => { + const userMockFactory = jest.fn(); + const authHandlerMock: AuthHandler = (req, _res, next) => { + req.loggedInUser = userMockFactory(); + next(); + }; + + const app = appFactory({ + complianceReportController: complianceReportControllerMock, + authHandler: authHandlerMock, + logger: loggerMock, + }); + + beforeEach(() => { + userMockFactory.mockReturnValue({ ...createUserResponse(), role: 'Staff' }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('POST /compliance-reports/', () => { + const complianceReportId = 'compliance-report-id'; + const createComplianceReportRequest = getComplianceReportCreateDataObject(); + + test('Should return 403 when not allowed to create a compliance report because user is not onboarded', async () => { + userMockFactory.mockReturnValueOnce({ + ...createUserResponse(), + onboarded: false, + role: 'Staff', + }); + + const response = await supertest(app) + .post('/compliance-reports') + .send(createComplianceReportRequest) + .set('Accept', 'application/json'); + + expect(response.status).toEqual(403); + }); + + test('Should return 403 when not allowed to create a compliance report because user is not ASAP Staff', async () => { + userMockFactory.mockReturnValueOnce({ + ...createUserResponse(), + onboarded: true, + role: 'Grantee', + }); + + const response = await supertest(app) + .post('/compliance-reports') + .send(createComplianceReportRequest) + .set('Accept', 'application/json'); + + expect(response.status).toEqual(403); + }); + + test('Should return a 201 and pass input to the controller', async () => { + userMockFactory.mockReturnValueOnce({ + ...createUserResponse(), + role: 'Staff', + }); + + complianceReportControllerMock.create.mockResolvedValueOnce( + complianceReportId, + ); + + const response = await supertest(app) + .post('/compliance-reports') + .send(createComplianceReportRequest) + .set('Accept', 'application/json'); + + expect(response.status).toBe(201); + expect(complianceReportControllerMock.create).toHaveBeenCalledWith( + createComplianceReportRequest, + ); + + expect(response.body).toEqual(complianceReportId); + }); + + describe('Validation', () => { + test('Should return 400 when url is missing', async () => { + const { url: _url, ...createComplianceReportRequest } = + getComplianceReportCreateDataObject(); + + const response = await supertest(app) + .post('/compliance-reports') + .send(createComplianceReportRequest) + .set('Accept', 'application/json'); + + expect(response.status).toEqual(400); + }); + + test('Should return 400 when description is missing', async () => { + const { description: _description, ...createComplianceReportRequest } = + getComplianceReportCreateDataObject(); + + const response = await supertest(app) + .post('/compliance-reports') + .send(createComplianceReportRequest) + .set('Accept', 'application/json'); + + expect(response.status).toEqual(400); + }); + + test('Should return 400 when manuscriptVersionId is missing', async () => { + const { + manuscriptVersionId: _manuscriptVersionId, + ...createComplianceReportRequest + } = getComplianceReportCreateDataObject(); + + const response = await supertest(app) + .post('/compliance-reports') + .send(createComplianceReportRequest) + .set('Accept', 'application/json'); + + expect(response.status).toEqual(400); + }); + }); + }); +}); diff --git a/apps/storybook/src/TeamProfileWorkspace.stories.tsx b/apps/storybook/src/TeamProfileWorkspace.stories.tsx index 8c62ed85a1..37e666b02f 100644 --- a/apps/storybook/src/TeamProfileWorkspace.stories.tsx +++ b/apps/storybook/src/TeamProfileWorkspace.stories.tsx @@ -17,5 +17,6 @@ export const Normal = () => ( description: 'Tool Description', }, ]} + canShareComplianceReport={false} /> ); diff --git a/packages/contentful/migrations/crn/complianceReports/20241004101557-create-compliance-report-model.js b/packages/contentful/migrations/crn/complianceReports/20241004101557-create-compliance-report-model.js new file mode 100644 index 0000000000..b07ad7ffe5 --- /dev/null +++ b/packages/contentful/migrations/crn/complianceReports/20241004101557-create-compliance-report-model.js @@ -0,0 +1,66 @@ +module.exports.description = 'Create Compliance Reports content model'; + +module.exports.up = (migration) => { + const complianceReports = migration + .createContentType('complianceReports') + .name('Compliance Reports') + .description('') + .displayField('url'); + + complianceReports + .createField('url') + .name('URL') + .type('Symbol') + .localized(false) + .required(true) + .validations([ + { + regexp: { + pattern: + '^(ftp|http|https):\\/\\/(\\w+:{0,1}\\w*@)?(\\S+)(:[0-9]+)?(\\/|\\/([\\w#!:.?+=&%@!\\-/]))?$', + flags: null, + }, + }, + ]) + .disabled(false) + .omitted(false); + + complianceReports + .createField('description') + .name('Description') + .type('Text') + .localized(false) + .required(true) + .validations([]) + .disabled(false) + .omitted(false); + + complianceReports + .createField('manuscriptVersion') + .name('Manuscript Version') + .type('Link') + .localized(false) + .required(true) + .validations([ + { + linkContentType: ['manuscriptVersions'], + }, + ]) + .disabled(false) + .omitted(false) + .linkType('Entry'); + + complianceReports.changeFieldControl( + 'manuscriptVersion', + 'builtin', + 'entryLinkEditor', + { + showLinkEntityAction: true, + showCreateEntityAction: false, + }, + ); +}; + +module.exports.down = (migration) => { + migration.deleteContentType('complianceReports'); +}; diff --git a/packages/contentful/src/crn/autogenerated-gql/gql.ts b/packages/contentful/src/crn/autogenerated-gql/gql.ts index 19f44080f0..6e9abc79b4 100644 --- a/packages/contentful/src/crn/autogenerated-gql/gql.ts +++ b/packages/contentful/src/crn/autogenerated-gql/gql.ts @@ -73,7 +73,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 versionsCollection(limit: 20, order: sys_publishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\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 asapAffiliationIncluded\n asapAffiliationIncludedDetails\n manuscriptLicense\n manuscriptLicenseDetails\n datasetsDeposited\n datasetsDepositedDetails\n codeDeposited\n codeDepositedDetails\n protocolsDeposited\n protocolsDepositedDetails\n labMaterialsRegistered\n labMaterialsRegisteredDetails\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 }\n }\n }\n': + '\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n versionsCollection(limit: 20, order: sys_publishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\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 asapAffiliationIncluded\n asapAffiliationIncludedDetails\n manuscriptLicense\n manuscriptLicenseDetails\n datasetsDeposited\n datasetsDepositedDetails\n codeDeposited\n codeDepositedDetails\n protocolsDeposited\n protocolsDepositedDetails\n labMaterialsRegistered\n labMaterialsRegisteredDetails\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 linkedFrom {\n complianceReportsCollection(limit: 1) {\n items {\n url\n description\n }\n }\n }\n }\n }\n }\n': types.ManuscriptsContentFragmentDoc, '\n query FetchManuscriptById($id: String!) {\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, @@ -333,8 +333,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 versionsCollection(limit: 20, order: sys_publishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\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 asapAffiliationIncluded\n asapAffiliationIncludedDetails\n manuscriptLicense\n manuscriptLicenseDetails\n datasetsDeposited\n datasetsDepositedDetails\n codeDeposited\n codeDepositedDetails\n protocolsDeposited\n protocolsDepositedDetails\n labMaterialsRegistered\n labMaterialsRegisteredDetails\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 }\n }\n }\n', -): (typeof documents)['\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n versionsCollection(limit: 20, order: sys_publishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\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 asapAffiliationIncluded\n asapAffiliationIncludedDetails\n manuscriptLicense\n manuscriptLicenseDetails\n datasetsDeposited\n datasetsDepositedDetails\n codeDeposited\n codeDepositedDetails\n protocolsDeposited\n protocolsDepositedDetails\n labMaterialsRegistered\n labMaterialsRegisteredDetails\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 }\n }\n }\n']; + source: '\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n versionsCollection(limit: 20, order: sys_publishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\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 asapAffiliationIncluded\n asapAffiliationIncludedDetails\n manuscriptLicense\n manuscriptLicenseDetails\n datasetsDeposited\n datasetsDepositedDetails\n codeDeposited\n codeDepositedDetails\n protocolsDeposited\n protocolsDepositedDetails\n labMaterialsRegistered\n labMaterialsRegisteredDetails\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 linkedFrom {\n complianceReportsCollection(limit: 1) {\n items {\n url\n description\n }\n }\n }\n }\n }\n }\n', +): (typeof documents)['\n fragment ManuscriptsContent on Manuscripts {\n sys {\n id\n }\n title\n versionsCollection(limit: 20, order: sys_publishedAt_DESC) {\n items {\n sys {\n id\n publishedAt\n firstPublishedAt\n }\n type\n lifecycle\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 asapAffiliationIncluded\n asapAffiliationIncludedDetails\n manuscriptLicense\n manuscriptLicenseDetails\n datasetsDeposited\n datasetsDepositedDetails\n codeDeposited\n codeDepositedDetails\n protocolsDeposited\n protocolsDepositedDetails\n labMaterialsRegistered\n labMaterialsRegisteredDetails\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 linkedFrom {\n complianceReportsCollection(limit: 1) {\n items {\n url\n description\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 ec00a0087a..bca8790d66 100644 --- a/packages/contentful/src/crn/autogenerated-gql/graphql.ts +++ b/packages/contentful/src/crn/autogenerated-gql/graphql.ts @@ -605,6 +605,94 @@ export enum CalendarsOrder { SysPublishedVersionDesc = 'sys_publishedVersion_DESC', } +/** [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/complianceReports) */ +export type ComplianceReports = Entry & + _Node & { + _id: Scalars['ID']; + contentfulMetadata: ContentfulMetadata; + description?: Maybe; + linkedFrom?: Maybe; + manuscriptVersion?: Maybe; + sys: Sys; + url?: Maybe; + }; + +/** [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/complianceReports) */ +export type ComplianceReportsDescriptionArgs = { + locale?: InputMaybe; +}; + +/** [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/complianceReports) */ +export type ComplianceReportsLinkedFromArgs = { + allowedLocales?: InputMaybe>>; +}; + +/** [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/complianceReports) */ +export type ComplianceReportsManuscriptVersionArgs = { + locale?: InputMaybe; + preview?: InputMaybe; + where?: InputMaybe; +}; + +/** [See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/complianceReports) */ +export type ComplianceReportsUrlArgs = { + locale?: InputMaybe; +}; + +export type ComplianceReportsCollection = { + items: Array>; + limit: Scalars['Int']; + skip: Scalars['Int']; + total: Scalars['Int']; +}; + +export type ComplianceReportsFilter = { + AND?: InputMaybe>>; + OR?: InputMaybe>>; + contentfulMetadata?: InputMaybe; + description?: InputMaybe; + description_contains?: InputMaybe; + description_exists?: InputMaybe; + description_in?: InputMaybe>>; + description_not?: InputMaybe; + description_not_contains?: InputMaybe; + description_not_in?: InputMaybe>>; + manuscriptVersion?: InputMaybe; + manuscriptVersion_exists?: InputMaybe; + sys?: InputMaybe; + url?: InputMaybe; + url_contains?: InputMaybe; + url_exists?: InputMaybe; + url_in?: InputMaybe>>; + url_not?: InputMaybe; + url_not_contains?: InputMaybe; + url_not_in?: InputMaybe>>; +}; + +export type ComplianceReportsLinkingCollections = { + entryCollection?: Maybe; +}; + +export type ComplianceReportsLinkingCollectionsEntryCollectionArgs = { + limit?: InputMaybe; + locale?: InputMaybe; + preview?: InputMaybe; + skip?: InputMaybe; +}; + +export enum ComplianceReportsOrder { + 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 type ContentfulMetadata = { tags: Array>; }; @@ -4160,10 +4248,24 @@ export enum ManuscriptVersionsLabsCollectionOrder { } export type ManuscriptVersionsLinkingCollections = { + complianceReportsCollection?: Maybe; entryCollection?: Maybe; manuscriptsCollection?: Maybe; }; +export type ManuscriptVersionsLinkingCollectionsComplianceReportsCollectionArgs = + { + limit?: InputMaybe; + locale?: InputMaybe; + order?: InputMaybe< + Array< + InputMaybe + > + >; + preview?: InputMaybe; + skip?: InputMaybe; + }; + export type ManuscriptVersionsLinkingCollectionsEntryCollectionArgs = { limit?: InputMaybe; locale?: InputMaybe; @@ -4183,6 +4285,19 @@ export type ManuscriptVersionsLinkingCollectionsManuscriptsCollectionArgs = { skip?: InputMaybe; }; +export enum ManuscriptVersionsLinkingCollectionsComplianceReportsCollectionOrder { + 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 ManuscriptVersionsLinkingCollectionsManuscriptsCollectionOrder { SysFirstPublishedAtAsc = 'sys_firstPublishedAt_ASC', SysFirstPublishedAtDesc = 'sys_firstPublishedAt_DESC', @@ -5057,6 +5172,8 @@ export type Query = { assetCollection?: Maybe; calendars?: Maybe; calendarsCollection?: Maybe; + complianceReports?: Maybe; + complianceReportsCollection?: Maybe; dashboard?: Maybe; dashboardCollection?: Maybe; discover?: Maybe; @@ -5175,6 +5292,21 @@ export type QueryCalendarsCollectionArgs = { where?: InputMaybe; }; +export type QueryComplianceReportsArgs = { + id: Scalars['String']; + locale?: InputMaybe; + preview?: InputMaybe; +}; + +export type QueryComplianceReportsCollectionArgs = { + limit?: InputMaybe; + locale?: InputMaybe; + order?: InputMaybe>>; + preview?: InputMaybe; + skip?: InputMaybe; + where?: InputMaybe; +}; + export type QueryDashboardArgs = { id: Scalars['String']; locale?: InputMaybe; @@ -12903,6 +13035,9 @@ export type FetchDashboardQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -13046,6 +13181,9 @@ export type FetchDashboardQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -13213,6 +13351,9 @@ export type FetchDiscoverQuery = { sys: Pick; }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { @@ -13381,6 +13522,7 @@ export type EventsContentFragment = Pick< Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { sys: Pick }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -13451,6 +13593,7 @@ export type EventsContentFragment = Pick< Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { sys: Pick }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -13521,6 +13664,7 @@ export type EventsContentFragment = Pick< Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { sys: Pick }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -13708,6 +13852,9 @@ export type FetchEventByIdQuery = { Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -13790,6 +13937,9 @@ export type FetchEventByIdQuery = { Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -13872,6 +14022,9 @@ export type FetchEventByIdQuery = { Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -14090,6 +14243,9 @@ export type FetchEventsQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -14202,6 +14358,9 @@ export type FetchEventsQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -14314,6 +14473,9 @@ export type FetchEventsQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -14577,6 +14739,9 @@ export type FetchEventsByUserIdQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -14706,6 +14871,9 @@ export type FetchEventsByUserIdQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -14835,6 +15003,9 @@ export type FetchEventsByUserIdQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -15140,6 +15311,9 @@ export type FetchEventsByExternalAuthorIdQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -15269,6 +15443,9 @@ export type FetchEventsByExternalAuthorIdQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -15398,6 +15575,9 @@ export type FetchEventsByExternalAuthorIdQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -15703,6 +15883,9 @@ export type FetchEventsByTeamIdQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -15832,6 +16015,9 @@ export type FetchEventsByTeamIdQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -15961,6 +16147,9 @@ export type FetchEventsByTeamIdQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -16756,6 +16945,13 @@ export type ManuscriptsContentFragment = Pick & { }>; } >; + linkedFrom?: Maybe<{ + complianceReportsCollection?: Maybe<{ + items: Array< + Maybe> + >; + }>; + }>; } > >; @@ -16847,6 +17043,13 @@ export type FetchManuscriptByIdQuery = { }>; } >; + linkedFrom?: Maybe<{ + complianceReportsCollection?: Maybe<{ + items: Array< + Maybe> + >; + }>; + }>; } > >; @@ -16869,6 +17072,7 @@ export type NewsContentFragment = Pick< Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { sys: Pick }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -16954,6 +17158,9 @@ export type FetchNewsByIdQuery = { Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -17070,6 +17277,9 @@ export type FetchNewsQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -17193,6 +17403,7 @@ export type PageContentFragment = Pick< Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { sys: Pick }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -17280,6 +17491,9 @@ export type FetchPagesQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -17595,6 +17809,7 @@ export type ResearchOutputsContentFragment = Pick< Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { sys: Pick }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -17822,6 +18037,9 @@ export type FetchResearchOutputByIdQuery = { Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -18079,6 +18297,9 @@ export type FetchResearchOutputsQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -18488,6 +18709,15 @@ export type FetchTeamByIdQuery = { }>; } >; + linkedFrom?: Maybe<{ + complianceReportsCollection?: Maybe<{ + items: Array< + Maybe< + Pick + > + >; + }>; + }>; } > >; @@ -18609,6 +18839,7 @@ export type TutorialsContentFragment = Pick< Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { sys: Pick }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -18743,6 +18974,9 @@ export type FetchTutorialByIdQuery = { Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -18908,6 +19142,9 @@ export type FetchTutorialsQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -19825,6 +20062,7 @@ export type WorkingGroupsContentFragment = Pick< Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { sys: Pick }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -19988,6 +20226,9 @@ export type FetchWorkingGroupByIdQuery = { Maybe< | ({ __typename: 'Announcements' } & { sys: Pick }) | ({ __typename: 'Calendars' } & { sys: Pick }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick }) | ({ __typename: 'Discover' } & { sys: Pick }) | ({ __typename: 'EventSpeakers' } & { sys: Pick }) @@ -20179,6 +20420,9 @@ export type FetchWorkingGroupsQuery = { | ({ __typename: 'Calendars' } & { sys: Pick; }) + | ({ __typename: 'ComplianceReports' } & { + sys: Pick; + }) | ({ __typename: 'Dashboard' } & { sys: Pick; }) @@ -22557,6 +22801,54 @@ export const ManuscriptsContentFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'linkedFrom' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { + kind: 'Name', + value: 'complianceReportsCollection', + }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'limit' }, + value: { kind: 'IntValue', value: '1' }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'items' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'url' }, + }, + { + kind: 'Field', + name: { + kind: 'Name', + value: 'description', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, ], }, }, diff --git a/packages/contentful/src/crn/queries/manuscript.queries.ts b/packages/contentful/src/crn/queries/manuscript.queries.ts index 7ab2ebf8d6..6ea71187d1 100644 --- a/packages/contentful/src/crn/queries/manuscript.queries.ts +++ b/packages/contentful/src/crn/queries/manuscript.queries.ts @@ -99,6 +99,14 @@ export const manuscriptContentQueryFragment = gql` } } } + linkedFrom { + complianceReportsCollection(limit: 1) { + items { + url + description + } + } + } } } } diff --git a/packages/contentful/src/crn/schema/autogenerated-schema.graphql b/packages/contentful/src/crn/schema/autogenerated-schema.graphql index 7a47e2efb9..458a2975a1 100644 --- a/packages/contentful/src/crn/schema/autogenerated-schema.graphql +++ b/packages/contentful/src/crn/schema/autogenerated-schema.graphql @@ -371,6 +371,64 @@ enum CalendarsOrder { sys_publishedVersion_DESC } +"""[See type definition](https://app.contentful.com/spaces/5v6w5j61tndm/content_types/complianceReports)""" +type ComplianceReports implements Entry & _Node { + _id: ID! + contentfulMetadata: ContentfulMetadata! + description(locale: String): String + linkedFrom(allowedLocales: [String]): ComplianceReportsLinkingCollections + manuscriptVersion(locale: String, preview: Boolean, where: ManuscriptVersionsFilter): ManuscriptVersions + sys: Sys! + url(locale: String): String +} + +type ComplianceReportsCollection { + items: [ComplianceReports]! + limit: Int! + skip: Int! + total: Int! +} + +input ComplianceReportsFilter { + AND: [ComplianceReportsFilter] + OR: [ComplianceReportsFilter] + contentfulMetadata: ContentfulMetadataFilter + description: String + description_contains: String + description_exists: Boolean + description_in: [String] + description_not: String + description_not_contains: String + description_not_in: [String] + manuscriptVersion: cfManuscriptVersionsNestedFilter + manuscriptVersion_exists: Boolean + sys: SysFilter + url: String + url_contains: String + url_exists: Boolean + url_in: [String] + url_not: String + url_not_contains: String + url_not_in: [String] +} + +type ComplianceReportsLinkingCollections { + entryCollection(limit: Int = 100, locale: String, preview: Boolean, skip: Int = 0): EntryCollection +} + +enum ComplianceReportsOrder { + 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 +} + type ContentfulMetadata { tags: [ContentfulTag]! } @@ -2945,10 +3003,24 @@ enum ManuscriptVersionsLabsCollectionOrder { } type ManuscriptVersionsLinkingCollections { + complianceReportsCollection(limit: Int = 100, locale: String, order: [ManuscriptVersionsLinkingCollectionsComplianceReportsCollectionOrder], preview: Boolean, skip: Int = 0): ComplianceReportsCollection entryCollection(limit: Int = 100, locale: String, preview: Boolean, skip: Int = 0): EntryCollection manuscriptsCollection(limit: Int = 100, locale: String, order: [ManuscriptVersionsLinkingCollectionsManuscriptsCollectionOrder], preview: Boolean, skip: Int = 0): ManuscriptsCollection } +enum ManuscriptVersionsLinkingCollectionsComplianceReportsCollectionOrder { + 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 ManuscriptVersionsLinkingCollectionsManuscriptsCollectionOrder { sys_firstPublishedAt_ASC sys_firstPublishedAt_DESC @@ -3609,6 +3681,8 @@ type Query { assetCollection(limit: Int = 100, locale: String, order: [AssetOrder], preview: Boolean, skip: Int = 0, where: AssetFilter): AssetCollection calendars(id: String!, locale: String, preview: Boolean): Calendars calendarsCollection(limit: Int = 100, locale: String, order: [CalendarsOrder], preview: Boolean, skip: Int = 0, where: CalendarsFilter): CalendarsCollection + complianceReports(id: String!, locale: String, preview: Boolean): ComplianceReports + complianceReportsCollection(limit: Int = 100, locale: String, order: [ComplianceReportsOrder], preview: Boolean, skip: Int = 0, where: ComplianceReportsFilter): ComplianceReportsCollection dashboard(id: String!, locale: String, preview: Boolean): Dashboard dashboardCollection(limit: Int = 100, locale: String, order: [DashboardOrder], preview: Boolean, skip: Int = 0, where: DashboardFilter): DashboardCollection discover(id: String!, locale: String, preview: Boolean): Discover diff --git a/packages/fixtures/src/manuscripts.ts b/packages/fixtures/src/manuscripts.ts index 648e94ba36..1ae760f6ab 100644 --- a/packages/fixtures/src/manuscripts.ts +++ b/packages/fixtures/src/manuscripts.ts @@ -24,6 +24,7 @@ export const createManuscriptResponse = ( teamId: 'team-1', versions: [ { + id: 'version-1', lifecycle: 'Draft Manuscript (prior to Publication)', type: 'Original Research', createdBy: manuscriptAuthor, diff --git a/packages/model/src/compliance-report.ts b/packages/model/src/compliance-report.ts new file mode 100644 index 0000000000..4e64b75af6 --- /dev/null +++ b/packages/model/src/compliance-report.ts @@ -0,0 +1,10 @@ +export type ComplianceReportDataObject = { + url: string; + description: string; +}; +export type ComplianceReportResponse = ComplianceReportDataObject; +export type ComplianceReportCreateDataObject = ComplianceReportDataObject & { + manuscriptVersionId: string; +}; +export type ComplianceReportPostRequest = ComplianceReportCreateDataObject; +export type ComplianceReportFormData = ComplianceReportDataObject; diff --git a/packages/model/src/index.ts b/packages/model/src/index.ts index bfde0fb8fc..83127b23cc 100644 --- a/packages/model/src/index.ts +++ b/packages/model/src/index.ts @@ -3,6 +3,7 @@ export * from './authors'; export * from './calendar'; export * from './calendar-common'; export * from './common'; +export * from './compliance-report'; export * from './controllers'; export * from './dashboard'; export * from './data-providers'; diff --git a/packages/model/src/manuscript.ts b/packages/model/src/manuscript.ts index 77d1cf2ea7..c984ec18de 100644 --- a/packages/model/src/manuscript.ts +++ b/packages/model/src/manuscript.ts @@ -1,5 +1,6 @@ import { JSONSchemaType } from 'ajv'; import { AuthorAlgoliaResponse } from './authors'; +import { ComplianceReportDataObject } from './compliance-report'; import { UserResponse } from './user'; export const manuscriptTypes = [ @@ -65,6 +66,7 @@ const manuscriptFileTypes = [ export type ManuscriptFileType = (typeof manuscriptFileTypes)[number]; export type ManuscriptVersion = { + id: string; type: ManuscriptType; lifecycle: ManuscriptLifecycle; preprintDoi?: string; @@ -109,6 +111,7 @@ export type ManuscriptVersion = { }; createdDate: string; publishedAt: string; + complianceReport?: ComplianceReportDataObject; }; export const manuscriptFormFieldsMapping: Record< @@ -116,7 +119,10 @@ export const manuscriptFormFieldsMapping: Record< Record< ManuscriptLifecycle, Array< - keyof Omit + keyof Omit< + ManuscriptVersion, + 'complianceReport' | 'createdBy' | 'createdDate' | 'id' | 'publishedAt' + > > > > = { diff --git a/packages/react-components/src/atoms/TextArea.tsx b/packages/react-components/src/atoms/TextArea.tsx index c66f3ce0e3..be0ee932be 100644 --- a/packages/react-components/src/atoms/TextArea.tsx +++ b/packages/react-components/src/atoms/TextArea.tsx @@ -72,7 +72,7 @@ type TextAreaProps = { readonly extras?: React.ReactNode; } & Pick< InputHTMLAttributes, - 'id' | 'placeholder' | 'required' | 'maxLength' + 'id' | 'placeholder' | 'required' | 'maxLength' | 'onBlur' >; const TextArea: React.FC = ({ enabled = true, @@ -85,6 +85,7 @@ const TextArea: React.FC = ({ value, onChange = noop, + onBlur, extras, @@ -122,6 +123,7 @@ const TextArea: React.FC = ({ }, }, ]} + {...(onBlur ? { onBlur } : {})} />
diff --git a/packages/react-components/src/atoms/TextField.tsx b/packages/react-components/src/atoms/TextField.tsx index a7c08b7811..f156e860ec 100644 --- a/packages/react-components/src/atoms/TextField.tsx +++ b/packages/react-components/src/atoms/TextField.tsx @@ -162,6 +162,7 @@ const TextField: React.FC = ({ value, onChange = noop, + onBlur, ...props }) => { @@ -205,6 +206,7 @@ const TextField: React.FC = ({ ':focus': { borderColor: colors.primary500.rgba }, }, ]} + {...(onBlur ? { onBlur } : {})} /> {labelIndicator && ( diff --git a/packages/react-components/src/icons/compliance-report.tsx b/packages/react-components/src/icons/compliance-report.tsx new file mode 100644 index 0000000000..42ea4f3773 --- /dev/null +++ b/packages/react-components/src/icons/compliance-report.tsx @@ -0,0 +1,23 @@ +/* istanbul ignore file */ + +const complianceReportIcon = ( + + Share Compliance Report Icon + + + +); + +export default complianceReportIcon; diff --git a/packages/react-components/src/icons/index.ts b/packages/react-components/src/icons/index.ts index 547585b1bd..7491e373d8 100644 --- a/packages/react-components/src/icons/index.ts +++ b/packages/react-components/src/icons/index.ts @@ -23,6 +23,7 @@ export { default as chevronLeftIcon } from './chevron-left'; export { default as chevronRightIcon } from './chevron-right'; export { default as chevronUpIcon } from './chevron-up'; export { default as clockIcon } from './clock'; +export { default as complianceReportIcon } from './compliance-report'; export { default as confidentialIcon } from './confidential'; export { default as copyIcon } from './copy-icon'; export { default as crnReportIcon } from './crnReport'; diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index acf7cf4e27..d84c21fb32 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -117,6 +117,7 @@ export { CaptionCard, CheckboxGroup, ComingSoon, + ComplianceReportHeader, ConfirmModal, DashboardRecommendedUsers, DashboardUpcomingEvents, @@ -216,6 +217,7 @@ export { AnalyticsPageHeader, BasicLayout, BiographyModal, + ComplianceReportForm, ContactInfoModal, ContentPage, DashboardPage, diff --git a/packages/react-components/src/organisms/ComplianceReportCard.tsx b/packages/react-components/src/organisms/ComplianceReportCard.tsx new file mode 100644 index 0000000000..d825696e4f --- /dev/null +++ b/packages/react-components/src/organisms/ComplianceReportCard.tsx @@ -0,0 +1,98 @@ +import { ComplianceReportResponse } from '@asap-hub/model'; +import { css } from '@emotion/react'; +import { useState } from 'react'; +import { + Button, + minusRectIcon, + plusRectIcon, + Subtitle, + Link, + crnReportIcon, + colors, + externalLinkIcon, + Markdown, +} from '..'; +import { paddingStyles } from '../card'; +import { mobileScreen, perRem, rem } from '../pixels'; + +type ComplianceReportCardProps = ComplianceReportResponse; + +const toastStyles = css({ + padding: `${15 / perRem}em ${24 / perRem}em`, + borderRadius: `${rem(8)} ${rem(8)} 0 0`, +}); + +const iconStyles = css({ + display: 'inline-block', + width: `${24 / perRem}em`, + height: `${24 / perRem}em`, + paddingRight: `${12 / perRem}em`, +}); + +const toastHeaderStyles = css({ + display: 'flex', + alignItems: 'center', + + [`@media (max-width: ${mobileScreen.max}px)`]: { + alignItems: 'flex-start', + }, +}); + +const toastContentStyles = css({ + paddingLeft: `${60 / perRem}em`, + paddingTop: rem(15), +}); + +const externalIconStyle = css({ + display: 'flex', + alignSelf: 'center', + gap: rem(8), + paddingRight: rem(8), + textWrap: 'nowrap', +}); + +const buttonStyles = css({ + width: rem(151), + '> a': { + height: rem(40), + }, +}); + +const ComplianceReportCard: React.FC = ({ + url, + description, +}) => { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ + + + + {crnReportIcon} + Compliance Report + +
+ {expanded && ( +
+
+ +
+ + + {externalLinkIcon} View Report + + +
+
+
+ )} +
+ ); +}; + +export default ComplianceReportCard; diff --git a/packages/react-components/src/organisms/ComplianceReportHeader.tsx b/packages/react-components/src/organisms/ComplianceReportHeader.tsx new file mode 100644 index 0000000000..a63323bac5 --- /dev/null +++ b/packages/react-components/src/organisms/ComplianceReportHeader.tsx @@ -0,0 +1,41 @@ +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 ComplianceReportHeader: React.FC = () => ( +
+
+ Share a Compliance Report +
+ + Share the compliance report associated with this manuscript. + +
+
+
+); + +export default ComplianceReportHeader; diff --git a/packages/react-components/src/organisms/ManuscriptCard.tsx b/packages/react-components/src/organisms/ManuscriptCard.tsx index 2215d0ded0..ce481e4609 100644 --- a/packages/react-components/src/organisms/ManuscriptCard.tsx +++ b/packages/react-components/src/organisms/ManuscriptCard.tsx @@ -1,11 +1,23 @@ import { TeamManuscript } from '@asap-hub/model'; +import { network } from '@asap-hub/routing'; import { css } from '@emotion/react'; import { useState } from 'react'; -import { Button, colors, minusRectIcon, plusRectIcon, Subtitle } from '..'; +import { useHistory } from 'react-router-dom'; +import { + Button, + colors, + complianceReportIcon, + minusRectIcon, + plusRectIcon, + Subtitle, +} from '..'; import { mobileScreen, perRem, rem } from '../pixels'; import ManuscriptVersionCard from './ManuscriptVersionCard'; -type ManuscriptCardProps = Pick; +type ManuscriptCardProps = Pick & { + teamId: string; + canShareComplianceReport: boolean; +}; const manuscriptContainerStyles = css({ marginTop: rem(12), @@ -47,8 +59,27 @@ const toastHeaderStyles = css({ }, }); -const ManuscriptCard: React.FC = ({ title, versions }) => { +const ManuscriptCard: React.FC = ({ + id, + title, + versions, + teamId, + canShareComplianceReport, +}) => { const [expanded, setExpanded] = useState(false); + const history = useHistory(); + + const complianceReportRoute = network({}) + .teams({}) + .team({ teamId }) + .workspace({}) + .createComplianceReport({ manuscriptId: id }).$; + + const handleShareComplianceReport = () => { + history.push(complianceReportRoute); + }; + + const hasActiveComplianceReport = !!versions[0]?.complianceReport; return (
@@ -66,6 +97,21 @@ const ManuscriptCard: React.FC = ({ title, versions }) => { {title} + {canShareComplianceReport && ( + + + + )}
{expanded && ( diff --git a/packages/react-components/src/organisms/ManuscriptVersionCard.tsx b/packages/react-components/src/organisms/ManuscriptVersionCard.tsx index 116123f906..22fb356bf6 100644 --- a/packages/react-components/src/organisms/ManuscriptVersionCard.tsx +++ b/packages/react-components/src/organisms/ManuscriptVersionCard.tsx @@ -22,6 +22,7 @@ import { UserCommentHeader } from '../molecules'; import ManuscriptFileSection from '../molecules/ManuscriptFileSection'; import UserTeamInfo from '../molecules/UserTeamInfo'; import { mobileScreen, perRem, rem } from '../pixels'; +import ComplianceReportCard from './ComplianceReportCard'; type ManuscriptVersionCardProps = ManuscriptVersion; @@ -124,6 +125,9 @@ const ManuscriptVersionCard: React.FC = ( return (
+ {version.complianceReport && ( + + )}
diff --git a/packages/react-components/src/organisms/__tests__/ComplianceReportCard.test.tsx b/packages/react-components/src/organisms/__tests__/ComplianceReportCard.test.tsx new file mode 100644 index 0000000000..dd144a6aa4 --- /dev/null +++ b/packages/react-components/src/organisms/__tests__/ComplianceReportCard.test.tsx @@ -0,0 +1,27 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ComplianceReportCard from '../ComplianceReportCard'; + +it('displays compliance report description and url when expanded', () => { + const props = { + url: 'http://example.com/', + description: 'compliance report description', + }; + const { getByText, queryByText, getByRole, rerender } = render( + , + ); + + expect(queryByText(/compliance report description/i)).not.toBeInTheDocument(); + expect(queryByText(/View Report/i)).not.toBeInTheDocument(); + expect(queryByText(/example.com/i)).not.toBeInTheDocument(); + + userEvent.click(getByRole('button')); + + rerender(); + + expect(getByText(/compliance report description/i)).toBeVisible(); + expect(getByText(/View Report/i)).toBeVisible(); + expect(getByText(/View Report/i).closest('a')?.href).toBe( + 'http://example.com/', + ); +}); diff --git a/packages/react-components/src/organisms/__tests__/ComplianceReportHeader.test.tsx b/packages/react-components/src/organisms/__tests__/ComplianceReportHeader.test.tsx new file mode 100644 index 0000000000..c1fd8181ce --- /dev/null +++ b/packages/react-components/src/organisms/__tests__/ComplianceReportHeader.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@testing-library/react'; + +import ComplianceReportHeader from '../ComplianceReportHeader'; + +it('renders the compliance report header content', () => { + render(); + expect( + screen.getByRole('heading', { name: /Share a Compliance Report/i }), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Share the compliance report associated with this manuscript.', + ), + ).toBeInTheDocument(); +}); diff --git a/packages/react-components/src/organisms/__tests__/ManuscriptCard.test.tsx b/packages/react-components/src/organisms/__tests__/ManuscriptCard.test.tsx index acd2403bd8..aa5e6d2d2c 100644 --- a/packages/react-components/src/organisms/__tests__/ManuscriptCard.test.tsx +++ b/packages/react-components/src/organisms/__tests__/ManuscriptCard.test.tsx @@ -2,10 +2,13 @@ import { createManuscriptResponse } from '@asap-hub/fixtures'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ComponentProps } from 'react'; +import { Router, Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; import ManuscriptCard from '../ManuscriptCard'; const props: ComponentProps = { ...createManuscriptResponse(), + canShareComplianceReport: false, }; it('displays manuscript version card when expanded', () => { @@ -35,3 +38,38 @@ it('displays manuscript version card when expanded', () => { expect(getByText(/Original Research/i)).toBeVisible(); expect(getByText(/Preprint/i)).toBeVisible(); }); + +it('displays share compliance report button if user has permission', () => { + const { queryByRole, getByRole, rerender } = render( + , + ); + + expect( + queryByRole('button', { name: /Share Compliance Report Icon/i }), + ).not.toBeInTheDocument(); + + rerender(); + + expect( + getByRole('button', { name: /Share Compliance Report Icon/i }), + ).toBeVisible(); +}); + +it('redirects to compliance report form when user clicks on share compliance report button', () => { + const history = createMemoryHistory({}); + const { getByRole } = render( + + + + + , + ); + + userEvent.click( + getByRole('button', { name: /Share Compliance Report Icon/i }), + ); + + expect(history.location.pathname).toBe( + `/network/teams/${props.teamId}/workspace/create-compliance-report/${props.id}`, + ); +}); diff --git a/packages/react-components/src/organisms/__tests__/ManuscriptVersionCard.test.tsx b/packages/react-components/src/organisms/__tests__/ManuscriptVersionCard.test.tsx index 8f5e94439e..c66cd92988 100644 --- a/packages/react-components/src/organisms/__tests__/ManuscriptVersionCard.test.tsx +++ b/packages/react-components/src/organisms/__tests__/ManuscriptVersionCard.test.tsx @@ -277,3 +277,25 @@ it('renders additional files details and download link when provided', () => { 'https://example.com/additional-file.pdf', ); }); + +it('displays compliance report section when present', () => { + const { getByRole, queryByRole, rerender } = render( + , + ); + userEvent.click(getByRole('button')); + expect( + queryByRole('heading', { name: /Compliance Report/i }), + ).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(getByRole('heading', { name: /Compliance Report/i })).toBeVisible(); +}); diff --git a/packages/react-components/src/organisms/index.ts b/packages/react-components/src/organisms/index.ts index 541bccb995..e011a11a9e 100644 --- a/packages/react-components/src/organisms/index.ts +++ b/packages/react-components/src/organisms/index.ts @@ -5,6 +5,7 @@ export { default as CalendarList } from './CalendarList'; export { default as CaptionCard } from './CaptionCard'; export { default as CheckboxGroup } from './CheckboxGroup'; export { default as ComingSoon } from './ComingSoon'; +export { default as ComplianceReportHeader } from './ComplianceReportHeader'; export { default as ConfirmModal } from './ConfirmModal'; export { default as DashboardRecommendedUsers } from './DashboardRecommendedUsers'; export { default as DashboardUpcomingEvents } from './DashboardUpcomingEvents'; diff --git a/packages/react-components/src/templates/ComplianceReportForm.tsx b/packages/react-components/src/templates/ComplianceReportForm.tsx new file mode 100644 index 0000000000..91a9e84af2 --- /dev/null +++ b/packages/react-components/src/templates/ComplianceReportForm.tsx @@ -0,0 +1,190 @@ +import { + ComplianceReportFormData, + ComplianceReportPostRequest, + ComplianceReportResponse, +} from '@asap-hub/model'; +import { urlExpression } from '@asap-hub/validation'; +import { css } from '@emotion/react'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useHistory } from 'react-router-dom'; +import { GlobeIcon, LabeledTextArea, LabeledTextField } from '..'; +import { Button, Card, Paragraph } from '../atoms'; +import { defaultPageLayoutPaddingStyle } from '../layout'; +import { mobileScreen, rem } from '../pixels'; + +const mainStyles = css({ + display: 'flex', + justifyContent: 'center', + padding: defaultPageLayoutPaddingStyle, +}); + +const cardStyles = css({ + padding: `${rem(32)} ${rem(24)} ${rem(16)}`, +}); + +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 ComplianceReportFormProps = { + manuscriptTitle: string; + manuscriptVersionId: string; + url?: string; + description?: string | ''; + onSave: ( + output: ComplianceReportPostRequest, + ) => Promise; + onSuccess: () => void; +}; + +const ComplianceReportForm: React.FC = ({ + onSave, + onSuccess, + manuscriptTitle, + manuscriptVersionId, + url, + description, +}) => { + const history = useHistory(); + + const methods = useForm({ + mode: 'onBlur', + defaultValues: { + url: url || '', + description: description || '', + }, + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isValid }, + } = methods; + + const onSubmit = async (data: ComplianceReportFormData) => { + await onSave({ + ...data, + manuscriptVersionId, + }); + + onSuccess(); + }; + + return ( +
+
+
+ + + Title of Manuscript + + {manuscriptTitle} + ( + } + placeholder="https://example.com" + /> + )} + /> + + ( + + Add a description to the compliance report. You can format + your text by using markup language. + + } + customValidationMessage={error?.message} + value={value || ''} + onChange={onChange} + onBlur={onBlur} + enabled={!isSubmitting} + /> + )} + /> + +
+
+ + +
+
+
+
+
+ ); +}; + +export default ComplianceReportForm; diff --git a/packages/react-components/src/templates/ManuscriptForm.tsx b/packages/react-components/src/templates/ManuscriptForm.tsx index a002b4df97..6d0337bd33 100644 --- a/packages/react-components/src/templates/ManuscriptForm.tsx +++ b/packages/react-components/src/templates/ManuscriptForm.tsx @@ -84,7 +84,13 @@ const apcCoverageLifecycles = [ type OptionalVersionFields = Array< keyof Omit< ManuscriptVersion, - 'type' | 'lifecycle' | 'createdBy' | 'createdDate' | 'publishedAt' + | 'id' + | 'type' + | 'lifecycle' + | 'complianceReport' + | 'createdBy' + | 'createdDate' + | 'publishedAt' > >; @@ -203,6 +209,7 @@ const setDefaultFieldValues = ( type ManuscriptFormProps = Omit< ManuscriptVersion, + | 'id' | 'type' | 'lifecycle' | 'manuscriptFile' diff --git a/packages/react-components/src/templates/TeamProfileWorkspace.tsx b/packages/react-components/src/templates/TeamProfileWorkspace.tsx index f050b2a91a..3ad6292fe5 100644 --- a/packages/react-components/src/templates/TeamProfileWorkspace.tsx +++ b/packages/react-components/src/templates/TeamProfileWorkspace.tsx @@ -68,6 +68,7 @@ type TeamProfileWorkspaceProps = Readonly< readonly tools: ReadonlyArray; readonly onDeleteTool?: (toolIndex: number) => Promise; readonly setEligibilityReasons: (newEligibilityReason: Set) => void; + readonly canShareComplianceReport?: boolean; }; const TeamProfileWorkspace: React.FC = ({ @@ -78,6 +79,7 @@ const TeamProfileWorkspace: React.FC = ({ tools, onDeleteTool, setEligibilityReasons, + canShareComplianceReport = false, }) => { const [displayEligibilityModal, setDisplayEligibilityModal] = useState(false); const history = useHistory(); @@ -136,7 +138,11 @@ const TeamProfileWorkspace: React.FC = ({
{manuscripts.map((manuscript) => (
- +
))} diff --git a/packages/react-components/src/templates/__tests__/ComplianceReportForm.test.tsx b/packages/react-components/src/templates/__tests__/ComplianceReportForm.test.tsx new file mode 100644 index 0000000000..8c0df86cd2 --- /dev/null +++ b/packages/react-components/src/templates/__tests__/ComplianceReportForm.test.tsx @@ -0,0 +1,124 @@ +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 ComplianceReportForm from '../ComplianceReportForm'; + +let history!: History; + +beforeEach(() => { + history = createMemoryHistory(); +}); + +const defaultProps: ComponentProps = { + onSave: jest.fn(() => Promise.resolve()), + onSuccess: jest.fn(), + manuscriptTitle: 'manuscript title', + manuscriptVersionId: 'manuscript-version-1', +}; + +it('renders the form', async () => { + render( + + + , + ); + expect(screen.getByText(/Title of Manuscript/i)).toBeVisible(); + expect(screen.getByRole('button', { name: /Share/i })).toBeVisible(); +}); + +it('data is sent on form submission', async () => { + const onSave = jest.fn(); + render( + + + , + ); + + const shareButton = screen.getByRole('button', { name: /Share/i }); + await waitFor(() => expect(shareButton).toBeEnabled()); + userEvent.click(shareButton); + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith({ + url: 'http://example.com', + description: 'manuscript description', + manuscriptVersionId: defaultProps.manuscriptVersionId, + }); + }); +}); + +it('displays error message when url is missing', async () => { + render( + + + , + ); + + const input = screen.getByRole('textbox', { name: /url/i }); + fireEvent.blur(input); + + await waitFor(() => { + expect( + screen.getAllByText(/Please enter a url/i).length, + ).toBeGreaterThanOrEqual(1); + }); + + userEvent.type(input, 'http://example.com'); + fireEvent.blur(input); + + await waitFor(() => { + expect(screen.queryByText(/Please enter a url/i)).toBeNull(); + }); +}); + +it('displays error message when description is missing', async () => { + render( + + + , + ); + + const input = screen.getByRole('textbox', { + name: /compliance report description/i, + }); + fireEvent.blur(input); + + await waitFor(() => { + expect( + screen.getAllByText(/Please enter a description/i).length, + ).toBeGreaterThanOrEqual(1); + }); + + userEvent.type(input, 'manuscription description'); + fireEvent.blur(input); + + await waitFor(() => { + expect(screen.queryByText(/Please enter a description/i)).toBeNull(); + }); +}); + +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 7cd629daad..d2d64516d5 100644 --- a/packages/react-components/src/templates/__tests__/TeamProfileWorkspace.test.tsx +++ b/packages/react-components/src/templates/__tests__/TeamProfileWorkspace.test.tsx @@ -99,6 +99,7 @@ describe('compliance section', () => { title: 'Nice manuscript', versions: [ { + id: 'version-1', type: 'Original Research', lifecycle: 'Draft Manuscript (prior to Publication)', manuscriptFile: { @@ -138,6 +139,7 @@ describe('compliance section', () => { title: 'A Good Manuscript', versions: [ { + id: 'version-1', type: 'Review / Op-Ed / Letter / Hot Topic', lifecycle: 'Preprint', manuscriptFile: { diff --git a/packages/react-components/src/templates/index.ts b/packages/react-components/src/templates/index.ts index acdede996d..3c81a14112 100644 --- a/packages/react-components/src/templates/index.ts +++ b/packages/react-components/src/templates/index.ts @@ -9,6 +9,7 @@ export { default as AnalyticsEngagementPageBody } from './AnalyticsEngagementPag export { default as AnalyticsPageHeader } from './AnalyticsPageHeader'; export { default as BasicLayout } from './BasicLayout'; export { default as BiographyModal } from './BiographyModal'; +export { default as ComplianceReportForm } from './ComplianceReportForm'; export { default as ContactInfoModal } from './ContactInfoModal'; export { default as ContentPage } from './ContentPage'; export { default as DashboardPage } from './DashboardPage'; diff --git a/packages/routing/src/network.ts b/packages/routing/src/network.ts index 636b52dfc6..ead5b330e6 100644 --- a/packages/routing/src/network.ts +++ b/packages/routing/src/network.ts @@ -92,7 +92,16 @@ const team = (() => { const tool = route('/:toolIndex', { toolIndex: stringParser }, {}); const tools = route('/tools', {}, { tool }); const createManuscript = route('/create-manuscript', {}, {}); - const workspace = route('/workspace', {}, { tools, createManuscript }); + const createComplianceReport = route( + '/create-compliance-report/:manuscriptId', + { manuscriptId: stringParser }, + {}, + ); + const workspace = route( + '/workspace', + {}, + { tools, createManuscript, createComplianceReport }, + ); const createOutput = route( '/create-output/:outputDocumentType', { outputDocumentType: outputDocumentTypeParser },