diff --git a/packages/openneuro-app/src/scripts/components/button/button.scss b/packages/openneuro-app/src/scripts/components/button/button.scss index c1154040a..1764dc501 100644 --- a/packages/openneuro-app/src/scripts/components/button/button.scss +++ b/packages/openneuro-app/src/scripts/components/button/button.scss @@ -10,6 +10,7 @@ line-height: 1.4em; } + .on-button--primary { color: #fff; background-color: var(--current-theme-primary); @@ -40,6 +41,20 @@ border-color: $on-light-green; } } +.on-button--ghost { + color: $on-dark-aqua; + background-color: transparent; + text-transform: uppercase; + transition: background-color 0.3s; + box-shadow:none; + border: 2px solid transparent; + &:hover, + &.active { + background-color: transparent; + color: $on-dark-aqua; + border: 2px solid $on-dark-aqua; + } +} .icon-text { display: flex; diff --git a/packages/openneuro-app/src/scripts/components/select/SelectGroup.tsx b/packages/openneuro-app/src/scripts/components/select/SelectGroup.tsx index b3ef6208d..6d6d40c24 100644 --- a/packages/openneuro-app/src/scripts/components/select/SelectGroup.tsx +++ b/packages/openneuro-app/src/scripts/components/select/SelectGroup.tsx @@ -7,7 +7,7 @@ export interface SelectGroupProps { value: string }[] value: string - setValue: (string) => void + setValue: (value: string) => void label?: string id: string layout: "inline" | "stacked" @@ -19,6 +19,7 @@ export const SelectGroup = ({ label, id, layout, + value, }: SelectGroupProps) => { return ( {label}} diff --git a/packages/openneuro-app/src/scripts/components/select/select.scss b/packages/openneuro-app/src/scripts/components/select/select.scss index 3eb25833d..d616c1db1 100644 --- a/packages/openneuro-app/src/scripts/components/select/select.scss +++ b/packages/openneuro-app/src/scripts/components/select/select.scss @@ -1,4 +1,4 @@ -@import "../scss/variables.scss"; +@import "../../scss/variables.scss"; .on-select-wrapper { label { diff --git a/packages/openneuro-app/src/scripts/contributors/__tests__/contributor-form.spec.tsx b/packages/openneuro-app/src/scripts/contributors/__tests__/contributor-form.spec.tsx new file mode 100644 index 000000000..6cdbd3c7a --- /dev/null +++ b/packages/openneuro-app/src/scripts/contributors/__tests__/contributor-form.spec.tsx @@ -0,0 +1,57 @@ +import React from "react" +import { vi } from "vitest" +import { fireEvent, render, screen } from "@testing-library/react" +import { ContributorFormRow } from "../contributor-form-row" +import type { Contributor } from "../../types/datacite" + +describe("ContributorFormRow", () => { + const mockContributor: Contributor = { + name: "Jane Doe", + givenName: "Jane", + familyName: "Doe", + orcid: "", + contributorType: "Researcher", + order: 1, + } + + const defaultProps = { + contributor: mockContributor, + index: 0, + errors: {}, + onChange: vi.fn(), + onMove: vi.fn(), + onRemove: vi.fn(), + isFirst: true, + isLast: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders contributor name input", () => { + render() + expect(screen.getByPlaceholderText("Name")).toHaveValue("Jane Doe") + }) + + it("calls onChange when name is updated", () => { + render() + const input = screen.getByPlaceholderText("Name") + fireEvent.change(input, { target: { value: "New Name" } }) + expect(defaultProps.onChange).toHaveBeenCalledWith(0, "name", "New Name") + }) + + it("calls onMove when up or down buttons are clicked", () => { + render() + const upButton = screen.getByText("↑") + fireEvent.click(upButton) + expect(defaultProps.onMove).toHaveBeenCalledWith(0, "up") + }) + + it("calls onRemove when trash button is clicked", () => { + render() + const trashButton = screen.getByRole("button", { name: "" }) + fireEvent.click(trashButton) + expect(defaultProps.onRemove).toHaveBeenCalledWith(0) + }) +}) diff --git a/packages/openneuro-app/src/scripts/contributors/__tests__/contributor-list.spec.tsx b/packages/openneuro-app/src/scripts/contributors/__tests__/contributor-list.spec.tsx new file mode 100644 index 000000000..06a0a2223 --- /dev/null +++ b/packages/openneuro-app/src/scripts/contributors/__tests__/contributor-list.spec.tsx @@ -0,0 +1,92 @@ +import React from "react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { MockedProvider } from "@apollo/client/testing" +import { ContributorsListDisplay } from "../contributors-list" +import type { Contributor } from "../../types/datacite" + +describe("ContributorsListDisplay", () => { + const baseContributors: Contributor[] = [ + { + name: "Jane Doe", + givenName: "Jane", + familyName: "Doe", + orcid: "", + contributorType: "Researcher", + order: 1, + }, + { + name: "John Smith", + givenName: "John", + familyName: "Smith", + orcid: "0000-0000-0000-0000", + contributorType: "DataCollector", + order: 2, + }, + ] + + const renderComponent = (props = {}) => { + return render( + + + , + ) + } + + it("renders contributors list", async () => { + renderComponent() + expect(await screen.findByText(/Jane Doe/)).toBeInTheDocument() + expect(screen.getByText(/John Smith/)).toBeInTheDocument() + }) + + it("shows edit button when editable", async () => { + renderComponent() + expect(await screen.findByText("Edit")).toBeInTheDocument() + }) + + it("enters edit mode when edit button clicked", async () => { + renderComponent() + + const editButton = await screen.findByText("Edit") + fireEvent.click(editButton) + + const nameInputs = await screen.findAllByPlaceholderText("Name") + expect(nameInputs.length).toBe(2) + expect(nameInputs[0]).toHaveValue("Jane Doe") + expect(nameInputs[1]).toHaveValue("John Smith") + }) + + it("adds a new contributor when 'Add Contributor' clicked", async () => { + renderComponent() + const editButton = await screen.findByText("Edit") + fireEvent.click(editButton) + + const addButton = await screen.findByText("Add Contributor") + fireEvent.click(addButton) + + expect(await screen.findAllByPlaceholderText("Name")).toHaveLength(3) + }) + + it("shows an error when trying to submit with empty name", async () => { + renderComponent() + + const editButton = await screen.findByText("Edit") + fireEvent.click(editButton) + + const addButton = await screen.findByText("Add Contributor") + fireEvent.click(addButton) + + const saveButton = await screen.findByText("Save") + fireEvent.click(saveButton) + + // Check that the new field has the browser validation pseudo-state + const nameInputs = await screen.findAllByPlaceholderText("Name") + + expect(nameInputs[2]).toBeInvalid() + expect(nameInputs[2]).toBeRequired() + }) +}) diff --git a/packages/openneuro-app/src/scripts/contributors/__tests__/contributor-utils.spec.tsx b/packages/openneuro-app/src/scripts/contributors/__tests__/contributor-utils.spec.tsx new file mode 100644 index 000000000..89a9031ff --- /dev/null +++ b/packages/openneuro-app/src/scripts/contributors/__tests__/contributor-utils.spec.tsx @@ -0,0 +1,27 @@ +import { cloneContributor, CONTRIBUTOR_TYPES } from "../contributor-utils" +import type { Contributor } from "../../types/datacite" + +describe("contributor-utils", () => { + it("clones a contributor deeply", () => { + const contributor: Contributor = { + name: "Jane Doe", + givenName: "Jane", + familyName: "Doe", + orcid: "0000-0000-0000-0000", + contributorType: "Researcher", + order: 1, + } + + const cloned = cloneContributor(contributor) + expect(cloned).toEqual(contributor) + expect(cloned).not.toBe(contributor) + }) + + it("includes Researcher as a valid contributor type", () => { + expect(CONTRIBUTOR_TYPES).toContain("Researcher") + }) + + it("contains at least one contributor type", () => { + expect(CONTRIBUTOR_TYPES.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/openneuro-app/src/scripts/users/__tests__/contributor.spec.tsx b/packages/openneuro-app/src/scripts/contributors/__tests__/contributor.spec.tsx similarity index 84% rename from packages/openneuro-app/src/scripts/users/__tests__/contributor.spec.tsx rename to packages/openneuro-app/src/scripts/contributors/__tests__/contributor.spec.tsx index a85e21999..f8b146754 100644 --- a/packages/openneuro-app/src/scripts/users/__tests__/contributor.spec.tsx +++ b/packages/openneuro-app/src/scripts/contributors/__tests__/contributor.spec.tsx @@ -56,22 +56,28 @@ describe("SingleContributorDisplay - Basic Loading", () => { it("renders the component and displays the contributor name", async () => { const contributor = { name: "Jane Doe", orcid: null } renderComponent({ contributor }) - expect(screen.getByText("Jane Doe")).toBeInTheDocument() + + // wait for async rendering + expect(await screen.findByText("Jane Doe")).toBeInTheDocument() }) it("renders 'Unknown Contributor' if the contributor name is missing", async () => { const contributor = { name: undefined, orcid: null } renderComponent({ contributor }) - expect(screen.getByText("Unknown Contributor")).toBeInTheDocument() + + expect(await screen.findByText("Unknown Contributor")).toBeInTheDocument() }) - it("renders the component and displays the ORCID link if an ID is provided", async () => { + it("renders the ORCID link if an ID is provided", async () => { const testOrcid = "0000-0000-0000-0000" const contributor = { name: "Author With ORCID", orcid: testOrcid } renderComponent({ contributor }) - const orcidLink = screen.getByLabelText( - `ORCID profile for ${contributor.name}`, - ) + + // check link by role + const orcidLink = await screen.findByRole("link", { + name: /ORCID profile for Author With ORCID/, + }) + expect(orcidLink).toBeInTheDocument() expect(orcidLink).toHaveAttribute( "href", diff --git a/packages/openneuro-app/src/scripts/contributors/contributor-form-row.tsx b/packages/openneuro-app/src/scripts/contributors/contributor-form-row.tsx new file mode 100644 index 000000000..bc73c8086 --- /dev/null +++ b/packages/openneuro-app/src/scripts/contributors/contributor-form-row.tsx @@ -0,0 +1,86 @@ +import React from "react" +import type { Contributor } from "../types/datacite" +import { SelectGroup } from "../components/select/SelectGroup" +import { CONTRIBUTOR_TYPES } from "./contributor-utils" + +interface ContributorFormRowProps { + contributor: Contributor + index: number + errors: Record + onChange: (index: number, field: keyof Contributor, value: string) => void + onMove: (index: number, direction: "up" | "down") => void + onRemove: (index: number) => void + isFirst: boolean + isLast: boolean +} + +export const ContributorFormRow: React.FC = ({ + contributor, + index, + errors, + onChange, + onMove, + onRemove, + isFirst, + isLast, +}) => ( +
+
+ + +
+ +
+ onChange(index, "name", e.target.value)} + style={{ + borderColor: errors[index] ? "red" : undefined, + borderWidth: errors[index] ? 2 : undefined, + }} + required + /> + {errors[index] && ( + {errors[index]} + )} +
+ + ({ label: t, value: t }))} + value={contributor.contributorType?.trim() ?? ""} + setValue={(v) => onChange(index, "contributorType", v)} + /> + + +
+) diff --git a/packages/openneuro-app/src/scripts/contributors/contributor-utils.ts b/packages/openneuro-app/src/scripts/contributors/contributor-utils.ts new file mode 100644 index 000000000..6ccc3be1b --- /dev/null +++ b/packages/openneuro-app/src/scripts/contributors/contributor-utils.ts @@ -0,0 +1,29 @@ +import type { Contributor } from "../types/datacite" + +export const CONTRIBUTOR_TYPES = [ + "ContactPerson", + "DataCollector", + "DataCurator", + "DataManager", + "Distributor", + "Editor", + "HostingInstitution", + "Producer", + "ProjectLeader", + "ProjectManager", + "ProjectMember", + "RegistrationAgency", + "RegistrationAuthority", + "RelatedPerson", + "Researcher", + "ResearchGroup", + "RightsHolder", + "Sponsor", + "Supervisor", + "WorkPackageLeader", + "Other", +] + +// Utility to deep clone a contributor +export const cloneContributor = (c: Contributor): Contributor => + structuredClone(c) diff --git a/packages/openneuro-app/src/scripts/contributors/contributor.tsx b/packages/openneuro-app/src/scripts/contributors/contributor.tsx new file mode 100644 index 000000000..175e78359 --- /dev/null +++ b/packages/openneuro-app/src/scripts/contributors/contributor.tsx @@ -0,0 +1,56 @@ +import React from "react" +import { Link } from "react-router-dom" +import { useUser } from "../queries/user" +import type { Contributor } from "../types/datacite" +import ORCIDiDLogo from "../../assets/ORCIDiD_iconvector.svg" + +interface SingleContributorDisplayProps { + contributor: Contributor + isLast: boolean + separator: React.ReactNode +} + +export const SingleContributorDisplay: React.FC = + ({ + contributor, + isLast, + separator, + }) => { + const { user, loading } = useUser(contributor.orcid || undefined) + const displayName = contributor.name || "Unknown Contributor" + const orcidBaseURL = "https://orcid.org/" + + if (loading) { + return ( + <> + {displayName} (checking user...) + {!isLast && separator} + + ) + } + + const userExists = !!user?.id + // TODO add event to allow user to approve attribution and if userApproved response allow linking to profile + const userApproved = true + return ( + <> + {contributor.orcid && userExists && userApproved + ? {displayName} + : displayName} + {contributor.orcid && userApproved && ( + <> + {" "} + + ORCID logo + + + )} + {!isLast && separator} + + ) + } diff --git a/packages/openneuro-app/src/scripts/contributors/contributors-list.tsx b/packages/openneuro-app/src/scripts/contributors/contributors-list.tsx new file mode 100644 index 000000000..6d908dc1a --- /dev/null +++ b/packages/openneuro-app/src/scripts/contributors/contributors-list.tsx @@ -0,0 +1,239 @@ +import React, { useEffect, useState } from "react" +import { gql, useMutation } from "@apollo/client" +import * as Sentry from "@sentry/react" +import type { Contributor } from "../types/datacite" +import { SingleContributorDisplay } from "./contributor" +import { Loading } from "../components/loading/Loading" +import { ContributorFormRow } from "./contributor-form-row" +import { cloneContributor } from "./contributor-utils" + +interface ContributorsListDisplayProps { + contributors: Contributor[] | null | undefined + separator?: React.ReactNode + datasetId?: string + editable?: boolean +} + +const UPDATE_CONTRIBUTORS = gql` + mutation UpdateContributors($datasetId: String!, $newContributors: [ContributorInput!]!) { + updateContributors(datasetId: $datasetId, newContributors: $newContributors) { + success + dataset { + id + draft { + id + contributors { name givenName familyName orcid contributorType order } + files { id filename key size annexed urls directory } + modified + } + } + } + } +` + +export const ContributorsListDisplay: React.FC = ( + { + contributors, + separator =
, + datasetId, + editable, + }, +) => { + const [isEditing, setIsEditing] = useState(false) + const [editingContributors, setEditingContributors] = useState( + contributors?.map((c) => ({ ...c, order: c.order ?? 0 })) || [], + ) + const [errors, setErrors] = useState>({}) + + useEffect(() => { + if (contributors) { + setEditingContributors( + contributors.map((c) => ({ ...c, order: c.order ?? 0 })), + ) + } + }, [contributors]) + + const [updateContributorsMutation, { loading }] = useMutation( + UPDATE_CONTRIBUTORS, + { + update(cache, { data }) { + const updatedDraft = data?.updateContributors?.dataset?.draft + if (!updatedDraft || !datasetId) return + + const datasetCacheId = cache.identify({ + __typename: "Dataset", + id: datasetId, + }) + if (!datasetCacheId) return + + cache.modify({ + id: datasetCacheId, + fields: { draft: () => ({ ...updatedDraft }) }, + }) + }, + onCompleted(data) { + const updated = data?.updateContributors?.dataset?.draft?.contributors + if (updated) { + setEditingContributors( + updated.map((c) => ({ ...c })).sort((a, b) => + (a.order ?? 0) - (b.order ?? 0) + ), + ) + } + setIsEditing(false) + setErrors({}) + }, + onError(err) { + Sentry.captureException(err) + }, + }, + ) + + const handleChange = ( + index: number, + field: keyof Contributor, + value: string, + ) => { + setEditingContributors((prev) => + prev.map((c, i) => + i === index + ? { ...cloneContributor(c), [field]: value } + : cloneContributor(c) + ) + ) + if (field === "name") { + setErrors((prev) => ({ + ...prev, + ...(value.trim() ? {} : { [index]: "Required" }), + })) + } + } + + const handleAdd = () => + setEditingContributors((prev) => [ + ...prev.map(cloneContributor), + { + name: "", + givenName: "", + familyName: "", + orcid: "", + contributorType: "Researcher", + order: prev.length + 1, + }, + ]) + + const handleRemove = (index: number) => + setEditingContributors((prev) => + prev.filter((_, i) => i !== index).map((c, idx) => ({ + ...cloneContributor(c), + order: idx + 1, + })) + ) + + const handleMove = (index: number, direction: "up" | "down") => + setEditingContributors((prev) => { + const newIndex = direction === "up" ? index - 1 : index + 1 + if (newIndex < 0 || newIndex >= prev.length) { + return prev.map(cloneContributor) + } + const updated = prev.map(cloneContributor) + const [movedItem] = updated.splice(index, 1) + updated.splice(newIndex, 0, movedItem) + return updated.map((c, idx) => ({ ...c, order: idx + 1 })) + }) + + const handleSave = () => { + if (!datasetId) return + const newErrors: Record = {} + editingContributors.forEach((c, idx) => { + if (!c.name.trim()) newErrors[idx] = "Required" + }) + if (Object.keys(newErrors).length > 0) return setErrors(newErrors) + + const cleanContributors = editingContributors.map((c) => ({ + name: c.name.trim(), + givenName: c.givenName || "", + familyName: c.familyName || "", + orcid: c.orcid || "", + contributorType: c.contributorType, + order: c.order, + })) + updateContributorsMutation({ + variables: { datasetId, newContributors: cleanContributors }, + }) + } + + if (!contributors || contributors.length === 0) return <>N/A + + if (isEditing && editable) { + return ( +
+ {loading ? : ( + <> + {editingContributors.map((c, i) => ( + + ))} + + + + )} +
+ ) + } + + return ( + <> +
+ {editable && !isEditing && ( + + )} +
+ {editingContributors + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((c, i) => ( + + ))} + + ) +} diff --git a/packages/openneuro-app/src/scripts/datalad/dataset/dataset-query-fragments.js b/packages/openneuro-app/src/scripts/datalad/dataset/dataset-query-fragments.js index 0be5820f8..0aa0402e1 100644 --- a/packages/openneuro-app/src/scripts/datalad/dataset/dataset-query-fragments.js +++ b/packages/openneuro-app/src/scripts/datalad/dataset/dataset-query-fragments.js @@ -59,17 +59,12 @@ export const DRAFT_FRAGMENT = gql` version } } - creators { - name - givenName - familyName - orcid - } contributors { name givenName familyName orcid + contributorType } } } @@ -247,17 +242,12 @@ export const SNAPSHOT_FIELDS = gql` } ...SnapshotIssues hexsha - creators { - name - givenName - familyName - orcid - } contributors { name givenName familyName orcid + contributorType } } ${SNAPSHOT_ISSUES} diff --git a/packages/openneuro-app/src/scripts/dataset/__tests__/__snapshots__/snapshot-container.spec.tsx.snap b/packages/openneuro-app/src/scripts/dataset/__tests__/__snapshots__/snapshot-container.spec.tsx.snap index 015599fd4..d1a139291 100644 --- a/packages/openneuro-app/src/scripts/dataset/__tests__/__snapshots__/snapshot-container.spec.tsx.snap +++ b/packages/openneuro-app/src/scripts/dataset/__tests__/__snapshots__/snapshot-container.spec.tsx.snap @@ -786,7 +786,7 @@ OCI-1131441 (R. Poldrack, PI) in any publications. N/A

{ - // Mock the login state const cookieObject = new Cookies() cookieObject.set("accessToken", token) return render( @@ -87,7 +95,7 @@ const renderComponent = ( } describe("DraftContainer", () => { - it("renders dataset name and authors", async () => { + it("renders dataset name and contributors as authors", async () => { vi.mock("../../queries/user", async (importOriginal) => { const actual = await importOriginal() return { @@ -109,6 +117,8 @@ describe("DraftContainer", () => { expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent( /Test Dataset Name/, ) + + // Check contributor names expect(await screen.findByText(/Author One/)).toBeInTheDocument() expect(await screen.findByText(/Author Two/)).toBeInTheDocument() @@ -132,6 +142,7 @@ describe("DraftContainer", () => { expect(orcidExternalLink).toHaveAttribute("target", "_blank") expect(orcidExternalLink).toHaveAttribute("rel", "noopener noreferrer") + // Author Two has no ORCID, so no profile link const authorTwoProfileLink = screen.queryByRole("link", { name: /Author Two/i, }) diff --git a/packages/openneuro-app/src/scripts/dataset/comments/__tests__/__snapshots__/comment.spec.jsx.snap b/packages/openneuro-app/src/scripts/dataset/comments/__tests__/__snapshots__/comment.spec.jsx.snap index 918a7210a..1b2e41e8c 100644 --- a/packages/openneuro-app/src/scripts/dataset/comments/__tests__/__snapshots__/comment.spec.jsx.snap +++ b/packages/openneuro-app/src/scripts/dataset/comments/__tests__/__snapshots__/comment.spec.jsx.snap @@ -12,7 +12,11 @@ exports[`Comment component > renders an ORCID user comment 1`] = ` class="row comment-header" > By - Example Exampler + + Example Exampler + renders an ORCID user comment 1`] = ` class="row comment-header" > By - Example Exampler + + Example Exampler + { formatDistanceToNow.mockReturnValueOnce("almost 2 years") const wrapper = render( - , + + + , ) expect(wrapper).toMatchSnapshot() }) @@ -33,19 +36,21 @@ describe("Comment component", () => { formatDistanceToNow.mockReturnValueOnce("almost 2 years") const wrapper = render( - , + + + , ) expect(wrapper).toMatchSnapshot() }) diff --git a/packages/openneuro-app/src/scripts/dataset/components/DatasetEventItem.tsx b/packages/openneuro-app/src/scripts/dataset/components/DatasetEventItem.tsx deleted file mode 100644 index 950864689..000000000 --- a/packages/openneuro-app/src/scripts/dataset/components/DatasetEventItem.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useEffect, useRef } from "react" -import styles from "./scss/dataset-events.module.scss" - -interface Event { - id: string - timestamp: string - note?: string - event: { type: string } - user?: { name?: string; email?: string } -} - -interface DatasetEventItemProps { - event: Event - editingNoteId: string | null - updatedNote: string - startEditingNote: (id: string, note: string) => void - handleUpdateNote: () => void - setUpdatedNote: (note: string) => void -} - -export const DatasetEventItem: React.FC = ({ - event, - editingNoteId, - updatedNote, - startEditingNote, - handleUpdateNote, - setUpdatedNote, -}) => { - const textareaRef = useRef(null) - - // Function to adjust/resize the height - const adjustTextareaHeight = () => { - if (textareaRef.current) { - textareaRef.current.style.height = "auto" - textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px` - } - } - - useEffect(() => { - adjustTextareaHeight() - }, [updatedNote]) - - return ( -
  • -
    -
    - {editingNoteId === event.id - ? ( -