From abd76e282fa04ae282982fe1bda5d0bcae1bbecd Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 10:58:56 -0800 Subject: [PATCH 01/14] update user schema to include new fields --- packages/openneuro-server/src/graphql/schema.ts | 4 ++++ packages/openneuro-server/src/models/user.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/openneuro-server/src/graphql/schema.ts b/packages/openneuro-server/src/graphql/schema.ts index 9aa6b14794..249ad90e81 100644 --- a/packages/openneuro-server/src/graphql/schema.ts +++ b/packages/openneuro-server/src/graphql/schema.ts @@ -322,6 +322,10 @@ export const typeDefs = ` name: String admin: Boolean blocked: Boolean + location: String + institution: String + github: String + links: [String] } # Which provider a user login comes from diff --git a/packages/openneuro-server/src/models/user.ts b/packages/openneuro-server/src/models/user.ts index 4738a066e9..9cf1fda5e2 100644 --- a/packages/openneuro-server/src/models/user.ts +++ b/packages/openneuro-server/src/models/user.ts @@ -15,6 +15,10 @@ export interface UserDocument extends Document { blocked: boolean created: Date lastSeen: Date + location: string + institution: string + github: string + links: string[] } const userSchema = new Schema({ @@ -29,10 +33,14 @@ const userSchema = new Schema({ blocked: { type: Boolean, default: false }, created: { type: Date, default: Date.now }, lastSeen: { type: Date, default: Date.now }, + location: { type: String, default: "" }, + institution: { type: String, default: "" }, + github: { type: String, default: "" }, + links: { type: [String], default: [] }, }) userSchema.index({ id: 1, provider: 1 }, { unique: true }) -// Allow case insensitive email queries +// Allow case-insensitive email queries userSchema.index( { email: 1 }, { From 363896102e2e44a101b6a51524b8f1438bc62e73 Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 11:27:00 -0800 Subject: [PATCH 02/14] adding resolver and schema mutation --- .../src/graphql/resolvers/user.ts | 26 +++++++++++++++++++ .../openneuro-server/src/graphql/schema.ts | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/openneuro-server/src/graphql/resolvers/user.ts b/packages/openneuro-server/src/graphql/resolvers/user.ts index cab75e6ba7..37eabdcc5a 100644 --- a/packages/openneuro-server/src/graphql/resolvers/user.ts +++ b/packages/openneuro-server/src/graphql/resolvers/user.ts @@ -54,6 +54,31 @@ export const setBlocked = (obj, { id, blocked }, { userInfo }) => { } } +const updateUser = async (obj, { id, location, institution, links }) => { + try { + // Find the user by their ID or ORCID (similar to your existing logic) + const user = await User.findOne({ + $or: [{ "orcid": id }, { "providerId": id }], + }).exec() + + if (!user) { + throw new Error("User not found") + } + + // Update user fields (optional values based on provided inputs) + if (location !== undefined) user.location = location + if (institution !== undefined) user.institution = institution + if (links !== undefined) user.links = links + + // Save the updated user + await user.save() + + return user // Return the updated user object + } catch (err) { + throw new Error("Failed to update user: " + err.message) + } +} + const UserResolvers = { id: (obj) => obj.id, provider: (obj) => obj.provider, @@ -66,6 +91,7 @@ const UserResolvers = { name: (obj) => obj.name, admin: (obj) => obj.admin, blocked: (obj) => obj.blocked, + updateUser, } export default UserResolvers diff --git a/packages/openneuro-server/src/graphql/schema.ts b/packages/openneuro-server/src/graphql/schema.ts index 249ad90e81..c626ffb0a4 100644 --- a/packages/openneuro-server/src/graphql/schema.ts +++ b/packages/openneuro-server/src/graphql/schema.ts @@ -132,6 +132,8 @@ export const typeDefs = ` setAdmin(id: ID!, admin: Boolean!): User # Sets a users admin status setBlocked(id: ID!, blocked: Boolean!): User + # Mutation for updating user data + updateUser(id: ID!, location: String, institution: String, links: [String]): User # Tracks a view or download for a dataset trackAnalytics(datasetId: ID!, tag: String, type: AnalyticTypes): Boolean # Follow dataset @@ -836,5 +838,4 @@ schemaComposer.addTypeDefs(typeDefs) schemaComposer.addResolveMethods(resolvers) schemaComposer.Query.addFields(datasetSearch) schemaComposer.Query.addFields(advancedDatasetSearch) - export default schemaComposer.buildSchema() From 6c0b807028a79ba3f46ec636a53bb49082d8ff24 Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 11:44:09 -0800 Subject: [PATCH 03/14] update mutations --- packages/openneuro-server/src/graphql/resolvers/mutation.ts | 3 ++- packages/openneuro-server/src/graphql/resolvers/user.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/openneuro-server/src/graphql/resolvers/mutation.ts b/packages/openneuro-server/src/graphql/resolvers/mutation.ts index 1cc46d5a38..7c3bf887d4 100644 --- a/packages/openneuro-server/src/graphql/resolvers/mutation.ts +++ b/packages/openneuro-server/src/graphql/resolvers/mutation.ts @@ -16,7 +16,7 @@ import { deprecateSnapshot, undoDeprecateSnapshot, } from "./snapshots.js" -import { removeUser, setAdmin, setBlocked } from "./user.js" +import { removeUser, setAdmin, setBlocked, updateUser } from "./user.js" import { updateSummary } from "./summary" import { revalidate, updateValidation } from "./validation.js" import { @@ -88,6 +88,7 @@ const Mutation = { deleteRelation, importRemoteDataset, finishImportRemoteDataset, + updateUser, } export default Mutation diff --git a/packages/openneuro-server/src/graphql/resolvers/user.ts b/packages/openneuro-server/src/graphql/resolvers/user.ts index 37eabdcc5a..dbeec1dec1 100644 --- a/packages/openneuro-server/src/graphql/resolvers/user.ts +++ b/packages/openneuro-server/src/graphql/resolvers/user.ts @@ -54,7 +54,7 @@ export const setBlocked = (obj, { id, blocked }, { userInfo }) => { } } -const updateUser = async (obj, { id, location, institution, links }) => { +export const updateUser = async (obj, { id, location, institution, links }) => { try { // Find the user by their ID or ORCID (similar to your existing logic) const user = await User.findOne({ @@ -91,7 +91,9 @@ const UserResolvers = { name: (obj) => obj.name, admin: (obj) => obj.admin, blocked: (obj) => obj.blocked, - updateUser, + location: (obj) => obj.location, + institution: (obj) => obj.institution, + links: (obj) => obj.links, } export default UserResolvers From e3bef13e04220e0bf40ff835ed43d9eb60b3467a Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 12:11:05 -0800 Subject: [PATCH 04/14] update the mutation to work with either id or orcid --- .../src/graphql/resolvers/user.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/openneuro-server/src/graphql/resolvers/user.ts b/packages/openneuro-server/src/graphql/resolvers/user.ts index dbeec1dec1..714e004b97 100644 --- a/packages/openneuro-server/src/graphql/resolvers/user.ts +++ b/packages/openneuro-server/src/graphql/resolvers/user.ts @@ -2,12 +2,11 @@ * User resolvers */ import User from "../../models/user" +function isValidOrcid(orcid: string): boolean { + return /^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$/.test(orcid || "") +} export const user = (obj, { id }) => { - function isValidOrcid(orcid: string): boolean { - return /^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$/.test(orcid || "") - } - if (isValidOrcid(id)) { return User.findOne({ $or: [{ "orcid": id }, { "providerId": id }], @@ -56,10 +55,15 @@ export const setBlocked = (obj, { id, blocked }, { userInfo }) => { export const updateUser = async (obj, { id, location, institution, links }) => { try { - // Find the user by their ID or ORCID (similar to your existing logic) - const user = await User.findOne({ - $or: [{ "orcid": id }, { "providerId": id }], - }).exec() + let user // Declare user outside the if block + + if (isValidOrcid(id)) { + user = await User.findOne({ + $or: [{ "orcid": id }, { "providerId": id }], + }).exec() + } else { + user = await User.findOne({ "id": id }).exec() + } if (!user) { throw new Error("User not found") From 9b93565784770cfe9e3255102e9f7565d325dbb1 Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 12:28:35 -0800 Subject: [PATCH 05/14] adding frontend ui gql --- .../src/scripts/users/user-account-view.tsx | 29 +++++++++++++++++-- .../src/scripts/users/user-query.tsx | 9 ++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/openneuro-app/src/scripts/users/user-account-view.tsx b/packages/openneuro-app/src/scripts/users/user-account-view.tsx index 92bd02a6c0..cceec2a09b 100644 --- a/packages/openneuro-app/src/scripts/users/user-account-view.tsx +++ b/packages/openneuro-app/src/scripts/users/user-account-view.tsx @@ -1,6 +1,8 @@ import React, { useState } from "react" +import { useMutation } from "@apollo/client" import { EditableContent } from "./components/editable-content" import styles from "./scss/useraccountview.module.scss" +import { GET_USER_BY_ORCID, UPDATE_USER } from "./user-query" interface UserAccountViewProps { user: { @@ -20,6 +22,30 @@ export const UserAccountView: React.FC = ({ user }) => { const [userInstitution, setInstitution] = useState( user.institution || "", ) + const [updateUser] = useMutation(UPDATE_USER) + + const handleLocationChange = async (newLocation: string) => { + setLocation(newLocation) + console.log("Updating location:", newLocation) // Log the location to check + + try { + const result = await updateUser({ + variables: { + id: user.orcid, + location: newLocation, + }, + refetchQueries: [ + { + query: GET_USER_BY_ORCID, + variables: { userId: user.orcid }, + }, + ], + }) + console.log("Mutation result:", result) // Log mutation result + } catch (error) { + console.error("Failed to update user:", error) + } + } return (
@@ -55,8 +81,7 @@ export const UserAccountView: React.FC = ({ user }) => { /> - setLocation(newLocation)} + setRows={handleLocationChange} className="custom-class" heading="Location" /> diff --git a/packages/openneuro-app/src/scripts/users/user-query.tsx b/packages/openneuro-app/src/scripts/users/user-query.tsx index e5d98b65a0..248a285a68 100644 --- a/packages/openneuro-app/src/scripts/users/user-query.tsx +++ b/packages/openneuro-app/src/scripts/users/user-query.tsx @@ -18,6 +18,15 @@ export const GET_USER_BY_ORCID = gql` } ` +export const UPDATE_USER = gql` +mutation updateUser($id: ID!, $location: String) { + updateUser(id: $id, location: $location) { + id + location + } +} +` + export interface User { id: string name: string From b314cfc5666bb0ca53eeb4f7a097fa8d01c740fb Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 14:30:09 -0800 Subject: [PATCH 06/14] update the various ui elements that control editing on the user account page --- .../scripts/users/components/edit-list.tsx | 35 +++++++--- .../scripts/users/components/edit-string.tsx | 61 +++++++++++++---- .../users/components/editable-content.tsx | 33 ++++++++-- .../src/scripts/users/user-account-view.tsx | 66 ++++++++++++++++--- .../src/scripts/users/user-query.tsx | 9 ++- 5 files changed, 168 insertions(+), 36 deletions(-) diff --git a/packages/openneuro-app/src/scripts/users/components/edit-list.tsx b/packages/openneuro-app/src/scripts/users/components/edit-list.tsx index c2526983a8..fad2e3814c 100644 --- a/packages/openneuro-app/src/scripts/users/components/edit-list.tsx +++ b/packages/openneuro-app/src/scripts/users/components/edit-list.tsx @@ -6,6 +6,8 @@ interface EditListProps { placeholder?: string elements?: string[] setElements: (elements: string[]) => void + validation?: RegExp // Validation regex prop + validationMessage?: string // Validation message prop } /** @@ -13,32 +15,45 @@ interface EditListProps { * Allows adding and removing strings from a list. */ export const EditList: React.FC = ( - { placeholder = "Enter item", elements = [], setElements }, + { + placeholder = "Enter item", + elements = [], + setElements, + validation, + validationMessage, + }, ) => { const [newElement, setNewElement] = useState("") const [warnEmpty, setWarnEmpty] = useState(false) + const [warnValidation, setWarnValidation] = useState(null) // Validation warning state - /** - * Remove an element from the list by index - * @param index - The index of the element to remove - */ const removeElement = (index: number): void => { setElements(elements.filter((_, i) => i !== index)) } - /** - * Add a new element to the list - */ + // Add a new element to the list const addElement = (): void => { if (!newElement.trim()) { setWarnEmpty(true) + setWarnValidation(null) + } else if (validation && !validation.test(newElement.trim())) { + setWarnValidation(validationMessage || "Invalid input format") + setWarnEmpty(false) } else { setElements([...elements, newElement.trim()]) setWarnEmpty(false) + setWarnValidation(null) setNewElement("") } } + // Handle Enter/Return key press to add element + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Enter") { + addElement() + } + } + return (
@@ -48,6 +63,7 @@ export const EditList: React.FC = ( placeholder={placeholder} value={newElement} onChange={(e) => setNewElement(e.target.value)} + onKeyDown={handleKeyDown} />
- {warnEmpty && ( - The input cannot be empty + {/* Show empty value warning */} + {warnEmpty && currentValue === "" && ( + {warnEmpty} + )} + {/* Show validation error */} + {warnValidation && ( + {warnValidation} )}
) diff --git a/packages/openneuro-app/src/scripts/users/components/editable-content.tsx b/packages/openneuro-app/src/scripts/users/components/editable-content.tsx index 00fd379e8f..6bc25a6ffb 100644 --- a/packages/openneuro-app/src/scripts/users/components/editable-content.tsx +++ b/packages/openneuro-app/src/scripts/users/components/editable-content.tsx @@ -11,6 +11,8 @@ interface EditableContentProps { setRows: React.Dispatch> className: string heading: string + validation?: RegExp + validationMessage?: string } export const EditableContent: React.FC = ({ @@ -18,15 +20,32 @@ export const EditableContent: React.FC = ({ setRows, className, heading, + validation, + validationMessage, }) => { const [editing, setEditing] = useState(false) + const [warning, setWarning] = useState(null) + const closeEditing = () => { + setEditing(false) + setWarning(null) + } + + // Function to handle validation of user input + const handleValidation = (value: string): boolean => { + if (validation && !validation.test(value)) { + setWarning(validationMessage || "Invalid input") + return false + } + setWarning(null) + return true + } return (

{heading}

{editing - ? setEditing(false)} /> + ? : setEditing(true)} />}
{editing @@ -40,15 +59,21 @@ export const EditableContent: React.FC = ({ setElements={setRows as React.Dispatch< React.SetStateAction >} + validation={validation} + validationMessage={validationMessage} /> ) : ( - >} + setValue={(newValue: string) => { + if (handleValidation(newValue)) { + setRows(newValue) + } + }} placeholder="Edit content" + closeEditing={closeEditing} + warning={warning} /> )} diff --git a/packages/openneuro-app/src/scripts/users/user-account-view.tsx b/packages/openneuro-app/src/scripts/users/user-account-view.tsx index cceec2a09b..039426b6f2 100644 --- a/packages/openneuro-app/src/scripts/users/user-account-view.tsx +++ b/packages/openneuro-app/src/scripts/users/user-account-view.tsx @@ -24,9 +24,32 @@ export const UserAccountView: React.FC = ({ user }) => { ) const [updateUser] = useMutation(UPDATE_USER) + const handleLinksChange = async (newLinks: string[]) => { + setLinks(newLinks) + console.log("Updating links:", newLinks) + + try { + const result = await updateUser({ + variables: { + id: user.orcid, + links: newLinks, + }, + refetchQueries: [ + { + query: GET_USER_BY_ORCID, + variables: { id: user.orcid }, + }, + ], + }) + console.log("Links mutation result:", result) + } catch (error) { + console.error("Failed to update links:", error) + } + } + const handleLocationChange = async (newLocation: string) => { setLocation(newLocation) - console.log("Updating location:", newLocation) // Log the location to check + console.log("Updating location:", newLocation) try { const result = await updateUser({ @@ -37,13 +60,36 @@ export const UserAccountView: React.FC = ({ user }) => { refetchQueries: [ { query: GET_USER_BY_ORCID, - variables: { userId: user.orcid }, + variables: { id: user.orcid }, }, ], }) - console.log("Mutation result:", result) // Log mutation result + console.log("Location mutation result:", result) } catch (error) { - console.error("Failed to update user:", error) + console.error("Failed to update location:", error) + } + } + + const handleInstitutionChange = async (newInstitution: string) => { + setInstitution(newInstitution) + console.log("Updating institution:", newInstitution) + + try { + const result = await updateUser({ + variables: { + id: user.orcid, + institution: newInstitution, + }, + refetchQueries: [ + { + query: GET_USER_BY_ORCID, + variables: { id: user.orcid }, + }, + ], + }) + console.log("Institution mutation result:", result) + } catch (error) { + console.error("Failed to update institution:", error) } } @@ -66,19 +112,22 @@ export const UserAccountView: React.FC = ({ user }) => { {user.github ? (
  • - github: + GitHub: {user.github}
  • ) - :
  • Connect your github
  • } + :
  • Connect your GitHub
  • } + = ({ user }) => { /> - setInstitution(newInstitution)} + setRows={handleInstitutionChange} className="custom-class" heading="Institution" /> diff --git a/packages/openneuro-app/src/scripts/users/user-query.tsx b/packages/openneuro-app/src/scripts/users/user-query.tsx index 248a285a68..ecd027c203 100644 --- a/packages/openneuro-app/src/scripts/users/user-query.tsx +++ b/packages/openneuro-app/src/scripts/users/user-query.tsx @@ -14,15 +14,20 @@ export const GET_USER_BY_ORCID = gql` orcid email avatar + location + institution + links } } ` export const UPDATE_USER = gql` -mutation updateUser($id: ID!, $location: String) { - updateUser(id: $id, location: $location) { +mutation updateUser($id: ID!, $location: String, $links: [String], $institution: String) { + updateUser(id: $id, location: $location, links: $links, institution: $institution) { id location + links + institution } } ` From 172d4ad3365a964f0968a2e3ec0b3d3d5b016908 Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 15:27:39 -0800 Subject: [PATCH 07/14] update testing specs --- .../__tests__/user-account-view.spec.tsx | 134 ++++++++++++++---- .../users/__tests__/user-routes.spec.tsx | 37 ++++- .../users/components/editable-content.tsx | 4 +- .../src/scripts/users/user-account-view.tsx | 3 + 4 files changed, 144 insertions(+), 34 deletions(-) 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 index e373825558..c990e5c45f 100644 --- 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 @@ -1,4 +1,5 @@ import React from "react" +import { MockedProvider } from "@apollo/client/testing" import { fireEvent, render, @@ -7,8 +8,10 @@ import { within, } from "@testing-library/react" import { UserAccountView } from "../user-account-view" +import { GET_USER_BY_ORCID, UPDATE_USER } from "../user-query" const baseUser = { + id: "1", name: "John Doe", email: "johndoe@example.com", orcid: "0000-0001-2345-6789", @@ -18,61 +21,132 @@ const baseUser = { github: "johndoe", } +const mocks = [ + { + request: { + query: GET_USER_BY_ORCID, + variables: { userId: baseUser.id }, + }, + result: { + data: { + user: baseUser, + }, + }, + }, + { + request: { + query: UPDATE_USER, + variables: { + id: baseUser.id, + location: "Marin, CA", + links: ["https://newlink.com"], + institution: "New University", + }, + }, + result: { + data: { + updateUser: { + id: baseUser.id, + location: "Marin, CA", + links: ["https://newlink.com"], + institution: "New University", + }, + }, + }, + }, +] + describe("", () => { it("should render the user details correctly", () => { - render() - - // Check if user details are rendered + render( + + + , + ) 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("GitHub:")).toBeInTheDocument() expect(screen.getByText("johndoe")).toBeInTheDocument() }) - it("should render links with EditableContent", async () => { - render() - const institutionSection = within( - screen.getByText("Institution").closest(".user-meta-block"), + it("should render location with EditableContent", async () => { + render( + + + , ) + const locationSection = within(screen.getByTestId("location-section")) + 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") + fireEvent.click(saveButton) + await waitFor(() => { + expect(locationSection.getByText("Marin, CA")).toBeInTheDocument() + }) + }) + + it("should render institution with EditableContent", async () => { + render( + + + , + ) + const institutionSection = within(screen.getByTestId("institution-section")) 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(() => + 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"), + it("should render links with EditableContent and validation", async () => { + render( + + + , ) - expect(screen.getByText("Location")).toBeInTheDocument() - const editButton = locationSection.getByText("Edit") + const linksSection = within(screen.getByTestId("links-section")) + expect(screen.getByText("Links")).toBeInTheDocument() + const editButton = linksSection.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") + const textbox = linksSection.getByRole("textbox") + fireEvent.change(textbox, { target: { value: "https://newlink.com" } }) + const saveButton = linksSection.getByText("Add") 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() + await waitFor(() => { + expect(linksSection.getByText("https://newlink.com")).toBeInTheDocument() + }) + }) + + it("should show an error message when invalid URL is entered in links section", async () => { + render( + + + , ) + const linksSection = within(screen.getByTestId("links-section")) + const editButton = linksSection.getByText("Edit") + fireEvent.click(editButton) + const textbox = linksSection.getByRole("textbox") + fireEvent.change(textbox, { target: { value: "invalid-url" } }) + const saveButton = linksSection.getByText("Add") + fireEvent.click(saveButton) + await waitFor(() => { + expect( + linksSection.getByText("Invalid URL format. Please use a valid link."), + ).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 index a2fb9fed72..e98f8c1331 100644 --- a/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx +++ b/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx @@ -1,8 +1,10 @@ import React from "react" import { cleanup, render, screen } from "@testing-library/react" import { MemoryRouter } from "react-router-dom" +import { MockedProvider } from "@apollo/client/testing" import { UserRoutes } from "../user-routes" import type { User } from "../user-routes" +import { GET_USER_BY_ORCID, UPDATE_USER } from "../user-query" const defaultUser: User = { id: "1", @@ -16,11 +18,40 @@ const defaultUser: User = { links: [], } +const mocks = [ + { + request: { + query: UPDATE_USER, + variables: { + 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: [], + }, + }, + result: { + data: { + updateUser: { + id: "1", + name: "John Doe", + }, + }, + }, + }, +] + const renderWithRouter = (user: User, route: string, hasEdit: boolean) => { return render( - - - , + + + + + , ) } diff --git a/packages/openneuro-app/src/scripts/users/components/editable-content.tsx b/packages/openneuro-app/src/scripts/users/components/editable-content.tsx index 6bc25a6ffb..225fadbca3 100644 --- a/packages/openneuro-app/src/scripts/users/components/editable-content.tsx +++ b/packages/openneuro-app/src/scripts/users/components/editable-content.tsx @@ -13,6 +13,7 @@ interface EditableContentProps { heading: string validation?: RegExp validationMessage?: string + "data-testid"?: string // Add the test ID prop to the interface } export const EditableContent: React.FC = ({ @@ -22,6 +23,7 @@ export const EditableContent: React.FC = ({ heading, validation, validationMessage, + "data-testid": testId, // Destructure the data-testid prop }) => { const [editing, setEditing] = useState(false) const [warning, setWarning] = useState(null) @@ -41,7 +43,7 @@ export const EditableContent: React.FC = ({ } return ( -
    +

    {heading}

    {editing diff --git a/packages/openneuro-app/src/scripts/users/user-account-view.tsx b/packages/openneuro-app/src/scripts/users/user-account-view.tsx index 039426b6f2..6ea3cb90a5 100644 --- a/packages/openneuro-app/src/scripts/users/user-account-view.tsx +++ b/packages/openneuro-app/src/scripts/users/user-account-view.tsx @@ -126,6 +126,7 @@ export const UserAccountView: React.FC = ({ user }) => { heading="Links" validation={/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/} // URL validation regex validationMessage="Invalid URL format. Please use a valid link." + data-testid="links-section" /> = ({ user }) => { setRows={handleLocationChange} className="custom-class" heading="Location" + data-testid="location-section" />
    ) From 90d9b8e9711d3e58fc9b895c7861e17f873af4fe Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 15:38:25 -0800 Subject: [PATCH 08/14] updates after linting --- .../users/__tests__/user-routes.spec.tsx | 2 +- .../src/scripts/users/user-account-view.tsx | 26 +++++++------------ 2 files changed, 11 insertions(+), 17 deletions(-) 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 index e98f8c1331..2e3bad3e43 100644 --- a/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx +++ b/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx @@ -4,7 +4,7 @@ import { MemoryRouter } from "react-router-dom" import { MockedProvider } from "@apollo/client/testing" import { UserRoutes } from "../user-routes" import type { User } from "../user-routes" -import { GET_USER_BY_ORCID, UPDATE_USER } from "../user-query" +import { UPDATE_USER } from "../user-query" const defaultUser: User = { id: "1", diff --git a/packages/openneuro-app/src/scripts/users/user-account-view.tsx b/packages/openneuro-app/src/scripts/users/user-account-view.tsx index 6ea3cb90a5..0a75727d60 100644 --- a/packages/openneuro-app/src/scripts/users/user-account-view.tsx +++ b/packages/openneuro-app/src/scripts/users/user-account-view.tsx @@ -26,10 +26,8 @@ export const UserAccountView: React.FC = ({ user }) => { const handleLinksChange = async (newLinks: string[]) => { setLinks(newLinks) - console.log("Updating links:", newLinks) - try { - const result = await updateUser({ + await updateUser({ variables: { id: user.orcid, links: newLinks, @@ -41,18 +39,16 @@ export const UserAccountView: React.FC = ({ user }) => { }, ], }) - console.log("Links mutation result:", result) - } catch (error) { - console.error("Failed to update links:", error) + } catch { + // Error handling can be implemented here if needed } } const handleLocationChange = async (newLocation: string) => { setLocation(newLocation) - console.log("Updating location:", newLocation) try { - const result = await updateUser({ + await updateUser({ variables: { id: user.orcid, location: newLocation, @@ -64,18 +60,16 @@ export const UserAccountView: React.FC = ({ user }) => { }, ], }) - console.log("Location mutation result:", result) - } catch (error) { - console.error("Failed to update location:", error) + } catch { + // Error handling can be implemented here if needed } } const handleInstitutionChange = async (newInstitution: string) => { setInstitution(newInstitution) - console.log("Updating institution:", newInstitution) try { - const result = await updateUser({ + await updateUser({ variables: { id: user.orcid, institution: newInstitution, @@ -87,9 +81,8 @@ export const UserAccountView: React.FC = ({ user }) => { }, ], }) - console.log("Institution mutation result:", result) - } catch (error) { - console.error("Failed to update institution:", error) + } catch { + // Error handling can be implemented here if needed } } @@ -124,6 +117,7 @@ export const UserAccountView: React.FC = ({ user }) => { setRows={handleLinksChange} className="custom-class" heading="Links" + // eslint-disable-next-line no-useless-escape validation={/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/} // URL validation regex validationMessage="Invalid URL format. Please use a valid link." data-testid="links-section" From f660798d8530473c3490e861fcb05908892cff34 Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 15:43:36 -0800 Subject: [PATCH 09/14] removing an unused prop --- .../src/scripts/users/components/editable-content.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/openneuro-app/src/scripts/users/components/editable-content.tsx b/packages/openneuro-app/src/scripts/users/components/editable-content.tsx index 225fadbca3..d605c25acf 100644 --- a/packages/openneuro-app/src/scripts/users/components/editable-content.tsx +++ b/packages/openneuro-app/src/scripts/users/components/editable-content.tsx @@ -75,7 +75,6 @@ export const EditableContent: React.FC = ({ }} placeholder="Edit content" closeEditing={closeEditing} - warning={warning} /> )} From b1628d4f5d3d211de0ba18a7f7a76d0b3b887b73 Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 15:53:20 -0800 Subject: [PATCH 10/14] removing some comments --- .../scripts/users/components/edit-list.tsx | 6 ++-- .../scripts/users/components/edit-string.tsx | 36 +++++++++---------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/openneuro-app/src/scripts/users/components/edit-list.tsx b/packages/openneuro-app/src/scripts/users/components/edit-list.tsx index fad2e3814c..10341cc5b2 100644 --- a/packages/openneuro-app/src/scripts/users/components/edit-list.tsx +++ b/packages/openneuro-app/src/scripts/users/components/edit-list.tsx @@ -6,8 +6,8 @@ interface EditListProps { placeholder?: string elements?: string[] setElements: (elements: string[]) => void - validation?: RegExp // Validation regex prop - validationMessage?: string // Validation message prop + validation?: RegExp + validationMessage?: string } /** @@ -25,7 +25,7 @@ export const EditList: React.FC = ( ) => { const [newElement, setNewElement] = useState("") const [warnEmpty, setWarnEmpty] = useState(false) - const [warnValidation, setWarnValidation] = useState(null) // Validation warning state + const [warnValidation, setWarnValidation] = useState(null) const removeElement = (index: number): void => { setElements(elements.filter((_, i) => i !== index)) diff --git a/packages/openneuro-app/src/scripts/users/components/edit-string.tsx b/packages/openneuro-app/src/scripts/users/components/edit-string.tsx index 0aae9f2e17..39d3fd2b75 100644 --- a/packages/openneuro-app/src/scripts/users/components/edit-string.tsx +++ b/packages/openneuro-app/src/scripts/users/components/edit-string.tsx @@ -7,28 +7,26 @@ interface EditStringProps { setValue: (value: string) => void placeholder?: string closeEditing: () => void - validation?: RegExp // New validation prop - validationMessage?: string // New validation message prop + validation?: RegExp + validationMessage?: string } -export const EditString: React.FC = ( - { - value = "", - setValue, - placeholder = "Enter text", - closeEditing, - validation, - validationMessage, - }, -) => { +export const EditString: React.FC = ({ + value = "", + setValue, + placeholder = "Enter text", + closeEditing, + validation, + validationMessage, +}) => { const [currentValue, setCurrentValue] = useState(value) const [warnEmpty, setWarnEmpty] = useState(null) - const [warnValidation, setWarnValidation] = useState(null) // State for validation warning + const [warnValidation, setWarnValidation] = useState(null) useEffect(() => { - if (value !== "" && currentValue === "") { + if (currentValue === "") { setWarnEmpty( - "Your input is empty. This will delete the previously saved value..", + "Your input is empty. This will delete the previously saved value.", ) } else { setWarnEmpty(null) @@ -39,16 +37,16 @@ export const EditString: React.FC = ( } else { setWarnValidation(null) } - }, [currentValue, value, validation, validationMessage]) + }, [currentValue, validation, validationMessage]) const handleSave = (): void => { - if (!warnValidation && currentValue.trim() !== "") { - setValue(currentValue.trim()) + // Allow saving even when the input is empty + if (!warnValidation) { + setValue(currentValue.trim()) // Trim whitespace but allow empty string closeEditing() } } - // Handle Enter key press for saving const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault() From cbc7b7f98d30d33932184f9bde8906103a8aceb7 Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 16:01:22 -0800 Subject: [PATCH 11/14] removing unused warning feature --- .../scripts/users/components/edit-string.tsx | 45 +++++++++++++------ .../users/components/editable-content.tsx | 11 +++-- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/packages/openneuro-app/src/scripts/users/components/edit-string.tsx b/packages/openneuro-app/src/scripts/users/components/edit-string.tsx index 39d3fd2b75..e45d013e4d 100644 --- a/packages/openneuro-app/src/scripts/users/components/edit-string.tsx +++ b/packages/openneuro-app/src/scripts/users/components/edit-string.tsx @@ -11,42 +11,59 @@ interface EditStringProps { validationMessage?: string } -export const EditString: React.FC = ({ - value = "", - setValue, - placeholder = "Enter text", - closeEditing, - validation, - validationMessage, -}) => { +import React, { useEffect, useState } from "react" +import { Button } from "@openneuro/components/button" +import "../scss/user-meta-blocks.scss" + +interface EditStringProps { + value?: string + setValue: (value: string) => void + placeholder?: string + closeEditing: () => void + validation?: RegExp + validationMessage?: string +} + +export const EditString: React.FC = ( + { + value = "", + setValue, + placeholder = "Enter text", + closeEditing, + validation, + validationMessage, + }, +) => { const [currentValue, setCurrentValue] = useState(value) const [warnEmpty, setWarnEmpty] = useState(null) const [warnValidation, setWarnValidation] = useState(null) useEffect(() => { - if (currentValue === "") { + // Show warning only if there was an initial value and it was deleted + if (value !== "" && currentValue === "") { setWarnEmpty( - "Your input is empty. This will delete the previously saved value.", + "Your input is empty. This will delete the previously saved value..", ) } else { setWarnEmpty(null) } + // Validation logic if (validation && currentValue && !validation.test(currentValue)) { setWarnValidation(validationMessage || "Invalid input") } else { setWarnValidation(null) } - }, [currentValue, validation, validationMessage]) + }, [currentValue, value, validation, validationMessage]) const handleSave = (): void => { - // Allow saving even when the input is empty if (!warnValidation) { - setValue(currentValue.trim()) // Trim whitespace but allow empty string + setValue(currentValue.trim()) closeEditing() } } + // Handle Enter key press for saving const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault() @@ -73,7 +90,7 @@ export const EditString: React.FC = ({ onClick={handleSave} />
    - {/* Show empty value warning */} + {/* Show empty value warning only if content was deleted */} {warnEmpty && currentValue === "" && ( {warnEmpty} )} diff --git a/packages/openneuro-app/src/scripts/users/components/editable-content.tsx b/packages/openneuro-app/src/scripts/users/components/editable-content.tsx index d605c25acf..68f98d6f5d 100644 --- a/packages/openneuro-app/src/scripts/users/components/editable-content.tsx +++ b/packages/openneuro-app/src/scripts/users/components/editable-content.tsx @@ -13,7 +13,7 @@ interface EditableContentProps { heading: string validation?: RegExp validationMessage?: string - "data-testid"?: string // Add the test ID prop to the interface + "data-testid"?: string } export const EditableContent: React.FC = ({ @@ -23,22 +23,19 @@ export const EditableContent: React.FC = ({ heading, validation, validationMessage, - "data-testid": testId, // Destructure the data-testid prop + "data-testid": testId, }) => { const [editing, setEditing] = useState(false) - const [warning, setWarning] = useState(null) + const closeEditing = () => { setEditing(false) - setWarning(null) } // Function to handle validation of user input const handleValidation = (value: string): boolean => { if (validation && !validation.test(value)) { - setWarning(validationMessage || "Invalid input") return false } - setWarning(null) return true } @@ -75,6 +72,8 @@ export const EditableContent: React.FC = ({ }} placeholder="Edit content" closeEditing={closeEditing} + validation={validation} + validationMessage={validationMessage} /> )} From 87079e089ccedd9abf40754221e4e834bf599daa Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Thu, 9 Jan 2025 16:05:33 -0800 Subject: [PATCH 12/14] fxing import issue --- .../src/scripts/users/components/edit-string.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/openneuro-app/src/scripts/users/components/edit-string.tsx b/packages/openneuro-app/src/scripts/users/components/edit-string.tsx index e45d013e4d..244a330c8c 100644 --- a/packages/openneuro-app/src/scripts/users/components/edit-string.tsx +++ b/packages/openneuro-app/src/scripts/users/components/edit-string.tsx @@ -11,19 +11,6 @@ interface EditStringProps { validationMessage?: string } -import React, { useEffect, useState } from "react" -import { Button } from "@openneuro/components/button" -import "../scss/user-meta-blocks.scss" - -interface EditStringProps { - value?: string - setValue: (value: string) => void - placeholder?: string - closeEditing: () => void - validation?: RegExp - validationMessage?: string -} - export const EditString: React.FC = ( { value = "", From b6a52ccb3aad2e21bfa827ce6d0a493eba10322b Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Mon, 13 Jan 2025 10:54:05 -0800 Subject: [PATCH 13/14] adding hasEdit and logged out conditions --- packages/openneuro-app/src/scripts/routes.tsx | 10 +++++++++- .../openneuro-app/src/scripts/users/user-query.tsx | 10 ++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/openneuro-app/src/scripts/routes.tsx b/packages/openneuro-app/src/scripts/routes.tsx index 4c1783a68a..050ed5723d 100644 --- a/packages/openneuro-app/src/scripts/routes.tsx +++ b/packages/openneuro-app/src/scripts/routes.tsx @@ -18,6 +18,7 @@ import { ImportDataset } from "./pages/import-dataset" import { DatasetMetadata } from "./pages/metadata/dataset-metadata" import { TermsPage } from "./pages/terms" import { UserQuery } from "./users/user-query" +import LoggedIn from "../scripts/authentication/logged-in" const AppRoutes: React.VoidFunctionComponent = () => ( @@ -34,7 +35,14 @@ const AppRoutes: React.VoidFunctionComponent = () => ( } /> } /> } /> - } /> + + + + } + /> } diff --git a/packages/openneuro-app/src/scripts/users/user-query.tsx b/packages/openneuro-app/src/scripts/users/user-query.tsx index ecd027c203..d7321d75aa 100644 --- a/packages/openneuro-app/src/scripts/users/user-query.tsx +++ b/packages/openneuro-app/src/scripts/users/user-query.tsx @@ -4,6 +4,9 @@ import { UserRoutes } from "./user-routes" import FourOFourPage from "../errors/404page" import { isValidOrcid } from "../utils/validationUtils" import { gql, useQuery } from "@apollo/client" +import { isAdmin } from "../authentication/admin-user" +import { useCookies } from "react-cookie" +import { getProfile } from "../authentication/profile" // GraphQL query to fetch user by ORCID export const GET_USER_BY_ORCID = gql` @@ -52,6 +55,9 @@ export const UserQuery: React.FC = () => { skip: !isOrcidValid, }) + const [cookies] = useCookies() + const profile = getProfile(cookies) + if (!isOrcidValid) { return } @@ -62,8 +68,8 @@ export const UserQuery: React.FC = () => { return } - // Assuming 'hasEdit' is true for now (you can modify this based on your logic) - const hasEdit = true + // is admin or profile matches id from the user data being returned + const hasEdit = isAdmin || data.user.id !== profile.sub ? true : false // Render user data with UserRoutes return From b96c9dd96363be0f89f6e73f44d3e512bce6c3d9 Mon Sep 17 00:00:00 2001 From: Gregory Noack Date: Mon, 13 Jan 2025 14:52:12 -0800 Subject: [PATCH 14/14] update to routes - adding fourothree to user route if logged out --- packages/openneuro-app/src/scripts/routes.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/openneuro-app/src/scripts/routes.tsx b/packages/openneuro-app/src/scripts/routes.tsx index 050ed5723d..971669d870 100644 --- a/packages/openneuro-app/src/scripts/routes.tsx +++ b/packages/openneuro-app/src/scripts/routes.tsx @@ -19,6 +19,8 @@ import { DatasetMetadata } from "./pages/metadata/dataset-metadata" import { TermsPage } from "./pages/terms" import { UserQuery } from "./users/user-query" import LoggedIn from "../scripts/authentication/logged-in" +import LoggedOut from "../scripts/authentication/logged-out" +import FourOThreePage from "./errors/403page" const AppRoutes: React.VoidFunctionComponent = () => ( @@ -38,9 +40,14 @@ const AppRoutes: React.VoidFunctionComponent = () => ( - - + <> + + + + + + + } />