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 |
+
+
+
+
+
+
+
-
+
>
)
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 (
+ router.back()}>
+ ← Back
+
+ );
+}
\ 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;
+ /** Add a collaborator to a Template */
+ addTemplateCollaborator?: Maybe;
+ /** Archive a Template (unpublishes any associated PublishedTemplate */
+ archiveTemplate?: Maybe;
+ /** Publish the template or save as a draft */
+ createVersion?: Maybe;
/** Delete the contributor role */
removeContributorRole?: Maybe;
+ /** Remove a TemplateCollaborator from a Template */
+ removeTemplateCollaborator?: Maybe;
/** Update the contributor role */
updateContributorRole?: Maybe;
+ /** Update a Template */
+ updateTemplate?: Maybe;
};
@@ -222,11 +241,41 @@ export type MutationAddContributorRoleArgs = {
};
+export type MutationAddTemplateArgs = {
+ copyFromTemplateId?: InputMaybe;
+ name: Scalars['String']['input'];
+};
+
+
+export type MutationAddTemplateCollaboratorArgs = {
+ email: Scalars['String']['input'];
+ templateId: Scalars['Int']['input'];
+};
+
+
+export type MutationArchiveTemplateArgs = {
+ templateId: Scalars['Int']['input'];
+};
+
+
+export type MutationCreateVersionArgs = {
+ comment?: InputMaybe;
+ templateId: Scalars['Int']['input'];
+ versionType?: InputMaybe;
+};
+
+
export type MutationRemoveContributorRoleArgs = {
id: Scalars['ID']['input'];
};
+export type MutationRemoveTemplateCollaboratorArgs = {
+ email: Scalars['String']['input'];
+ templateId: Scalars['Int']['input'];
+};
+
+
export type MutationUpdateContributorRoleArgs = {
description?: InputMaybe;
displayOrder: Scalars['Int']['input'];
@@ -235,6 +284,13 @@ export type MutationUpdateContributorRoleArgs = {
url: Scalars['URL']['input'];
};
+
+export type MutationUpdateTemplateArgs = {
+ name: Scalars['String']['input'];
+ templateId: Scalars['Int']['input'];
+ visibility: TemplateVisibility;
+};
+
export type OrganizationIdentifier = {
__typename?: 'OrganizationIdentifier';
identifier: Scalars['Ror']['output'];
@@ -276,8 +332,21 @@ export type Query = {
contributorRoles?: Maybe>>;
/** Get the DMSP by its DMP ID */
dmspById?: Maybe;
+ /** Returns the currently logged in user's information */
me?: Maybe;
+ /** Search for VersionedTemplate whose name or owning Org's name contains the search term */
+ publishedTemplates?: Maybe>>;
+ /** Get the specified Template (user must be an Admin) */
+ template?: Maybe;
+ /** Get all of the Users that belong to another affiliation that can edit the Template */
+ templateCollaborators?: Maybe>>;
+ /** Get all of the VersionedTemplate for the specified Template (a.k. the Template history) */
+ templateVersions?: Maybe>>;
+ /** Get the Templates that belong to the current user's affiliation (user must be an Admin) */
+ templates?: Maybe>>;
+ /** Returns the specified user (Admin only) */
user?: Maybe;
+ /** Returns all of the users associated with the current user's affiliation (Admin only) */
users?: Maybe>>;
};
@@ -308,8 +377,28 @@ export type QueryDmspByIdArgs = {
};
+export type QueryPublishedTemplatesArgs = {
+ term: Scalars['String']['input'];
+};
+
+
+export type QueryTemplateArgs = {
+ templateId: Scalars['Int']['input'];
+};
+
+
+export type QueryTemplateCollaboratorsArgs = {
+ templateId: Scalars['Int']['input'];
+};
+
+
+export type QueryTemplateVersionsArgs = {
+ templateId: Scalars['Int']['input'];
+};
+
+
export type QueryUserArgs = {
- userId: Scalars['String']['input'];
+ userId: Scalars['Int']['input'];
};
export type RelatedIdentifier = {
@@ -332,25 +421,157 @@ export type SingleDmspResponse = {
success: Scalars['Boolean']['output'];
};
+/** A Template used to create DMPs */
+export type Template = {
+ __typename?: 'Template';
+ /** Whether or not this Template is designated as a 'Best Practice' template */
+ bestPractice: Scalars['Boolean']['output'];
+ /** Users from different affiliations who have been invited to collaborate on this template */
+ collaborators?: Maybe>;
+ /** The timestamp when the Object was created */
+ created?: Maybe;
+ /** The user who created the Object */
+ createdById?: Maybe;
+ /** The current published version */
+ currentVersion?: Maybe;
+ /** A description of the purpose of the template */
+ description?: Maybe;
+ /** Errors associated with the Object */
+ errors?: Maybe>;
+ /** The unique identifer for the Object */
+ id?: Maybe;
+ /** Whether or not the Template has had any changes since it was last published */
+ isDirty: Scalars['Boolean']['output'];
+ /** The timestamp when the Object was last modifed */
+ modified?: Maybe;
+ /** The user who last modified the Object */
+ modifiedById?: Maybe;
+ /** The name/title of the template */
+ name: Scalars['String']['output'];
+ /** The affiliation that the template belongs to */
+ owner?: Maybe;
+ /** The template that this one was derived from */
+ sourceTemplateId?: Maybe;
+ /** The template's availability setting: Public is available to everyone, Private only your affiliation */
+ visibility: TemplateVisibility;
+};
+
+/** A user that that belongs to a different affiliation that can edit the Template */
+export type TemplateCollaborator = {
+ __typename?: 'TemplateCollaborator';
+ /** The timestamp when the Object was created */
+ created?: Maybe;
+ /** The user who created the Object */
+ createdById?: Maybe;
+ /** The collaborator's email */
+ email: Scalars['String']['output'];
+ /** Errors associated with the Object */
+ errors?: Maybe>;
+ /** The unique identifer for the Object */
+ id?: Maybe;
+ /** The user who invited the collaborator */
+ invitedBy?: Maybe;
+ /** The timestamp when the Object was last modifed */
+ modified?: Maybe;
+ /** The user who last modified the Object */
+ modifiedById?: Maybe;
+ /** The template the collaborator may edit */
+ template?: Maybe;
+ /** The collaborator (if they have an account) */
+ user?: Maybe;
+};
+
+/** Template version type */
+export enum TemplateVersionType {
+ /** Draft - saved state for internal review */
+ Draft = 'DRAFT',
+ /** Published - saved state for use when creating DMPs */
+ Published = 'PUBLISHED'
+}
+
+/** Template visibility */
+export enum TemplateVisibility {
+ /** Visible only to users of your institution */
+ Private = 'PRIVATE',
+ /** Visible to all users */
+ Public = 'PUBLIC'
+}
+
+/** A user of the DMPTool */
export type User = {
__typename?: 'User';
+ /** The user's organizational affiliation */
affiliation?: Maybe;
- created: Scalars['DateTimeISO']['output'];
+ /** The timestamp when the Object was created */
+ created?: Maybe;
+ /** The user who created the Object */
+ createdById?: Maybe;
+ /** The user's primary email address */
email: Scalars['EmailAddress']['output'];
+ /** Errors associated with the Object */
+ errors?: Maybe>;
+ /** The user's first/given name */
givenName?: Maybe;
+ /** The unique identifer for the Object */
id?: Maybe;
- modified: Scalars['DateTimeISO']['output'];
+ /** The timestamp when the Object was last modifed */
+ modified?: Maybe;
+ /** The user who last modified the Object */
+ modifiedById?: Maybe;
+ /** The user's ORCID */
orcid?: Maybe;
+ /** The user's role within the DMPTool */
role: UserRole;
+ /** The user's last/family name */
surName?: Maybe;
};
+/** The types of roles supported by the DMPTool */
export enum UserRole {
Admin = 'ADMIN',
Researcher = 'RESEARCHER',
- SuperAdmin = 'SUPER_ADMIN'
+ Superadmin = 'SUPERADMIN'
}
+/** A snapshot of a Template when it became published. DMPs are created from published templates */
+export type VersionedTemplate = {
+ __typename?: 'VersionedTemplate';
+ /** Whether or not this is the version provided when users create a new DMP (default: false) */
+ active: Scalars['Boolean']['output'];
+ /** Whether or not this Template is designated as a 'Best Practice' template */
+ bestPractice: Scalars['Boolean']['output'];
+ /** A comment/note the user enters when publishing the Template */
+ comment?: Maybe;
+ /** The timestamp when the Object was created */
+ created?: Maybe;
+ /** The user who created the Object */
+ createdById?: Maybe;
+ /** A description of the purpose of the template */
+ description?: Maybe;
+ /** Errors associated with the Object */
+ errors?: Maybe>;
+ /** The unique identifer for the Object */
+ id?: Maybe;
+ /** The timestamp when the Object was last modifed */
+ modified?: Maybe;
+ /** The user who last modified the Object */
+ modifiedById?: Maybe;
+ /** The name/title of the template */
+ name: Scalars['String']['output'];
+ /** The owner of the Template */
+ owner?: Maybe;
+ /** The template that this published version stems from */
+ template?: Maybe;
+ /** The major.minor semantic version */
+ version: Scalars['String']['output'];
+ /** The type of version: Published or Draft (default: Draft) */
+ versionType?: Maybe;
+ /** The publisher of the Template */
+ versionedBy?: Maybe;
+ /** The template's availability setting: Public is available to everyone, Private only your affiliation */
+ visibility: TemplateVisibility;
+};
+
export enum YesNoUnknown {
No = 'no',
Unknown = 'unknown',
@@ -367,7 +588,14 @@ export type AffiliationsQuery = { __typename?: 'Query', affiliations?: Array<{ _
export type ContributorRolesQueryVariables = Exact<{ [key: string]: never; }>;
-export type ContributorRolesQuery = { __typename?: 'Query', contributorRoles?: Array<{ __typename?: 'ContributorRole', id: number, label: string, url: any } | null> | null };
+export type ContributorRolesQuery = { __typename?: 'Query', contributorRoles?: Array<{ __typename?: 'ContributorRole', id?: number | null, label: string, url: any } | null> | null };
+
+export type TemplateVersionsQueryVariables = Exact<{
+ templateId: Scalars['Int']['input'];
+}>;
+
+
+export type TemplateVersionsQuery = { __typename?: 'Query', templateVersions?: Array<{ __typename?: 'VersionedTemplate', name: string, version: string, created?: any | null, comment?: string | null, id?: number | null, versionedBy?: { __typename?: 'User', givenName?: string | null, surName?: string | null, modified?: any | null, affiliation?: { __typename?: 'Affiliation', name: string } | null } | null } | null> | null };
export const AffiliationsDocument = gql`
@@ -451,4 +679,56 @@ export function useContributorRolesSuspenseQuery(baseOptions?: Apollo.SuspenseQu
export type ContributorRolesQueryHookResult = ReturnType;
export type ContributorRolesLazyQueryHookResult = ReturnType;
export type ContributorRolesSuspenseQueryHookResult = ReturnType;
-export type ContributorRolesQueryResult = Apollo.QueryResult;
\ No newline at end of file
+export type ContributorRolesQueryResult = Apollo.QueryResult;
+export const TemplateVersionsDocument = gql`
+ query TemplateVersions($templateId: Int!) {
+ templateVersions(templateId: $templateId) {
+ name
+ version
+ created
+ comment
+ id
+ versionedBy {
+ givenName
+ surName
+ affiliation {
+ name
+ }
+ modified
+ }
+ }
+}
+ `;
+
+/**
+ * __useTemplateVersionsQuery__
+ *
+ * To run a query within a React component, call `useTemplateVersionsQuery` and pass it any options that fit your needs.
+ * When your component renders, `useTemplateVersionsQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useTemplateVersionsQuery({
+ * variables: {
+ * templateId: // value for 'templateId'
+ * },
+ * });
+ */
+export function useTemplateVersionsQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: TemplateVersionsQueryVariables; skip?: boolean; } | { skip: boolean; }) ) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useQuery(TemplateVersionsDocument, options);
+ }
+export function useTemplateVersionsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useLazyQuery(TemplateVersionsDocument, options);
+ }
+export function useTemplateVersionsSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useSuspenseQuery(TemplateVersionsDocument, options);
+ }
+export type TemplateVersionsQueryHookResult = ReturnType;
+export type TemplateVersionsLazyQueryHookResult = ReturnType;
+export type TemplateVersionsSuspenseQueryHookResult = ReturnType;
+export type TemplateVersionsQueryResult = Apollo.QueryResult;
\ No newline at end of file
diff --git a/graphql/templateVersions.query.graphql b/graphql/templateVersions.query.graphql
new file mode 100644
index 00000000..b77fda1c
--- /dev/null
+++ b/graphql/templateVersions.query.graphql
@@ -0,0 +1,17 @@
+query TemplateVersions($templateId: Int!) {
+ templateVersions(templateId: $templateId) {
+ name
+ version
+ created
+ comment
+ id
+ versionedBy {
+ givenName
+ surName
+ affiliation {
+ name
+ }
+ modified
+ }
+ }
+}
\ No newline at end of file
diff --git a/public/images/bg-arrow-Up.svg b/public/images/bg-arrow-Up.svg
new file mode 100644
index 00000000..694de1a7
--- /dev/null
+++ b/public/images/bg-arrow-Up.svg
@@ -0,0 +1,11 @@
+
+
+ Path 4 Copy 3
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/styles/_elements.scss b/styles/_elements.scss
index 37c6c8fd..5d822506 100644
--- a/styles/_elements.scss
+++ b/styles/_elements.scss
@@ -304,4 +304,121 @@ Label {
line-height: 1em;
margin-top: 0;
}
+
+ // Table
+ .react-aria-Table {
+ padding: 0.286rem;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--overlay-background);
+ outline: none;
+ border-spacing: 0;
+ min-height: 100px;
+ align-self: start;
+ max-width: 100%;
+ word-break: break-word;
+ forced-color-adjust: none;
+
+ &[data-focus-visible] {
+ outline: 2px solid var(--focus-ring-color);
+ outline-offset: -1px;
+ }
+
+ .react-aria-TableHeader {
+ color: var(--text-color);
+
+ &:after {
+ content: '';
+ display: table-row;
+ height: 2px;
+ }
+
+ & tr:last-child .react-aria-Column {
+ border-bottom: 1px solid var(--border-color);
+ cursor: default;
+ }
+ }
+
+ .react-aria-Row {
+ --radius-top: 6px;
+ --radius-bottom: 6px;
+ --radius: var(--radius-top) var(--radius-top) var(--radius-bottom) var(--radius-bottom);
+ border-radius: var(--radius);
+ clip-path: inset(0 round var(--radius)); /* firefox */
+ outline: none;
+ cursor: default;
+ color: var(--text-color);
+ font-size: 1.072rem;
+ position: relative;
+ transform: scale(1);
+
+ &[data-focus-visible] {
+ outline: 2px solid var(--focus-ring-color);
+ outline-offset: -2px;
+ }
+
+ &[data-pressed] {
+ background: var(--gray-100);
+ }
+
+ &[data-selected] {
+ background: var(--highlight-background);
+ color: var(--highlight-foreground);
+ --focus-ring-color: var(--highlight-foreground);
+
+ &[data-focus-visible],
+ .react-aria-Cell[data-focus-visible] {
+ outline-offset: -4px;
+ }
+ }
+
+ &[data-disabled] {
+ color: var(--text-color-disabled);
+ }
+ }
+
+ .react-aria-Cell,
+ .react-aria-Column {
+ padding: 4px 8px;
+ text-align: left;
+ outline: none;
+
+ &[data-focus-visible] {
+ outline: 2px solid var(--focus-ring-color);
+ outline-offset: -2px;
+ }
+ }
+
+ .react-aria-Cell {
+ transform: translateZ(0);
+
+ &:first-child {
+ border-radius: var(--radius-top) 0 0 var(--radius-bottom);
+ }
+
+ &:last-child {
+ border-radius: 0 var(--radius-top) var(--radius-bottom) 0;
+ }
+ }
+
+ /* join selected items if :has selector is supported */
+ @supports selector(:has(.foo)) {
+ .react-aria-Row[data-selected]:has(+ [data-selected]),
+ .react-aria-Row[data-selected]:has(+ .react-aria-DropIndicator + [data-selected]) {
+ --radius-bottom: 0px;
+ }
+
+ .react-aria-Row[data-selected] + [data-selected],
+ .react-aria-Row[data-selected] + .react-aria-DropIndicator + [data-selected]{
+ --radius-top: 0px;
+ }
+ }
+}
+
+:where(.react-aria-Row) .react-aria-Checkbox {
+ --selected-color: var(--highlight-foreground);
+ --selected-color-pressed: var(--highlight-foreground-pressed);
+ --checkmark-color: var(--highlight-background);
+ --background-color: var(--highlight-background);
+}
}
diff --git a/styles/_page.scss b/styles/_page.scss
index 3f8ffa66..c7171c9a 100644
--- a/styles/_page.scss
+++ b/styles/_page.scss
@@ -1,3 +1,4 @@
+
/*
* Styles related to the overall structure of the browser page.
* These are the core sections that make up the structure of the
@@ -56,6 +57,36 @@ header {
}
}
+.with-subheader {
+ margin-bottom: 5px;
+}
+
+.subheader {
+ @include device(md) {
+ font-size: 1rem;
+ }
+
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: left;
+ gap: 5px 20px;
+ font-size: 0.8rem;
+ color: var(--gray-600);
+ margin-top: 0;
+}
+
+// ** Main Section ** //
+main {
+ @include device(xs) {
+ padding: var(--brand-space1) var(--brand-space2);
+ }
+ @include device(md) {
+ padding: var(--brand-space2) var(--brand-space3);
+ }
+ margin-bottom: 25px;
+}
+
// TODO/FIXME: I'm not convinced that we should isolate dropdown styles
// for a single area of the page like this.
@@ -93,10 +124,19 @@ header span.down {
-webkit-transform: rotate(45deg);
}
+// ** Table Cells ** //
+table {
+ border-spacing: 0;
+ margin-top: 25px;
+ th {
+ padding-bottom: 15px;
+ text-align: left;
+ }
-// ** Main Section ** //
-main {
- margin-bottom: 25px;
+ td {
+ padding: 15px 25px 10px 0;
+ vertical-align: top;
+ }
}
@@ -121,3 +161,20 @@ footer {
margin-right: 5px;
}
}
+
+
+.back-link-button {
+ background: none;
+ border: none;
+ color: #000;
+ text-decoration: none;
+ cursor: pointer;
+ padding: 0;
+ font: inherit;
+}
+
+.back-link-button:hover,
+.back-link-button:focus {
+ color: var(--purple-600);
+ text-decoration: underline;
+}
\ No newline at end of file
diff --git a/styles/_typography.scss b/styles/_typography.scss
index d661f43b..03693ef2 100644
--- a/styles/_typography.scss
+++ b/styles/_typography.scss
@@ -7,26 +7,38 @@ h4 {
}
h1 {
+ @include device(md) {
+ font-size: 2rem;
+ }
color: var(--heading-color);
- font-size: 1.75em;
+ font-size: 1.5rem; //24px
}
+
h2, h3, h4 {
color: var(--heading-color);
}
h2 {
- font-size: 1.5em;
- margin-top: 10px;
+ @include device(md) {
+ font-size: 1.75rem;
+ }
+ font-size: 1.25rem;//20px
}
h3 {
- font-size: 1.3em;
+ @include device(md) {
+ font-size: 1.5rem;
+ }
+ font-size: 1.125rem;//18px
padding: 5px 10px 5px 3px;
}
h4 {
- font-size: 1.2em;
+ @include device(md) {
+ font-size: 1.25rem;
+ }
+ font-size: 1rem;//16px
}
p, dd {
@@ -56,4 +68,4 @@ code {
monospace;
color: var(--brand-tertiary);
font-weight: bold;
-}
+}
\ No newline at end of file
diff --git a/styles/globals.scss b/styles/globals.scss
index 40f8c9b9..a481d4a0 100644
--- a/styles/globals.scss
+++ b/styles/globals.scss
@@ -1,4 +1,4 @@
-@import '@/components/responsive';
+@import '@/styles/responsive';
*,
*:before,
diff --git a/styles/responsive.scss b/styles/responsive.scss
new file mode 100644
index 00000000..9aab05f7
--- /dev/null
+++ b/styles/responsive.scss
@@ -0,0 +1,50 @@
+/* NOTE:: Since we are doing responsive, all CSS that DOESN'T use
+ * media queries will be assumed to be for mobile sizes.
+ */
+
+// Responsive Sizes
+$xs: 0;
+$sm: 640px;
+$md: 768px;
+$lg: 1024px;
+$xxl: 1440px;
+
+
+// A quick helper to quickly create a media query block for a named device
+@mixin device($breakpoint) {
+ @if $breakpoint == xs {
+ @media screen and (min-width: $xs) {
+ @content;
+ }
+ }
+
+ @if $breakpoint == sm {
+ @media screen and (min-width: $sm) {
+ @content;
+ }
+ }
+
+ @if $breakpoint == md {
+ @media screen and (min-width: $md) {
+ @content;
+ }
+ }
+
+ @if $breakpoint == lg {
+ @media screen and (min-width: $lg) {
+ @content;
+ }
+ }
+
+ @if $breakpoint == xl {
+ @media screen and (min-width: $xl) {
+ @content;
+ }
+ }
+
+ @if $breakpoint == xxl{
+ @media screen and (min-width: $xxl) {
+ @content;
+ }
+ }
+}
\ No newline at end of file
diff --git a/utils/dateUtils.ts b/utils/dateUtils.ts
new file mode 100644
index 00000000..3becbc15
--- /dev/null
+++ b/utils/dateUtils.ts
@@ -0,0 +1,36 @@
+export const formatWithTimeAndDate = (isoString: Date) => {
+ if (isoString) {
+ // Parse the ISO date string into a Date object
+ const date = new Date(isoString);
+
+ // Define arrays for month names
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+ // Extract date components
+ const hours = date.getHours();
+ const minutes = date.getMinutes();
+ const day = date.getDate();
+ const month = date.getMonth(); // Month is 0-based
+ const year = date.getFullYear();
+
+ // Format hours and minutes
+ const formattedHours = hours.toString().padStart(2, '0'); // Add leading zero if needed
+ const formattedMinutes = minutes.toString().padStart(2, '0'); // Add leading zero if needed
+
+ // Format the final string
+ return `${formattedHours}:${formattedMinutes} on ${months[month]} ${day}, ${year}`;
+ } else {
+ return 'No date';
+ }
+}
+
+export const formatShortMonthDayYear = (isoString: Date) => {
+ if (isoString) {
+ const date = new Date(isoString);
+ const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' };
+ return date.toLocaleDateString('en-US', options);
+ } else {
+ return 'No date';
+ }
+
+}
\ No newline at end of file