diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ce6118..2520f145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,23 @@ -## [1.0.0] - ### Added +- Added the Template History page [#86] - Added more documentation to README.md [#91] - Added existing header and footer from production to app [#89] - Added two new TypeAhead/Autosuggest components, one with and one without the "Other" option [#10] - Added the use of graphql-codegen to generate types for graphql requests [#10] + +### Updates +- Updated endpoints for 'signin' and 'signup' to be 'apollo-signin' and 'apollo-signup' [#99] + +### Fixed +- Fixed an issue with docker-compose not starting the app [#93] + +## v0.0.1 + +### Added - Added logout api and link [#65] - Added loading state and velocity controls to the login and signup forms [#66] - Added placeholders for /signup and /login pages and added a new api endpoint for setting the access token in a cookie [#19] - Added .editorconfig file to help maintain consistent coding styles [#37] -- Added buildspec.yaml file for CI/CD pipeline [#81] -- Added graphql file structure and client creation. [#36] - Added Styleguide Page, as well as custom components and css that are only to be used on the Styleguide. [#51] - Added some default CSS variables that we can hook into when we start doing the @@ -34,5 +41,3 @@ outline in the styleguide. [#51] - Updated some CSS classes to remove verbose naming. [#51] -### Fixed -- Fixed an issue with docker-compose not starting the app [#93] \ No newline at end of file diff --git a/app/login/__tests__/page.spec.tsx b/app/login/__tests__/page.spec.tsx index 3df67790..9d63fb31 100644 --- a/app/login/__tests__/page.spec.tsx +++ b/app/login/__tests__/page.spec.tsx @@ -21,6 +21,8 @@ const mockFocus = jest.fn(); import { useRouter } from 'next/navigation'; const mockUseRouter = useRouter as jest.Mock; +// Assign fetch to global object in Node.js environment +global.fetch = global.fetch || require('node-fetch'); describe('LoginPage', () => { beforeEach(() => { diff --git a/app/signup/__tests__/page.spec.tsx b/app/signup/__tests__/page.spec.tsx index c8bc630a..cd948e1e 100644 --- a/app/signup/__tests__/page.spec.tsx +++ b/app/signup/__tests__/page.spec.tsx @@ -21,6 +21,7 @@ const mockFocus = jest.fn(); import { useRouter } from 'next/navigation'; const mockUseRouter = useRouter as jest.Mock; +global.fetch = global.fetch || require('node-fetch'); describe('SignUpPage', () => { diff --git a/app/styleguide/page.tsx b/app/styleguide/page.tsx index e4b50c90..86642ef6 100644 --- a/app/styleguide/page.tsx +++ b/app/styleguide/page.tsx @@ -17,6 +17,12 @@ import { OverlayArrow, Dialog, Switch, + Table, + TableHeader, + TableBody, + Column, + Row, + Cell, } from "react-aria-components"; import "./styleguide.scss"; @@ -43,6 +49,7 @@ function Page() { Layout Forms Form Fields + Table Custom Widget @@ -407,6 +414,36 @@ function Page() { +
+

Table

+

+ A table displays data in rows and columns and enables a user to navigate its contents via directional navigation keys, and optionally supports row selection and sorting. +

+ +

+ This is a core component, see + the component docs here. +

+ + + + + One + Two + Three + + + + Item One + Item Two + Item Three + + +
+
+
+ +

Widgets

TBD (Custom Components, etc…)

@@ -474,7 +511,7 @@ function Page() {
- + ) diff --git a/app/template/[templateId]/history/__tests__/mockedResponse.json b/app/template/[templateId]/history/__tests__/mockedResponse.json new file mode 100644 index 00000000..2937b088 --- /dev/null +++ b/app/template/[templateId]/history/__tests__/mockedResponse.json @@ -0,0 +1,94 @@ +{ + "templateVersions": [ + { + "name": "NIH-GDS: Genomic Data Sharing", + "version": "v3.1", + "created": "2024-08-02T02:03:01.000Z", + "comment": "Added some additional requirements to Preservation section!", + "versionedBy": { + "givenName": "Severus", + "surName": "Snape", + "affiliation": { + "name": "National Institutes of Health" + }, + "modified": "2024-08-21T18:05:57.000Z", + "modifiedById": 459 + } + }, + { + "name": "NIH-GDS: Genomic Data Sharing", + "version": "v3", + "created": "2024-04-01T02:03:00.000Z", + "comment": "This is the initial version of our template!", + "versionedBy": { + "givenName": "Severus", + "surName": "Snape", + "affiliation": { + "name": "National Institutes of Health" + }, + "modified": "2024-08-21T18:05:57.000Z", + "modifiedById": 320 + } + }, + { + "name": "NIH-GDS: Genomic Data Sharing", + "version": "v2.2", + "created": "2024-03-21T22:23:20.000Z", + "comment": "Added some additional requirements to Preservation section!", + "versionedBy": { + "givenName": "Severus", + "surName": "Snape", + "affiliation": { + "name": "National Institutes of Health" + }, + "modified": "2024-08-21T18:05:57.000Z", + "modifiedById": 779 + } + }, + { + "name": "NIH-GDS: Genomic Data Sharing", + "version": "v2.1", + "created": "2024-03-19T19:20:18.000Z", + "comment": "Added some additional requirements to Preservation section!", + "versionedBy": { + "givenName": "Severus", + "surName": "Snape", + "affiliation": { + "name": "National Institutes of Health" + }, + "modified": "2024-08-21T18:05:57.000Z", + "modifiedById": 822 + } + }, + { + "name": "NIH-GDS: Genomic Data Sharing", + "version": "v2", + "created": "2024-02-18T16:17:15.000Z", + "comment": "This is the initial version of our template!", + "versionedBy": { + "givenName": "Severus", + "surName": "Snape", + "affiliation": { + "name": "National Institutes of Health" + }, + "modified": "2024-08-21T18:05:57.000Z", + "modifiedById": 89 + } + }, + { + "name": "NIH-GDS: Genomic Data Sharing", + "version": "v1", + "created": "2024-01-17T13:14:15.000Z", + "comment": "This is the initial version of our template!", + "versionedBy": { + "givenName": "Severus", + "surName": "Snape", + "affiliation": { + "name": "National Institutes of Health" + }, + "modified": "2024-08-21T18:05:57.000Z", + "modifiedById": 551 + } + } + ] +} \ No newline at end of file diff --git a/app/template/[templateId]/history/__tests__/page.spec.tsx b/app/template/[templateId]/history/__tests__/page.spec.tsx new file mode 100644 index 00000000..8d85688c --- /dev/null +++ b/app/template/[templateId]/history/__tests__/page.spec.tsx @@ -0,0 +1,160 @@ +import React, { ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; +import TemplateHistory from '../page'; +import { useTemplateVersionsQuery } from '@/generated/graphql'; +import { MockedProvider } from '@apollo/client/testing'; +import { useParams } from 'next/navigation'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import mockData from './mockedResponse.json' + +expect.extend(toHaveNoViolations); + +jest.mock('@/generated/graphql', () => ({ + useTemplateVersionsQuery: jest.fn(), +})); + +jest.mock('next/navigation', () => ({ + useParams: jest.fn(), +})) + +jest.mock('@/components/BackButton', () => { + return { + __esModule: true, + default: () =>
Mocked Back Button
, + }; +}); + +jest.mock('@/components/PageWrapper', () => { + const mockPageWrapper = jest.fn(({ children }: { children: ReactNode, title: string }) => ( +
{children}
+ )); + return { + __esModule: true, + default: mockPageWrapper + } +}); + +describe('TemplateHistory', () => { + beforeEach(() => { + const mockTemplateId = 123; + const mockUseParams = useParams as jest.Mock; + + // Mock the return value of useParams + mockUseParams.mockReturnValue({ templateId: `${mockTemplateId}` }); + }); + + it('should render the component with PageWrapper', async () => { + const titleProp = 'Template History'; + const pageWrapper = await import('@/components/PageWrapper'); + const mockPageWrapper = pageWrapper.default; + + (useTemplateVersionsQuery as jest.Mock).mockReturnValue(mockData); + + const { getByTestId } = render(); + + expect(getByTestId('mock-page-wrapper')).toBeInTheDocument(); + expect(mockPageWrapper).toHaveBeenCalledWith(expect.objectContaining({ title: titleProp, }), {}) + }) + + it('should use the templateId from the param in the call to useTemplateVersionsQuery', () => { + + (useTemplateVersionsQuery as jest.Mock).mockReturnValue({ + data: mockData, + loading: false, + error: null, + }); + + render( + + + + ); + + expect(useTemplateVersionsQuery).toHaveBeenCalledWith({ "variables": { "templateId": 123 } }) + }); + + it('should render loading state correctly', () => { + (useTemplateVersionsQuery as jest.Mock).mockReturnValue({ loading: true }); + + render(); + expect(screen.getByText('Loading publication history...')).toBeInTheDocument(); + }); + + it('should render error state correctly', () => { + (useTemplateVersionsQuery as jest.Mock).mockReturnValue({ loading: false, error: new Error('Test Error') }); + + render(); + expect(screen.getByText('There was a problem.')).toBeInTheDocument(); + }); + + it('should render page heading and subheader correctly, which includes the title, by, version and date of latest publication', async () => { + (useTemplateVersionsQuery as jest.Mock).mockReturnValue({ + loading: false, + data: mockData, + }); + + const { getByTestId } = render(); + const h1Element = await screen.findByRole('heading', { level: 1 }); + expect(h1Element).toHaveTextContent('NIH-GDS: Genomic Data Sharing'); + expect(getByTestId('author')).toHaveTextContent('by National Institutes of Health') + expect(getByTestId('latest-version')).toHaveTextContent('3.1') + expect(getByTestId('publication-date')).toHaveTextContent('Published: Aug 1, 2024') + }); + + it('should render correct headers for table', async () => { + (useTemplateVersionsQuery as jest.Mock).mockReturnValue({ + loading: false, + data: mockData, + }); + + render(); + + // Check h2 header above table + const h2Element = await screen.findByRole('heading', { level: 2 }); + expect(h2Element).toHaveTextContent('History'); + + // Check table column headers + const headers = screen.getAllByRole('columnheader'); + expect(headers[0]).toHaveTextContent('Action'); + expect(headers[1]).toHaveTextContent('User'); + expect(headers[2]).toHaveTextContent('Time and Date'); + }) + + it('should render correct content in table', async () => { + (useTemplateVersionsQuery as jest.Mock).mockReturnValue({ + loading: false, + data: mockData, + }); + + render(); + const table = screen.getByRole('grid'); + // Get all rows in table body + const rows = table.querySelectorAll('tbody tr'); + + // Second row in table + const targetRow1 = rows[1]; + + const row1Cells = targetRow1.querySelectorAll('td'); + expect(row1Cells[0].textContent).toBe('Published v3Change log:This is the initial version of our template!'); + expect(row1Cells[1].textContent).toBe('Severus Snape'); + expect(row1Cells[2].textContent).toBe('19:03 on Mar 31, 2024'); + }) + + it('should render "No template history available" when no data is available', () => { + (useTemplateVersionsQuery as jest.Mock).mockReturnValue({ + loading: false, + data: { templateVersions: [] }, + }); + + render(); + expect(screen.getByText('No template history available.')).toBeInTheDocument(); + }); + + it('should pass axe accessibility test', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }) +}); \ No newline at end of file diff --git a/app/template/[templateId]/history/history.module.scss b/app/template/[templateId]/history/history.module.scss new file mode 100644 index 00000000..f817618d --- /dev/null +++ b/app/template/[templateId]/history/history.module.scss @@ -0,0 +1,10 @@ + + +.historyVersion { + margin-right: 10px; +} + +.changeLog { + color: var(--gray-500); + font-size: 0.75rem; +} \ No newline at end of file diff --git a/app/template/[templateId]/history/page.tsx b/app/template/[templateId]/history/page.tsx new file mode 100644 index 00000000..390df139 --- /dev/null +++ b/app/template/[templateId]/history/page.tsx @@ -0,0 +1,110 @@ +'use client' + +import React from "react"; +import { useParams } from "next/navigation"; +import { useTemplateVersionsQuery } from '@/generated/graphql'; +import { + Table, + TableHeader, + TableBody, + Column, + Row, + Cell, +} from "react-aria-components"; +import PageWrapper from "@/components/PageWrapper"; +import BackButton from "@/components/BackButton"; +import { formatWithTimeAndDate, formatShortMonthDayYear } from "@/utils/dateUtils" +import styles from './history.module.scss'; + +const TemplateHistory = () => { + const params = useParams(); + const templateId = Number(params.templateId); + + const { data = {}, loading, error } = useTemplateVersionsQuery( + { variables: { templateId } } + ); + + // Handle loading state + if (loading) { + return

Loading publication history...

; + } + + if (error) { + return

There was a problem.

+ } + + const templateVersions = data?.templateVersions || []; + const sortedTemplates = templateVersions.slice().sort((a, b) => { + if (a === null || b === null) { + return a === null ? 1 : -1; + } + const versionA = parseInt(a.version.slice(1), 10); + const versionB = parseInt(b.version.slice(1), 10); + return versionB - versionA; + }); + + const lastPublication = sortedTemplates.length > 0 ? sortedTemplates[0] : null; + const lastPublicationDate = formatShortMonthDayYear(lastPublication?.created); + + return ( + + + {loading &&

Template history is loading...

} +
+ {lastPublication && ( + <> +

{lastPublication?.name || 'Unknown'}

+
+
{`by ${lastPublication?.versionedBy?.affiliation?.name}`}
+
+ Version {lastPublication?.version.slice(1)} + Published: {lastPublicationDate} +
+
+ + )} + +

History

+ + + + Action + User + Time and Date + + + { + sortedTemplates.length > 0 + ? sortedTemplates.map((item, index) => { + const publishDate = formatWithTimeAndDate(item?.created); + const versionedBy = item?.versionedBy; + + return ( + + +
Published {item?.version}
+
+ + Change log:
{item?.comment} +
+
+
+ + {versionedBy + ? `${versionedBy.givenName || ''} ${versionedBy.surName || ''}` + : 'Unknown'} + {publishDate} +
+ ); + }) + : No template history available. + } + +
+
+
+
+ ) +} + +export default TemplateHistory; diff --git a/components/BackButton/index.tsx b/components/BackButton/index.tsx new file mode 100644 index 00000000..64398c8d --- /dev/null +++ b/components/BackButton/index.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +export default function BackButton() { + const router = useRouter(); + + return ( + + ); +} \ No newline at end of file diff --git a/components/PageWrapper/index.tsx b/components/PageWrapper/index.tsx new file mode 100644 index 00000000..fb8db901 --- /dev/null +++ b/components/PageWrapper/index.tsx @@ -0,0 +1,19 @@ +import React, { useEffect, ReactNode } from 'react'; +interface PageWrapperProps { + title: string; + children: ReactNode; +} + +const PageWrapper: React.FC = ({ title, children }) => { + useEffect(() => { + document.title = `${title} | DMPTool`; + window.scrollTo(0, 0); + }, [title]); + return ( + <> + {children} + + ) +} + +export default PageWrapper; \ No newline at end of file diff --git a/components/responsive.scss b/components/responsive.scss deleted file mode 100644 index 07ec032b..00000000 --- a/components/responsive.scss +++ /dev/null @@ -1,72 +0,0 @@ -/* NOTE:: Since we are doing responsive, all CSS that DOESN'T use - * media queries will be assumed to be for mobile sizes. - */ - -// Responsive Sizes -$mobile-small: 320px; -$mobile: 375px; -$mobile-large: 425px; -$phablet: 560px; -$tablet-small: 640px; -$tablet: 768px; -$laptop: 1024px; -$laptop-large: 1440px; -$display-4k: 2560px; - - -// A quick helper to quickly create a media query block for a named device -@mixin device($breakpoint) { - @if $breakpoint == mobile-small { - @media screen and (min-width: $mobile-small) { - @content; - } - } - - @if $breakpoint ==mobile { - @media screen and (min-width: $mobile-small) { - @content; - } - } - - @if $breakpoint ==mobile-large { - @media screen and (min-width: $mobile-large) { - @content; - } - } - - @if $breakpoint ==phablet { - @media screen and (min-width: $phablet) { - @content; - } - } - - @if $breakpoint ==tablet-small { - @media screen and (min-width: $tablet-small) { - @content; - } - } - - @if $breakpoint ==tablet { - @media screen and (min-width: $tablet) { - @content; - } - } - - @if $breakpoint ==laptop { - @media screen and (min-width: $laptop) { - @content; - } - } - - @if $breakpoint ==laptop-large { - @media screen and (min-width: $laptop-large) { - @content; - } - } - - @if $breakpoint ==display-4k { - @media screen and (min-width: $display-4k) { - @content; - } - } -} \ No newline at end of file diff --git a/generated/graphql.tsx b/generated/graphql.tsx index 371fa20d..94c643be 100644 --- a/generated/graphql.tsx +++ b/generated/graphql.tsx @@ -136,17 +136,24 @@ export type Contributor = Person & { export type ContributorRole = { __typename?: 'ContributorRole'; - /** The timestamp of when the contributor role was created */ - created: Scalars['DateTimeISO']['output']; + /** The timestamp when the Object was created */ + created?: Maybe; + /** The user who created the Object */ + createdById?: Maybe; /** A longer description of the contributor role useful for tooltips */ description?: Maybe; /** The order in which to display these items when displayed in the UI */ displayOrder: Scalars['Int']['output']; - id: Scalars['Int']['output']; + /** Errors associated with the Object */ + errors?: Maybe>; + /** The unique identifer for the Object */ + id?: Maybe; /** The Ui label to display for the contributor role */ label: Scalars['String']['output']; - /** The timestamp of when the contributor role last modified */ - modified: Scalars['DateTimeISO']['output']; + /** The timestamp when the Object was last modifed */ + modified?: Maybe; + /** The user who last modified the Object */ + modifiedById?: Maybe; /** The URL for the contributor role */ url: Scalars['URL']['output']; }; @@ -207,10 +214,22 @@ export type Mutation = { _empty?: Maybe; /** Add a new contributor role (URL and label must be unique!) */ addContributorRole?: Maybe; + /** Create a new Template. Leave the 'copyFromTemplateId' blank to create a new template from scratch */ + addTemplate?: Maybe