diff --git a/packages/openneuro-app/src/@types/custom.d.ts b/packages/openneuro-app/src/@types/custom.d.ts index b0d0ea9ab..be98db7bc 100644 --- a/packages/openneuro-app/src/@types/custom.d.ts +++ b/packages/openneuro-app/src/@types/custom.d.ts @@ -11,6 +11,14 @@ declare module "*.svg" { export = value } + + +// Allow custom scss modules +declare module "*.module.scss" { + const classes: { [key: string]: string }; + export default classes; +} + // Allow .scss imports declare module "*.scss" { const value: string diff --git a/packages/openneuro-app/src/scripts/dataset/mutations/__tests__/update-permissions.spec.jsx b/packages/openneuro-app/src/scripts/dataset/mutations/__tests__/update-permissions.spec.jsx index b5d87c1e2..936a24b4a 100644 --- a/packages/openneuro-app/src/scripts/dataset/mutations/__tests__/update-permissions.spec.jsx +++ b/packages/openneuro-app/src/scripts/dataset/mutations/__tests__/update-permissions.spec.jsx @@ -2,12 +2,13 @@ import React from "react" import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { MockedProvider } from "@apollo/client/testing" import { - isValidOrcid, UPDATE_ORCID_PERMISSIONS, UPDATE_PERMISSIONS, UpdateDatasetPermissions, } from "../update-permissions" +import { isValidOrcid } from "../../../utils/validationUtils.ts"; + function permissionMocksFactory( updatePermissionsCalled, updateOrcidPermissionsCalled, diff --git a/packages/openneuro-app/src/scripts/dataset/mutations/update-permissions.tsx b/packages/openneuro-app/src/scripts/dataset/mutations/update-permissions.tsx index 6279dfdfa..36f3a4dc4 100644 --- a/packages/openneuro-app/src/scripts/dataset/mutations/update-permissions.tsx +++ b/packages/openneuro-app/src/scripts/dataset/mutations/update-permissions.tsx @@ -7,15 +7,7 @@ import ToastContent from "../../common/partials/toast-content" import { validate as isValidEmail } from "email-validator" import { Button } from "@openneuro/components/button" -export function isValidOrcid(orcid: string) { - if (orcid) { - return /^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$/.test(orcid) - ? true - : false - } else { - return false - } -} +import { isValidOrcid } from "../../utils/validationUtils"; export const UPDATE_PERMISSIONS = gql` mutation updatePermissions( diff --git a/packages/openneuro-app/src/scripts/routes.tsx b/packages/openneuro-app/src/scripts/routes.tsx index 76d897841..6f1ab9390 100644 --- a/packages/openneuro-app/src/scripts/routes.tsx +++ b/packages/openneuro-app/src/scripts/routes.tsx @@ -5,6 +5,8 @@ import { Navigate, Route, Routes } from "react-router-dom" import DatasetQuery from "./dataset/dataset-query" //import PreRefactorDatasetProps from './dataset/dataset-pre-refactor-container' + + import FaqPage from "./pages/faq/faq" import FrontPageContainer from "./pages/front-page/front-page" import Admin from "./pages/admin/admin" @@ -17,6 +19,7 @@ import FourOFourPage from "./errors/404page" import { ImportDataset } from "./pages/import-dataset" import { DatasetMetadata } from "./pages/metadata/dataset-metadata" import { TermsPage } from "./pages/terms" +import { UserQuery } from "./users/user-query" const AppRoutes: React.VoidFunctionComponent = () => ( @@ -33,6 +36,7 @@ const AppRoutes: React.VoidFunctionComponent = () => ( } /> } /> } /> + } /> } diff --git a/packages/openneuro-app/src/scripts/users/__tests__/user-account-view.spec.tsx b/packages/openneuro-app/src/scripts/users/__tests__/user-account-view.spec.tsx new file mode 100644 index 000000000..ec2f7940a --- /dev/null +++ b/packages/openneuro-app/src/scripts/users/__tests__/user-account-view.spec.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { render, screen, fireEvent, within, waitFor} from '@testing-library/react'; +import { UserAccountView } from '../user-account-view'; + +const baseUser = { + name: "John Doe", + email: "johndoe@example.com", + orcid: "0000-0001-2345-6789", + location: "San Francisco, CA", + institution: "University of California", + links: ["https://example.com", "https://example.org"], + github: "johndoe", + }; + +describe('', () => { + it('should render the user details correctly', () => { + render(); + + // Check if user details are rendered + expect(screen.getByText('Name:')).toBeInTheDocument(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Email:')).toBeInTheDocument(); + expect(screen.getByText('johndoe@example.com')).toBeInTheDocument(); + expect(screen.getByText('ORCID:')).toBeInTheDocument(); + expect(screen.getByText('0000-0001-2345-6789')).toBeInTheDocument(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + }); + + it('should render links with EditableContent', async () => { + render(); + const institutionSection = within(screen.getByText('Institution').closest('.user-meta-block')); + expect(screen.getByText('Institution')).toBeInTheDocument(); + const editButton = institutionSection.getByText('Edit'); + fireEvent.click(editButton); + const textbox = institutionSection.getByRole('textbox'); + fireEvent.change(textbox, { target: { value: 'New University' } }); + const saveButton = institutionSection.getByText('Save'); + const closeButton = institutionSection.getByText('Close'); + fireEvent.click(saveButton); + fireEvent.click(closeButton); + // Add debug step + await waitFor(() => screen.debug()); + // Use a flexible matcher to check for text + await waitFor(() => + expect(institutionSection.getByText('New University')).toBeInTheDocument() + ); + }); + + + it('should render location with EditableContent', async () => { + render(); + const locationSection = within(screen.getByText('Location').closest('.user-meta-block')); + expect(screen.getByText('Location')).toBeInTheDocument(); + const editButton = locationSection.getByText('Edit'); + fireEvent.click(editButton); + const textbox = locationSection.getByRole('textbox'); + fireEvent.change(textbox, { target: { value: 'Marin, CA' } }); + const saveButton = locationSection.getByText('Save'); + const closeButton = locationSection.getByText('Close'); + fireEvent.click(saveButton); + fireEvent.click(closeButton); + // Add debug step + await waitFor(() => screen.debug()); + // Use a flexible matcher to check for text + await waitFor(() => + expect(locationSection.getByText('Marin, CA')).toBeInTheDocument() + ); + }); +}); diff --git a/packages/openneuro-app/src/scripts/users/__tests__/user-card.spec.tsx b/packages/openneuro-app/src/scripts/users/__tests__/user-card.spec.tsx new file mode 100644 index 000000000..247bdeeab --- /dev/null +++ b/packages/openneuro-app/src/scripts/users/__tests__/user-card.spec.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import type { User } from "../user-card"; +import { UserCard } from "../user-card"; + +describe("UserCard Component", () => { + const baseUser: User = { + name: "John Doe", + email: "johndoe@example.com", + orcid: "0000-0001-2345-6789", + location: "San Francisco, CA", + institution: "University of California", + links: ["https://example.com", "https://example.org"], + github: "johndoe", + }; + + it("renders all user details when all data is provided", () => { + + render(); + + const orcidLink = screen.getByRole("link", { + name: "ORCID profile of John Doe", + }); + expect(orcidLink).toHaveAttribute("href", "https://orcid.org/0000-0001-2345-6789"); + expect(screen.getByText("University of California")).toBeInTheDocument(); + expect(screen.getByText("San Francisco, CA")).toBeInTheDocument(); + + const emailLink = screen.getByRole("link", { name: "johndoe@example.com" }); + expect(emailLink).toHaveAttribute("href", "mailto:johndoe@example.com"); + + const githubLink = screen.getByRole("link", { name: "Github profile of John Doe", }); + expect(githubLink).toHaveAttribute("href", "https://github.com/johndoe"); + expect( + screen.getByRole("link", { name: "https://example.com" }) + ).toHaveAttribute("href", "https://example.com"); + expect( + screen.getByRole("link", { name: "https://example.org" }) + ).toHaveAttribute("href", "https://example.org"); + }); + + it("renders without optional fields", () => { + const minimalUser: User = { + name: "Jane Doe", + email: "janedoe@example.com", + orcid: "0000-0002-3456-7890", + links: [], + }; + + render(); + + const orcidLink = screen.getByRole("link", { + name: "ORCID profile of Jane Doe", + }); + expect(orcidLink).toHaveAttribute("href", "https://orcid.org/0000-0002-3456-7890"); + const emailLink = screen.getByRole("link", { name: "janedoe@example.com" }); + expect(emailLink).toHaveAttribute("href", "mailto:janedoe@example.com"); + expect(screen.queryByText("University of California")).not.toBeInTheDocument(); + expect(screen.queryByText("San Francisco, CA")).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Github profile of Jane Doe" })).not.toBeInTheDocument(); + }); + + it("renders correctly when links are empty", () => { + const userWithEmptyLinks: User = { + ...baseUser, + links: [], + }; + + render(); + + expect(screen.queryByRole("link", { name: "https://example.com" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "https://example.org" })).not.toBeInTheDocument(); + }); + + it("renders correctly when location and institution are missing", () => { + const userWithoutLocationAndInstitution: User = { + name: "Emily Doe", + email: "emilydoe@example.com", + orcid: "0000-0003-4567-8901", + links: ["https://example.com"], + }; + + render(); + + const orcidLink = screen.getByRole("link", { + name: "ORCID profile of Emily Doe", + }); + expect(orcidLink).toHaveAttribute("href", "https://orcid.org/0000-0003-4567-8901"); + const emailLink = screen.getByRole("link", { name: "emilydoe@example.com" }); + expect(emailLink).toHaveAttribute("href", "mailto:emilydoe@example.com"); + const link = screen.getByRole("link", { name: "https://example.com" }); + expect(link).toHaveAttribute("href", "https://example.com"); + expect(screen.queryByText("San Francisco, CA")).not.toBeInTheDocument(); + expect(screen.queryByText("University of California")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/openneuro-app/src/scripts/users/__tests__/user-query.spec.tsx b/packages/openneuro-app/src/scripts/users/__tests__/user-query.spec.tsx new file mode 100644 index 000000000..d75d4ad51 --- /dev/null +++ b/packages/openneuro-app/src/scripts/users/__tests__/user-query.spec.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { UserQuery } from "../user-query"; +import FourOFourPage from "../../errors/404page"; + + +// TODO update these once the correct query is in place and dummy data is not used. +// maybe there is a better way to do this +const VALID_ORCID = "0000-0001-6755-0259"; +const INVALID_ORCID = "0000-000X-1234-5678"; +const UNKNOWN_ORCID = "0000-0000-0000-0000"; + +const renderWithRouter = (orcid: string) => { + return render( + + + } /> + } /> + + + ); +}; + +describe("UserQuery Component", () => { + // TODO update these once the correct query is in place and dummy data is not used. + // maybe there is a better way to do this + it("renders UserRoutes for a valid ORCID", async () => { + renderWithRouter(VALID_ORCID); + const userName = await screen.findByText("Gregory Noack"); + expect(userName).toBeInTheDocument(); + + const userLocation = screen.getByText("Stanford, CA"); + expect(userLocation).toBeInTheDocument(); + }); + + it("renders FourOFourPage for an invalid ORCID", async () => { + renderWithRouter(INVALID_ORCID); + const errorMessage = await screen.findByText( + /404: The page you are looking for does not exist./i + ); + expect(errorMessage).toBeInTheDocument(); + }); + + it("renders FourOFourPage for a missing ORCID", async () => { + renderWithRouter(""); + const errorMessage = await screen.findByText( + /404: The page you are looking for does not exist./i + ); + expect(errorMessage).toBeInTheDocument(); + }); + + it("renders FourOFourPage for an unknown ORCID", async () => { + renderWithRouter(UNKNOWN_ORCID); + const errorMessage = await screen.findByText( + /404: The page you are looking for does not exist./i + ); + expect(errorMessage).toBeInTheDocument(); + }); +}); diff --git a/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx b/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx new file mode 100644 index 000000000..e531ba9fb --- /dev/null +++ b/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { render, screen, cleanup } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { UserRoutes } from "../user-routes"; +import type { User } from "../user-routes"; + +const defaultUser: User = { + id: "1", + name: "John Doe", + location: "Unknown", + github: "", + institution: "Unknown Institution", + email: "john.doe@example.com", + avatar: "https://dummyimage.com/200x200/000/fff", + orcid: "0000-0000-0000-0000", + links: [], +}; + +const renderWithRouter = (user: User, route: string, hasEdit: boolean) => { + return render( + + + + ); +}; + +describe("UserRoutes Component", () => { + const user: User = defaultUser; + + it("renders UserDatasetsView for the default route", async () => { + renderWithRouter(user, "/", true); + expect(screen.getByText(`${user.name}'s Datasets`)).toBeInTheDocument(); + const datasetsView = await screen.findByTestId("user-datasets-view"); + expect(datasetsView).toBeInTheDocument(); + }); + + it("renders FourOFourPage for an invalid route", async () => { + renderWithRouter(user, "/nonexistent-route", true); + const errorMessage = await screen.findByText( + /404: The page you are looking for does not exist./i + ); + expect(errorMessage).toBeInTheDocument(); + }); + + it("renders UserAccountView when hasEdit is true", async () => { + renderWithRouter(user, "/account", true); + const accountView = await screen.findByTestId("user-account-view"); + expect(accountView).toBeInTheDocument(); + }); + + it("renders UserNotificationsView when hasEdit is true", async () => { + renderWithRouter(user, "/notifications", true); + const notificationsView = await screen.findByTestId( + "user-notifications-view" + ); + expect(notificationsView).toBeInTheDocument(); + }); + + it("renders FourOThreePage when hasEdit is false for restricted routes", async () => { + const restrictedRoutes = ["/account", "/notifications"]; + + for (const route of restrictedRoutes) { + cleanup(); + renderWithRouter(user, route, false); + const errorMessage = await screen.findByText( + /403: You do not have access to this page, you may need to sign in./i + ); + expect(errorMessage).toBeInTheDocument(); + } + }); +}); diff --git a/packages/openneuro-app/src/scripts/users/__tests__/user-tabs.spec.tsx b/packages/openneuro-app/src/scripts/users/__tests__/user-tabs.spec.tsx new file mode 100644 index 000000000..217d89b4e --- /dev/null +++ b/packages/openneuro-app/src/scripts/users/__tests__/user-tabs.spec.tsx @@ -0,0 +1,87 @@ +import React, { useState } from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { UserAccountTabs } from "../user-tabs"; + +// Wrapper component to allow dynamic modification of `hasEdit` +const UserAccountTabsWrapper: React.FC = () => { + const [hasEdit, setHasEdit] = useState(true); + + return ( + <> + + + + } /> + + + + ); +}; + +describe("UserAccountTabs Component", () => { + it("should not render tabs when hasEdit is false", () => { + render(); + + expect(screen.getByText("User Datasets")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Toggle hasEdit")); + + expect(screen.queryByText("User Datasets")).not.toBeInTheDocument(); + }); + + it("should render tabs when hasEdit is toggled back to true", () => { + render(); + + expect(screen.getByText("User Datasets")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Toggle hasEdit")); + expect(screen.queryByText("User Datasets")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("Toggle hasEdit")); + expect(screen.getByText("User Datasets")).toBeInTheDocument(); + }); + + it("should update active class on the correct NavLink based on route", () => { + render(); + + // Utility function to check if an element has 'active' class - used because of CSS module discrepancies between classNames + const hasActiveClass = (element) => element.className.includes('active'); + + const datasetsTab = screen.getByText("User Datasets"); + expect(hasActiveClass(datasetsTab)).toBe(true); + + const notificationsTab = screen.getByText("User Notifications"); + + fireEvent.click(notificationsTab); + + expect(hasActiveClass(notificationsTab)).toBe(true); + expect(hasActiveClass(datasetsTab)).toBe(false); + + const accountTab = screen.getByText("Account Info"); + + fireEvent.click(accountTab); + + expect(hasActiveClass(accountTab)).toBe(true); + expect(hasActiveClass(datasetsTab)).toBe(false); + expect(hasActiveClass(notificationsTab)).toBe(false); + }); + + + it("should trigger animation state when a tab is clicked", async () => { + render(); + + const notificationsTab = screen.getByText("User Notifications"); + // Utility function to check if an element has 'clicked' class - used because of CSS module discrepancies between classNames + const hasClickedClass = (element) => element.className.includes('clicked'); + const tabsContainer = await screen.findByRole("list"); + + expect(hasClickedClass(tabsContainer)).toBe(false); + + fireEvent.click(notificationsTab); + + expect(hasClickedClass(tabsContainer)).toBe(true); + }); + + +}); diff --git a/packages/openneuro-app/src/scripts/users/components/close-button.tsx b/packages/openneuro-app/src/scripts/users/components/close-button.tsx new file mode 100644 index 000000000..a33c434b2 --- /dev/null +++ b/packages/openneuro-app/src/scripts/users/components/close-button.tsx @@ -0,0 +1,20 @@ +import React from "react" +import type { FC } from "react" +import { Button } from "@openneuro/components/button" + +/** + * An edit button, calls action when clicked + */ +interface CloseButtonProps { + action: () => void +} +export const CloseButton: FC = ({ action }) => { + return ( +