@openneuro/app 4.29.8 → 4.30.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.scss-lint.yml +11 -11
- package/maintenance.html +26 -20
- package/package.json +3 -3
- package/src/@types/custom.d.ts +6 -0
- package/src/index.html +14 -10
- package/src/scripts/datalad/routes/dataset-redirect.tsx +2 -0
- package/src/scripts/dataset/mutations/__tests__/update-permissions.spec.jsx +2 -1
- package/src/scripts/dataset/mutations/update-permissions.tsx +1 -9
- package/src/scripts/routes.tsx +17 -0
- package/src/scripts/users/__tests__/user-account-view.spec.tsx +152 -0
- package/src/scripts/users/__tests__/user-card.spec.tsx +110 -0
- package/src/scripts/users/__tests__/user-query.spec.tsx +65 -0
- package/src/scripts/users/__tests__/user-routes.spec.tsx +102 -0
- package/src/scripts/users/__tests__/user-tabs.spec.tsx +84 -0
- package/src/scripts/users/components/close-button.tsx +20 -0
- package/src/scripts/users/components/edit-button.tsx +20 -0
- package/src/scripts/users/components/edit-list.tsx +103 -0
- package/src/scripts/users/components/edit-string.tsx +90 -0
- package/src/scripts/users/components/editable-content.tsx +98 -0
- package/src/scripts/users/scss/editable-content.scss +15 -0
- package/src/scripts/users/scss/user-meta-blocks.scss +14 -0
- package/src/scripts/users/scss/useraccountview.module.scss +20 -0
- package/src/scripts/users/scss/usercard.module.scss +24 -0
- package/src/scripts/users/scss/usercontainer.module.scss +38 -0
- package/src/scripts/users/scss/usertabs.module.scss +63 -0
- package/src/scripts/users/user-account-view.tsx +142 -0
- package/src/scripts/users/user-card.tsx +86 -0
- package/src/scripts/users/user-container.tsx +49 -0
- package/src/scripts/users/user-datasets-view.tsx +53 -0
- package/src/scripts/users/user-notifications-view.tsx +11 -0
- package/src/scripts/users/user-query.tsx +76 -0
- package/src/scripts/users/user-routes.tsx +52 -0
- package/src/scripts/users/user-tabs.tsx +74 -0
- package/src/scripts/utils/__tests__/markdown.spec.tsx +1 -2
- package/src/scripts/utils/validationUtils.ts +8 -0
- package/src/scripts/validation/validation.tsx +11 -8
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react"
|
|
3
|
+
import { MockedProvider } from "@apollo/client/testing"
|
|
4
|
+
import { MemoryRouter, Route, Routes } from "react-router-dom"
|
|
5
|
+
import { UserQuery } from "../user-query"
|
|
6
|
+
import { GET_USER_BY_ORCID } from "../user-query"
|
|
7
|
+
|
|
8
|
+
const validOrcid = "0009-0001-9689-7232"
|
|
9
|
+
|
|
10
|
+
const userMock = {
|
|
11
|
+
request: {
|
|
12
|
+
query: GET_USER_BY_ORCID,
|
|
13
|
+
variables: { userId: validOrcid },
|
|
14
|
+
},
|
|
15
|
+
result: {
|
|
16
|
+
data: {
|
|
17
|
+
user: {
|
|
18
|
+
__typename: "User",
|
|
19
|
+
id: "1",
|
|
20
|
+
name: "Test User",
|
|
21
|
+
orcid: validOrcid,
|
|
22
|
+
email: "test@example.com",
|
|
23
|
+
avatar: "http://example.com/avatar.png",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mocks = [userMock]
|
|
30
|
+
|
|
31
|
+
describe("UserQuery component", () => {
|
|
32
|
+
it("displays the ORCID on the page for a valid ORCID", async () => {
|
|
33
|
+
render(
|
|
34
|
+
<MockedProvider mocks={mocks} addTypename={true}>
|
|
35
|
+
<MemoryRouter initialEntries={[`/user/${validOrcid}`]}>
|
|
36
|
+
<Routes>
|
|
37
|
+
<Route path="/user/:orcid" element={<UserQuery />} />
|
|
38
|
+
</Routes>
|
|
39
|
+
</MemoryRouter>
|
|
40
|
+
</MockedProvider>,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
await waitFor(() =>
|
|
44
|
+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument()
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
expect(screen.getByText(validOrcid)).toBeInTheDocument()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("shows 404 page for invalid ORCID", async () => {
|
|
51
|
+
render(
|
|
52
|
+
<MockedProvider mocks={[]} addTypename={true}>
|
|
53
|
+
<MemoryRouter initialEntries={[`/user/invalid-orcid`]}>
|
|
54
|
+
<Routes>
|
|
55
|
+
<Route path="/user/:orcid" element={<UserQuery />} />
|
|
56
|
+
</Routes>
|
|
57
|
+
</MemoryRouter>
|
|
58
|
+
</MockedProvider>,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
await waitFor(() => screen.getByText(/404/i))
|
|
62
|
+
|
|
63
|
+
expect(screen.getByText(/404/i)).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react"
|
|
3
|
+
import { MemoryRouter } from "react-router-dom"
|
|
4
|
+
import { MockedProvider } from "@apollo/client/testing"
|
|
5
|
+
import { UserRoutes } from "../user-routes"
|
|
6
|
+
import type { User } from "../user-routes"
|
|
7
|
+
import { UPDATE_USER } from "../user-query"
|
|
8
|
+
|
|
9
|
+
const defaultUser: User = {
|
|
10
|
+
id: "1",
|
|
11
|
+
name: "John Doe",
|
|
12
|
+
location: "Unknown",
|
|
13
|
+
github: "",
|
|
14
|
+
institution: "Unknown Institution",
|
|
15
|
+
email: "john.doe@example.com",
|
|
16
|
+
avatar: "https://dummyimage.com/200x200/000/fff",
|
|
17
|
+
orcid: "0000-0000-0000-0000",
|
|
18
|
+
links: [],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const mocks = [
|
|
22
|
+
{
|
|
23
|
+
request: {
|
|
24
|
+
query: UPDATE_USER,
|
|
25
|
+
variables: {
|
|
26
|
+
id: "1",
|
|
27
|
+
name: "John Doe",
|
|
28
|
+
location: "Unknown",
|
|
29
|
+
github: "",
|
|
30
|
+
institution: "Unknown Institution",
|
|
31
|
+
email: "john.doe@example.com",
|
|
32
|
+
avatar: "https://dummyimage.com/200x200/000/fff",
|
|
33
|
+
orcid: "0000-0000-0000-0000",
|
|
34
|
+
links: [],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
result: {
|
|
38
|
+
data: {
|
|
39
|
+
updateUser: {
|
|
40
|
+
id: "1",
|
|
41
|
+
name: "John Doe",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const renderWithRouter = (user: User, route: string, hasEdit: boolean) => {
|
|
49
|
+
return render(
|
|
50
|
+
<MockedProvider mocks={mocks} addTypename={false}>
|
|
51
|
+
<MemoryRouter initialEntries={[route]}>
|
|
52
|
+
<UserRoutes user={user} hasEdit={hasEdit} />
|
|
53
|
+
</MemoryRouter>
|
|
54
|
+
</MockedProvider>,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("UserRoutes Component", () => {
|
|
59
|
+
const user: User = defaultUser
|
|
60
|
+
|
|
61
|
+
it("renders UserDatasetsView for the default route", async () => {
|
|
62
|
+
renderWithRouter(user, "/", true)
|
|
63
|
+
expect(screen.getByText(`${user.name}'s Datasets`)).toBeInTheDocument()
|
|
64
|
+
const datasetsView = await screen.findByTestId("user-datasets-view")
|
|
65
|
+
expect(datasetsView).toBeInTheDocument()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("renders FourOFourPage for an invalid route", async () => {
|
|
69
|
+
renderWithRouter(user, "/nonexistent-route", true)
|
|
70
|
+
const errorMessage = await screen.findByText(
|
|
71
|
+
/404: The page you are looking for does not exist./i,
|
|
72
|
+
)
|
|
73
|
+
expect(errorMessage).toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("renders UserAccountView when hasEdit is true", async () => {
|
|
77
|
+
renderWithRouter(user, "/account", true)
|
|
78
|
+
const accountView = await screen.findByTestId("user-account-view")
|
|
79
|
+
expect(accountView).toBeInTheDocument()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("renders UserNotificationsView when hasEdit is true", async () => {
|
|
83
|
+
renderWithRouter(user, "/notifications", true)
|
|
84
|
+
const notificationsView = await screen.findByTestId(
|
|
85
|
+
"user-notifications-view",
|
|
86
|
+
)
|
|
87
|
+
expect(notificationsView).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("renders FourOThreePage when hasEdit is false for restricted routes", async () => {
|
|
91
|
+
const restrictedRoutes = ["/account", "/notifications"]
|
|
92
|
+
|
|
93
|
+
for (const route of restrictedRoutes) {
|
|
94
|
+
cleanup()
|
|
95
|
+
renderWithRouter(user, route, false)
|
|
96
|
+
const errorMessage = await screen.findByText(
|
|
97
|
+
/403: You do not have access to this page, you may need to sign in./i,
|
|
98
|
+
)
|
|
99
|
+
expect(errorMessage).toBeInTheDocument()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { useState } from "react"
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react"
|
|
3
|
+
import { MemoryRouter, Route, Routes } from "react-router-dom"
|
|
4
|
+
import { UserAccountTabs } from "../user-tabs"
|
|
5
|
+
|
|
6
|
+
// Wrapper component to allow dynamic modification of `hasEdit`
|
|
7
|
+
const UserAccountTabsWrapper: React.FC = () => {
|
|
8
|
+
const [hasEdit, setHasEdit] = useState(true)
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
<button onClick={() => setHasEdit(!hasEdit)}>Toggle hasEdit</button>
|
|
13
|
+
<MemoryRouter>
|
|
14
|
+
<Routes>
|
|
15
|
+
<Route path="*" element={<UserAccountTabs hasEdit={hasEdit} />} />
|
|
16
|
+
</Routes>
|
|
17
|
+
</MemoryRouter>
|
|
18
|
+
</>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("UserAccountTabs Component", () => {
|
|
23
|
+
it("should not render tabs when hasEdit is false", () => {
|
|
24
|
+
render(<UserAccountTabsWrapper />)
|
|
25
|
+
|
|
26
|
+
expect(screen.getByText("User Datasets")).toBeInTheDocument()
|
|
27
|
+
|
|
28
|
+
fireEvent.click(screen.getByText("Toggle hasEdit"))
|
|
29
|
+
|
|
30
|
+
expect(screen.queryByText("User Datasets")).not.toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("should render tabs when hasEdit is toggled back to true", () => {
|
|
34
|
+
render(<UserAccountTabsWrapper />)
|
|
35
|
+
|
|
36
|
+
expect(screen.getByText("User Datasets")).toBeInTheDocument()
|
|
37
|
+
|
|
38
|
+
fireEvent.click(screen.getByText("Toggle hasEdit"))
|
|
39
|
+
expect(screen.queryByText("User Datasets")).not.toBeInTheDocument()
|
|
40
|
+
|
|
41
|
+
fireEvent.click(screen.getByText("Toggle hasEdit"))
|
|
42
|
+
expect(screen.getByText("User Datasets")).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("should update active class on the correct NavLink based on route", () => {
|
|
46
|
+
render(<UserAccountTabsWrapper />)
|
|
47
|
+
|
|
48
|
+
// Utility function to check if an element has 'active' class - used because of CSS module discrepancies between classNames
|
|
49
|
+
const hasActiveClass = (element) => element.className.includes("active")
|
|
50
|
+
|
|
51
|
+
const datasetsTab = screen.getByText("User Datasets")
|
|
52
|
+
expect(hasActiveClass(datasetsTab)).toBe(true)
|
|
53
|
+
|
|
54
|
+
const notificationsTab = screen.getByText("User Notifications")
|
|
55
|
+
|
|
56
|
+
fireEvent.click(notificationsTab)
|
|
57
|
+
|
|
58
|
+
expect(hasActiveClass(notificationsTab)).toBe(true)
|
|
59
|
+
expect(hasActiveClass(datasetsTab)).toBe(false)
|
|
60
|
+
|
|
61
|
+
const accountTab = screen.getByText("Account Info")
|
|
62
|
+
|
|
63
|
+
fireEvent.click(accountTab)
|
|
64
|
+
|
|
65
|
+
expect(hasActiveClass(accountTab)).toBe(true)
|
|
66
|
+
expect(hasActiveClass(datasetsTab)).toBe(false)
|
|
67
|
+
expect(hasActiveClass(notificationsTab)).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("should trigger animation state when a tab is clicked", async () => {
|
|
71
|
+
render(<UserAccountTabsWrapper />)
|
|
72
|
+
|
|
73
|
+
const notificationsTab = screen.getByText("User Notifications")
|
|
74
|
+
// Utility function to check if an element has 'clicked' class - used because of CSS module discrepancies between classNames
|
|
75
|
+
const hasClickedClass = (element) => element.className.includes("clicked")
|
|
76
|
+
const tabsContainer = await screen.findByRole("list")
|
|
77
|
+
|
|
78
|
+
expect(hasClickedClass(tabsContainer)).toBe(false)
|
|
79
|
+
|
|
80
|
+
fireEvent.click(notificationsTab)
|
|
81
|
+
|
|
82
|
+
expect(hasClickedClass(tabsContainer)).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { FC } from "react"
|
|
3
|
+
import { Button } from "@openneuro/components/button"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An edit button, calls action when clicked
|
|
7
|
+
*/
|
|
8
|
+
interface CloseButtonProps {
|
|
9
|
+
action: () => void
|
|
10
|
+
}
|
|
11
|
+
export const CloseButton: FC<CloseButtonProps> = ({ action }) => {
|
|
12
|
+
return (
|
|
13
|
+
<Button
|
|
14
|
+
className="description-btn description-button-cancel"
|
|
15
|
+
label="Close"
|
|
16
|
+
icon="fas fa-times"
|
|
17
|
+
onClick={() => action()}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { FC } from "react"
|
|
3
|
+
import { Button } from "@openneuro/components/button"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An edit button, calls action when clicked
|
|
7
|
+
*/
|
|
8
|
+
interface EditButtonProps {
|
|
9
|
+
action: () => void
|
|
10
|
+
}
|
|
11
|
+
export const EditButton: FC<EditButtonProps> = ({ action }) => {
|
|
12
|
+
return (
|
|
13
|
+
<Button
|
|
14
|
+
className="description-btn description-button-edit"
|
|
15
|
+
label="Edit"
|
|
16
|
+
icon="fas fa-edit"
|
|
17
|
+
onClick={() => action()}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useState } from "react"
|
|
2
|
+
import { Button } from "@openneuro/components/button"
|
|
3
|
+
import "../scss/user-meta-blocks.scss"
|
|
4
|
+
|
|
5
|
+
interface EditListProps {
|
|
6
|
+
placeholder?: string
|
|
7
|
+
elements?: string[]
|
|
8
|
+
setElements: (elements: string[]) => void
|
|
9
|
+
validation?: RegExp
|
|
10
|
+
validationMessage?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* EditList Component
|
|
15
|
+
* Allows adding and removing strings from a list.
|
|
16
|
+
*/
|
|
17
|
+
export const EditList: React.FC<EditListProps> = (
|
|
18
|
+
{
|
|
19
|
+
placeholder = "Enter item",
|
|
20
|
+
elements = [],
|
|
21
|
+
setElements,
|
|
22
|
+
validation,
|
|
23
|
+
validationMessage,
|
|
24
|
+
},
|
|
25
|
+
) => {
|
|
26
|
+
const [newElement, setNewElement] = useState<string>("")
|
|
27
|
+
const [warnEmpty, setWarnEmpty] = useState<boolean>(false)
|
|
28
|
+
const [warnValidation, setWarnValidation] = useState<string | null>(null)
|
|
29
|
+
|
|
30
|
+
const removeElement = (index: number): void => {
|
|
31
|
+
setElements(elements.filter((_, i) => i !== index))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Add a new element to the list
|
|
35
|
+
const addElement = (): void => {
|
|
36
|
+
if (!newElement.trim()) {
|
|
37
|
+
setWarnEmpty(true)
|
|
38
|
+
setWarnValidation(null)
|
|
39
|
+
} else if (validation && !validation.test(newElement.trim())) {
|
|
40
|
+
setWarnValidation(validationMessage || "Invalid input format")
|
|
41
|
+
setWarnEmpty(false)
|
|
42
|
+
} else {
|
|
43
|
+
setElements([...elements, newElement.trim()])
|
|
44
|
+
setWarnEmpty(false)
|
|
45
|
+
setWarnValidation(null)
|
|
46
|
+
setNewElement("")
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Handle Enter/Return key press to add element
|
|
51
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
|
52
|
+
if (e.key === "Enter") {
|
|
53
|
+
addElement()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="edit-list-container">
|
|
59
|
+
<div className="el-group">
|
|
60
|
+
<input
|
|
61
|
+
type="text"
|
|
62
|
+
className="form-control"
|
|
63
|
+
placeholder={placeholder}
|
|
64
|
+
value={newElement}
|
|
65
|
+
onChange={(e) => setNewElement(e.target.value)}
|
|
66
|
+
onKeyDown={handleKeyDown}
|
|
67
|
+
/>
|
|
68
|
+
<Button
|
|
69
|
+
className="edit-list-add"
|
|
70
|
+
primary={true}
|
|
71
|
+
size="small"
|
|
72
|
+
label="Add"
|
|
73
|
+
onClick={addElement}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
{warnEmpty && (
|
|
77
|
+
<small className="warning-text">
|
|
78
|
+
Your input was empty
|
|
79
|
+
</small>
|
|
80
|
+
)}
|
|
81
|
+
{warnValidation && (
|
|
82
|
+
<small className="warning-text">{warnValidation}</small>
|
|
83
|
+
)}
|
|
84
|
+
<div className="edit-list-items">
|
|
85
|
+
{elements.map((element, index) => (
|
|
86
|
+
<div key={index} className="edit-list-group-item">
|
|
87
|
+
{element}
|
|
88
|
+
<Button
|
|
89
|
+
className="edit-list-remove"
|
|
90
|
+
nobg={true}
|
|
91
|
+
size="xsmall"
|
|
92
|
+
icon="fa fa-times"
|
|
93
|
+
label="Remove"
|
|
94
|
+
color="red"
|
|
95
|
+
onClick={() =>
|
|
96
|
+
removeElement(index)}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react"
|
|
2
|
+
import { Button } from "@openneuro/components/button"
|
|
3
|
+
import "../scss/user-meta-blocks.scss"
|
|
4
|
+
|
|
5
|
+
interface EditStringProps {
|
|
6
|
+
value?: string
|
|
7
|
+
setValue: (value: string) => void
|
|
8
|
+
placeholder?: string
|
|
9
|
+
closeEditing: () => void
|
|
10
|
+
validation?: RegExp
|
|
11
|
+
validationMessage?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const EditString: React.FC<EditStringProps> = (
|
|
15
|
+
{
|
|
16
|
+
value = "",
|
|
17
|
+
setValue,
|
|
18
|
+
placeholder = "Enter text",
|
|
19
|
+
closeEditing,
|
|
20
|
+
validation,
|
|
21
|
+
validationMessage,
|
|
22
|
+
},
|
|
23
|
+
) => {
|
|
24
|
+
const [currentValue, setCurrentValue] = useState<string>(value)
|
|
25
|
+
const [warnEmpty, setWarnEmpty] = useState<string | null>(null)
|
|
26
|
+
const [warnValidation, setWarnValidation] = useState<string | null>(null)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
// Show warning only if there was an initial value and it was deleted
|
|
30
|
+
if (value !== "" && currentValue === "") {
|
|
31
|
+
setWarnEmpty(
|
|
32
|
+
"Your input is empty. This will delete the previously saved value..",
|
|
33
|
+
)
|
|
34
|
+
} else {
|
|
35
|
+
setWarnEmpty(null)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Validation logic
|
|
39
|
+
if (validation && currentValue && !validation.test(currentValue)) {
|
|
40
|
+
setWarnValidation(validationMessage || "Invalid input")
|
|
41
|
+
} else {
|
|
42
|
+
setWarnValidation(null)
|
|
43
|
+
}
|
|
44
|
+
}, [currentValue, value, validation, validationMessage])
|
|
45
|
+
|
|
46
|
+
const handleSave = (): void => {
|
|
47
|
+
if (!warnValidation) {
|
|
48
|
+
setValue(currentValue.trim())
|
|
49
|
+
closeEditing()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Handle Enter key press for saving
|
|
54
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
55
|
+
if (event.key === "Enter") {
|
|
56
|
+
event.preventDefault()
|
|
57
|
+
handleSave()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="edit-string-container">
|
|
63
|
+
<div className="edit-string-group">
|
|
64
|
+
<input
|
|
65
|
+
type="text"
|
|
66
|
+
className="form-control"
|
|
67
|
+
placeholder={placeholder}
|
|
68
|
+
value={currentValue}
|
|
69
|
+
onChange={(e) => setCurrentValue(e.target.value)}
|
|
70
|
+
onKeyDown={handleKeyDown}
|
|
71
|
+
/>
|
|
72
|
+
<Button
|
|
73
|
+
className="edit-string-save"
|
|
74
|
+
primary={true}
|
|
75
|
+
size="small"
|
|
76
|
+
label="Save"
|
|
77
|
+
onClick={handleSave}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
{/* Show empty value warning only if content was deleted */}
|
|
81
|
+
{warnEmpty && currentValue === "" && (
|
|
82
|
+
<small className="warning-text">{warnEmpty}</small>
|
|
83
|
+
)}
|
|
84
|
+
{/* Show validation error */}
|
|
85
|
+
{warnValidation && (
|
|
86
|
+
<small className="warning-text">{warnValidation}</small>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React, { useState } from "react"
|
|
2
|
+
import { EditList } from "./edit-list"
|
|
3
|
+
import { EditString } from "./edit-string"
|
|
4
|
+
import { EditButton } from "./edit-button"
|
|
5
|
+
import { CloseButton } from "./close-button"
|
|
6
|
+
import { Markdown } from "../../utils/markdown"
|
|
7
|
+
import "../scss/editable-content.scss"
|
|
8
|
+
|
|
9
|
+
interface EditableContentProps {
|
|
10
|
+
editableContent: string[] | string
|
|
11
|
+
setRows: React.Dispatch<React.SetStateAction<string[] | string>>
|
|
12
|
+
className: string
|
|
13
|
+
heading: string
|
|
14
|
+
validation?: RegExp
|
|
15
|
+
validationMessage?: string
|
|
16
|
+
"data-testid"?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const EditableContent: React.FC<EditableContentProps> = ({
|
|
20
|
+
editableContent,
|
|
21
|
+
setRows,
|
|
22
|
+
className,
|
|
23
|
+
heading,
|
|
24
|
+
validation,
|
|
25
|
+
validationMessage,
|
|
26
|
+
"data-testid": testId,
|
|
27
|
+
}) => {
|
|
28
|
+
const [editing, setEditing] = useState(false)
|
|
29
|
+
|
|
30
|
+
const closeEditing = () => {
|
|
31
|
+
setEditing(false)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Function to handle validation of user input
|
|
35
|
+
const handleValidation = (value: string): boolean => {
|
|
36
|
+
if (validation && !validation.test(value)) {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className={`user-meta-block ${className}`} data-testid={testId}>
|
|
44
|
+
<span className="umb-heading">
|
|
45
|
+
<h4>{heading}</h4>
|
|
46
|
+
{editing
|
|
47
|
+
? <CloseButton action={closeEditing} />
|
|
48
|
+
: <EditButton action={() => setEditing(true)} />}
|
|
49
|
+
</span>
|
|
50
|
+
{editing
|
|
51
|
+
? (
|
|
52
|
+
<>
|
|
53
|
+
{Array.isArray(editableContent)
|
|
54
|
+
? (
|
|
55
|
+
<EditList
|
|
56
|
+
placeholder="Add new item"
|
|
57
|
+
elements={editableContent}
|
|
58
|
+
setElements={setRows as React.Dispatch<
|
|
59
|
+
React.SetStateAction<string[]>
|
|
60
|
+
>}
|
|
61
|
+
validation={validation}
|
|
62
|
+
validationMessage={validationMessage}
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
: (
|
|
66
|
+
<EditString
|
|
67
|
+
value={editableContent}
|
|
68
|
+
setValue={(newValue: string) => {
|
|
69
|
+
if (handleValidation(newValue)) {
|
|
70
|
+
setRows(newValue)
|
|
71
|
+
}
|
|
72
|
+
}}
|
|
73
|
+
placeholder="Edit content"
|
|
74
|
+
closeEditing={closeEditing}
|
|
75
|
+
validation={validation}
|
|
76
|
+
validationMessage={validationMessage}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
</>
|
|
80
|
+
)
|
|
81
|
+
: (
|
|
82
|
+
<>
|
|
83
|
+
{Array.isArray(editableContent)
|
|
84
|
+
? (
|
|
85
|
+
<ul>
|
|
86
|
+
{editableContent.map((item, index) => (
|
|
87
|
+
<li key={index}>
|
|
88
|
+
<Markdown>{item}</Markdown>
|
|
89
|
+
</li>
|
|
90
|
+
))}
|
|
91
|
+
</ul>
|
|
92
|
+
)
|
|
93
|
+
: <Markdown>{editableContent}</Markdown>}
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.useraccountview{
|
|
2
|
+
h3{
|
|
3
|
+
margin-top: 0;
|
|
4
|
+
font-size: 22px;
|
|
5
|
+
}
|
|
6
|
+
.accountDetail{
|
|
7
|
+
list-style: none;
|
|
8
|
+
margin: 0;
|
|
9
|
+
padding: 0;
|
|
10
|
+
li{
|
|
11
|
+
margin: 0 0 10px;
|
|
12
|
+
font-size: 16px;
|
|
13
|
+
}
|
|
14
|
+
span{
|
|
15
|
+
font-weight: 700;
|
|
16
|
+
margin-right: 10px;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* usercard.module.scss */
|
|
2
|
+
.userCard {
|
|
3
|
+
padding: 20px;
|
|
4
|
+
ul{
|
|
5
|
+
list-style: none;
|
|
6
|
+
margin: 0;
|
|
7
|
+
padding: 0;
|
|
8
|
+
li{
|
|
9
|
+
margin-bottom: 5px;
|
|
10
|
+
font-size: 14px;
|
|
11
|
+
i{
|
|
12
|
+
margin-right: 10px;
|
|
13
|
+
color: #666;
|
|
14
|
+
|
|
15
|
+
}
|
|
16
|
+
&.orcid i{
|
|
17
|
+
color: #a6cf39;
|
|
18
|
+
}
|
|
19
|
+
&:last-of-type{
|
|
20
|
+
margin-bottom: 0;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|