Skip to content

Commit fa5f934

Browse files
authored
Merge branch 'master' into node-24
2 parents c24e296 + d768026 commit fa5f934

File tree

77 files changed

+3993
-1956
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+3993
-1956
lines changed

packages/openneuro-app/src/scripts/components/button/button.scss

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
line-height: 1.4em;
1111
}
1212

13+
1314
.on-button--primary {
1415
color: #fff;
1516
background-color: var(--current-theme-primary);
@@ -40,6 +41,20 @@
4041
border-color: $on-light-green;
4142
}
4243
}
44+
.on-button--ghost {
45+
color: $on-dark-aqua;
46+
background-color: transparent;
47+
text-transform: uppercase;
48+
transition: background-color 0.3s;
49+
box-shadow:none;
50+
border: 2px solid transparent;
51+
&:hover,
52+
&.active {
53+
background-color: transparent;
54+
color: $on-dark-aqua;
55+
border: 2px solid $on-dark-aqua;
56+
}
57+
}
4358

4459
.icon-text {
4560
display: flex;

packages/openneuro-app/src/scripts/components/select/SelectGroup.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface SelectGroupProps {
77
value: string
88
}[]
99
value: string
10-
setValue: (string) => void
10+
setValue: (value: string) => void
1111
label?: string
1212
id: string
1313
layout: "inline" | "stacked"
@@ -19,6 +19,7 @@ export const SelectGroup = ({
1919
label,
2020
id,
2121
layout,
22+
value,
2223
}: SelectGroupProps) => {
2324
return (
2425
<span
@@ -29,11 +30,14 @@ export const SelectGroup = ({
2930
{label && <label htmlFor={id}>{label}</label>}
3031
<select
3132
className="on-select"
32-
onChange={(e) => setValue(e.target.value)}
3333
id={id}
34+
value={value}
35+
onChange={(e) => setValue(e.target.value)}
3436
>
3537
{options.map((item, index) => (
36-
<option key={index} label={item.label} value={item.value} />
38+
<option key={index} value={item.value}>
39+
{item.label}
40+
</option>
3741
))}
3842
</select>
3943
</span>

packages/openneuro-app/src/scripts/components/select/select.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@import "../scss/variables.scss";
1+
@import "../../scss/variables.scss";
22

33
.on-select-wrapper {
44
label {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import React from "react"
2+
import { vi } from "vitest"
3+
import { fireEvent, render, screen } from "@testing-library/react"
4+
import { ContributorFormRow } from "../contributor-form-row"
5+
import type { Contributor } from "../../types/datacite"
6+
7+
describe("ContributorFormRow", () => {
8+
const mockContributor: Contributor = {
9+
name: "Jane Doe",
10+
givenName: "Jane",
11+
familyName: "Doe",
12+
orcid: "",
13+
contributorType: "Researcher",
14+
order: 1,
15+
}
16+
17+
const defaultProps = {
18+
contributor: mockContributor,
19+
index: 0,
20+
errors: {},
21+
onChange: vi.fn(),
22+
onMove: vi.fn(),
23+
onRemove: vi.fn(),
24+
isFirst: true,
25+
isLast: false,
26+
}
27+
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
})
31+
32+
it("renders contributor name input", () => {
33+
render(<ContributorFormRow {...defaultProps} />)
34+
expect(screen.getByPlaceholderText("Name")).toHaveValue("Jane Doe")
35+
})
36+
37+
it("calls onChange when name is updated", () => {
38+
render(<ContributorFormRow {...defaultProps} />)
39+
const input = screen.getByPlaceholderText("Name")
40+
fireEvent.change(input, { target: { value: "New Name" } })
41+
expect(defaultProps.onChange).toHaveBeenCalledWith(0, "name", "New Name")
42+
})
43+
44+
it("calls onMove when up or down buttons are clicked", () => {
45+
render(<ContributorFormRow {...defaultProps} isFirst={false} />)
46+
const upButton = screen.getByText("↑")
47+
fireEvent.click(upButton)
48+
expect(defaultProps.onMove).toHaveBeenCalledWith(0, "up")
49+
})
50+
51+
it("calls onRemove when trash button is clicked", () => {
52+
render(<ContributorFormRow {...defaultProps} />)
53+
const trashButton = screen.getByRole("button", { name: "" })
54+
fireEvent.click(trashButton)
55+
expect(defaultProps.onRemove).toHaveBeenCalledWith(0)
56+
})
57+
})
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React from "react"
2+
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
3+
import { MockedProvider } from "@apollo/client/testing"
4+
import { ContributorsListDisplay } from "../contributors-list"
5+
import type { Contributor } from "../../types/datacite"
6+
7+
describe("ContributorsListDisplay", () => {
8+
const baseContributors: Contributor[] = [
9+
{
10+
name: "Jane Doe",
11+
givenName: "Jane",
12+
familyName: "Doe",
13+
orcid: "",
14+
contributorType: "Researcher",
15+
order: 1,
16+
},
17+
{
18+
name: "John Smith",
19+
givenName: "John",
20+
familyName: "Smith",
21+
orcid: "0000-0000-0000-0000",
22+
contributorType: "DataCollector",
23+
order: 2,
24+
},
25+
]
26+
27+
const renderComponent = (props = {}) => {
28+
return render(
29+
<MockedProvider>
30+
<ContributorsListDisplay
31+
contributors={baseContributors}
32+
datasetId="ds000001"
33+
editable
34+
{...props}
35+
/>
36+
</MockedProvider>,
37+
)
38+
}
39+
40+
it("renders contributors list", async () => {
41+
renderComponent()
42+
expect(await screen.findByText(/Jane Doe/)).toBeInTheDocument()
43+
expect(screen.getByText(/John Smith/)).toBeInTheDocument()
44+
})
45+
46+
it("shows edit button when editable", async () => {
47+
renderComponent()
48+
expect(await screen.findByText("Edit")).toBeInTheDocument()
49+
})
50+
51+
it("enters edit mode when edit button clicked", async () => {
52+
renderComponent()
53+
54+
const editButton = await screen.findByText("Edit")
55+
fireEvent.click(editButton)
56+
57+
const nameInputs = await screen.findAllByPlaceholderText("Name")
58+
expect(nameInputs.length).toBe(2)
59+
expect(nameInputs[0]).toHaveValue("Jane Doe")
60+
expect(nameInputs[1]).toHaveValue("John Smith")
61+
})
62+
63+
it("adds a new contributor when 'Add Contributor' clicked", async () => {
64+
renderComponent()
65+
const editButton = await screen.findByText("Edit")
66+
fireEvent.click(editButton)
67+
68+
const addButton = await screen.findByText("Add Contributor")
69+
fireEvent.click(addButton)
70+
71+
expect(await screen.findAllByPlaceholderText("Name")).toHaveLength(3)
72+
})
73+
74+
it("shows an error when trying to submit with empty name", async () => {
75+
renderComponent()
76+
77+
const editButton = await screen.findByText("Edit")
78+
fireEvent.click(editButton)
79+
80+
const addButton = await screen.findByText("Add Contributor")
81+
fireEvent.click(addButton)
82+
83+
const saveButton = await screen.findByText("Save")
84+
fireEvent.click(saveButton)
85+
86+
// Check that the new field has the browser validation pseudo-state
87+
const nameInputs = await screen.findAllByPlaceholderText("Name")
88+
89+
expect(nameInputs[2]).toBeInvalid()
90+
expect(nameInputs[2]).toBeRequired()
91+
})
92+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { cloneContributor, CONTRIBUTOR_TYPES } from "../contributor-utils"
2+
import type { Contributor } from "../../types/datacite"
3+
4+
describe("contributor-utils", () => {
5+
it("clones a contributor deeply", () => {
6+
const contributor: Contributor = {
7+
name: "Jane Doe",
8+
givenName: "Jane",
9+
familyName: "Doe",
10+
orcid: "0000-0000-0000-0000",
11+
contributorType: "Researcher",
12+
order: 1,
13+
}
14+
15+
const cloned = cloneContributor(contributor)
16+
expect(cloned).toEqual(contributor)
17+
expect(cloned).not.toBe(contributor)
18+
})
19+
20+
it("includes Researcher as a valid contributor type", () => {
21+
expect(CONTRIBUTOR_TYPES).toContain("Researcher")
22+
})
23+
24+
it("contains at least one contributor type", () => {
25+
expect(CONTRIBUTOR_TYPES.length).toBeGreaterThan(0)
26+
})
27+
})

packages/openneuro-app/src/scripts/users/__tests__/contributor.spec.tsx renamed to packages/openneuro-app/src/scripts/contributors/__tests__/contributor.spec.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,22 +56,28 @@ describe("SingleContributorDisplay - Basic Loading", () => {
5656
it("renders the component and displays the contributor name", async () => {
5757
const contributor = { name: "Jane Doe", orcid: null }
5858
renderComponent({ contributor })
59-
expect(screen.getByText("Jane Doe")).toBeInTheDocument()
59+
60+
// wait for async rendering
61+
expect(await screen.findByText("Jane Doe")).toBeInTheDocument()
6062
})
6163

6264
it("renders 'Unknown Contributor' if the contributor name is missing", async () => {
6365
const contributor = { name: undefined, orcid: null }
6466
renderComponent({ contributor })
65-
expect(screen.getByText("Unknown Contributor")).toBeInTheDocument()
67+
68+
expect(await screen.findByText("Unknown Contributor")).toBeInTheDocument()
6669
})
6770

68-
it("renders the component and displays the ORCID link if an ID is provided", async () => {
71+
it("renders the ORCID link if an ID is provided", async () => {
6972
const testOrcid = "0000-0000-0000-0000"
7073
const contributor = { name: "Author With ORCID", orcid: testOrcid }
7174
renderComponent({ contributor })
72-
const orcidLink = screen.getByLabelText(
73-
`ORCID profile for ${contributor.name}`,
74-
)
75+
76+
// check link by role
77+
const orcidLink = await screen.findByRole("link", {
78+
name: /ORCID profile for Author With ORCID/,
79+
})
80+
7581
expect(orcidLink).toBeInTheDocument()
7682
expect(orcidLink).toHaveAttribute(
7783
"href",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import React from "react"
2+
import type { Contributor } from "../types/datacite"
3+
import { SelectGroup } from "../components/select/SelectGroup"
4+
import { CONTRIBUTOR_TYPES } from "./contributor-utils"
5+
6+
interface ContributorFormRowProps {
7+
contributor: Contributor
8+
index: number
9+
errors: Record<number, string>
10+
onChange: (index: number, field: keyof Contributor, value: string) => void
11+
onMove: (index: number, direction: "up" | "down") => void
12+
onRemove: (index: number) => void
13+
isFirst: boolean
14+
isLast: boolean
15+
}
16+
17+
export const ContributorFormRow: React.FC<ContributorFormRowProps> = ({
18+
contributor,
19+
index,
20+
errors,
21+
onChange,
22+
onMove,
23+
onRemove,
24+
isFirst,
25+
isLast,
26+
}) => (
27+
<div
28+
style={{
29+
display: "flex",
30+
alignItems: "center",
31+
marginBottom: 8,
32+
gap: 8,
33+
flexWrap: "wrap",
34+
}}
35+
>
36+
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
37+
<button
38+
type="button"
39+
onClick={() => onMove(index, "up")}
40+
disabled={isFirst}
41+
>
42+
43+
</button>
44+
<button
45+
type="button"
46+
onClick={() => onMove(index, "down")}
47+
disabled={isLast}
48+
>
49+
50+
</button>
51+
</div>
52+
53+
<div style={{ display: "flex", flexDirection: "column" }}>
54+
<input
55+
type="text"
56+
placeholder="Name"
57+
value={contributor.name || ""}
58+
onChange={(e) => onChange(index, "name", e.target.value)}
59+
style={{
60+
borderColor: errors[index] ? "red" : undefined,
61+
borderWidth: errors[index] ? 2 : undefined,
62+
}}
63+
required
64+
/>
65+
{errors[index] && (
66+
<span style={{ color: "red", fontSize: "0.8em" }}>{errors[index]}</span>
67+
)}
68+
</div>
69+
70+
<SelectGroup
71+
id={`contributor-type-${index}`}
72+
layout="inline"
73+
options={CONTRIBUTOR_TYPES.map((t) => ({ label: t, value: t }))}
74+
value={contributor.contributorType?.trim() ?? ""}
75+
setValue={(v) => onChange(index, "contributorType", v)}
76+
/>
77+
78+
<button
79+
type="button"
80+
onClick={() => onRemove(index)}
81+
style={{ color: "#C82429", border: 0, background: "none" }}
82+
>
83+
<i className="fa fa-trash"></i>
84+
</button>
85+
</div>
86+
)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Contributor } from "../types/datacite"
2+
3+
export const CONTRIBUTOR_TYPES = [
4+
"ContactPerson",
5+
"DataCollector",
6+
"DataCurator",
7+
"DataManager",
8+
"Distributor",
9+
"Editor",
10+
"HostingInstitution",
11+
"Producer",
12+
"ProjectLeader",
13+
"ProjectManager",
14+
"ProjectMember",
15+
"RegistrationAgency",
16+
"RegistrationAuthority",
17+
"RelatedPerson",
18+
"Researcher",
19+
"ResearchGroup",
20+
"RightsHolder",
21+
"Sponsor",
22+
"Supervisor",
23+
"WorkPackageLeader",
24+
"Other",
25+
]
26+
27+
// Utility to deep clone a contributor
28+
export const cloneContributor = (c: Contributor): Contributor =>
29+
structuredClone(c)

0 commit comments

Comments
 (0)