@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.
Files changed (36) hide show
  1. package/.scss-lint.yml +11 -11
  2. package/maintenance.html +26 -20
  3. package/package.json +3 -3
  4. package/src/@types/custom.d.ts +6 -0
  5. package/src/index.html +14 -10
  6. package/src/scripts/datalad/routes/dataset-redirect.tsx +2 -0
  7. package/src/scripts/dataset/mutations/__tests__/update-permissions.spec.jsx +2 -1
  8. package/src/scripts/dataset/mutations/update-permissions.tsx +1 -9
  9. package/src/scripts/routes.tsx +17 -0
  10. package/src/scripts/users/__tests__/user-account-view.spec.tsx +152 -0
  11. package/src/scripts/users/__tests__/user-card.spec.tsx +110 -0
  12. package/src/scripts/users/__tests__/user-query.spec.tsx +65 -0
  13. package/src/scripts/users/__tests__/user-routes.spec.tsx +102 -0
  14. package/src/scripts/users/__tests__/user-tabs.spec.tsx +84 -0
  15. package/src/scripts/users/components/close-button.tsx +20 -0
  16. package/src/scripts/users/components/edit-button.tsx +20 -0
  17. package/src/scripts/users/components/edit-list.tsx +103 -0
  18. package/src/scripts/users/components/edit-string.tsx +90 -0
  19. package/src/scripts/users/components/editable-content.tsx +98 -0
  20. package/src/scripts/users/scss/editable-content.scss +15 -0
  21. package/src/scripts/users/scss/user-meta-blocks.scss +14 -0
  22. package/src/scripts/users/scss/useraccountview.module.scss +20 -0
  23. package/src/scripts/users/scss/usercard.module.scss +24 -0
  24. package/src/scripts/users/scss/usercontainer.module.scss +38 -0
  25. package/src/scripts/users/scss/usertabs.module.scss +63 -0
  26. package/src/scripts/users/user-account-view.tsx +142 -0
  27. package/src/scripts/users/user-card.tsx +86 -0
  28. package/src/scripts/users/user-container.tsx +49 -0
  29. package/src/scripts/users/user-datasets-view.tsx +53 -0
  30. package/src/scripts/users/user-notifications-view.tsx +11 -0
  31. package/src/scripts/users/user-query.tsx +76 -0
  32. package/src/scripts/users/user-routes.tsx +52 -0
  33. package/src/scripts/users/user-tabs.tsx +74 -0
  34. package/src/scripts/utils/__tests__/markdown.spec.tsx +1 -2
  35. package/src/scripts/utils/validationUtils.ts +8 -0
  36. package/src/scripts/validation/validation.tsx +11 -8
@@ -0,0 +1,38 @@
1
+ .usercontainer {
2
+ display: flex;
3
+ min-height: calc(100vh - 350px);
4
+ }
5
+
6
+ .userHeader {
7
+ display: flex;
8
+ align-items: center;
9
+ margin: 10px 0 0;
10
+ height: 100px;
11
+ .avatar {
12
+ border-radius: 50%;
13
+ margin-right: 10px;
14
+ width: 60px;
15
+ height: 60px;
16
+ }
17
+ .username {
18
+ }
19
+ h2 {
20
+ font-size: 35px;
21
+ line-height: 38px;
22
+ font-weight: 400;
23
+ }
24
+ }
25
+
26
+ .userSidebar {
27
+ width: 350px;
28
+ flex: 0 0 auto;
29
+ padding: 0;
30
+ width: 430px;
31
+ }
32
+ .userViews {
33
+ border-left: 1px solid #e0e0e0;
34
+ flex: 1;
35
+ padding: 0 0 0 50px;
36
+ position: relative;
37
+ max-width: 100%;
38
+ }
@@ -0,0 +1,63 @@
1
+ .userAccountTabLinks {
2
+ ul {
3
+ margin: 0;
4
+ padding: 0;
5
+ list-style-type: none;
6
+ position: relative;
7
+
8
+ &:before,
9
+ &:after {
10
+ content: '';
11
+ width: calc(100% - 60px);
12
+ height: 32px;
13
+ display: inline-block;
14
+ position: absolute;
15
+ left: 0;
16
+ top: 0;
17
+ transform: translateY(var(--active-offset, 0));
18
+ margin-left: 25px;
19
+ border-radius: 8px;
20
+ transition: transform 0.3s ease; /* Always apply transition */
21
+ }
22
+ /* Apply animation when clicked */
23
+ // not working for some reason
24
+ // &.clicked:before,
25
+ // &.clicked:after {
26
+ // transform: translateY(var(--active-offset, 0));
27
+ // transition: transform 0.3s ease; /* Ensure transition */
28
+ // }
29
+
30
+ &:before {
31
+ background: #eee;
32
+ margin: 0 20px 0 35px;
33
+ }
34
+
35
+ &:after {
36
+ height: 23px;
37
+ margin: 5px 0 0px 25px;
38
+ width: 5px;
39
+ background: #204e5b;
40
+ }
41
+
42
+ li {
43
+ margin: 10px 25px;
44
+ position: relative;
45
+
46
+ a {
47
+ padding: 5px 10px 5px 20px;
48
+ border-radius: 4px;
49
+ display: block;
50
+ text-decoration: none;
51
+ background-color: transparent;
52
+ transition: background-color 0.3s;
53
+ &.active {
54
+ font-weight: bold;
55
+ &:hover {
56
+ color: #204e5b;
57
+ cursor: default;
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,142 @@
1
+ import React, { useState } from "react"
2
+ import { useMutation } from "@apollo/client"
3
+ import { EditableContent } from "./components/editable-content"
4
+ import styles from "./scss/useraccountview.module.scss"
5
+ import { GET_USER_BY_ORCID, UPDATE_USER } from "./user-query"
6
+
7
+ interface UserAccountViewProps {
8
+ user: {
9
+ name: string
10
+ email: string
11
+ orcid: string
12
+ links: string[]
13
+ location: string
14
+ institution: string
15
+ github?: string
16
+ }
17
+ }
18
+
19
+ export const UserAccountView: React.FC<UserAccountViewProps> = ({ user }) => {
20
+ const [userLinks, setLinks] = useState<string[]>(user.links || [])
21
+ const [userLocation, setLocation] = useState<string>(user.location || "")
22
+ const [userInstitution, setInstitution] = useState<string>(
23
+ user.institution || "",
24
+ )
25
+ const [updateUser] = useMutation(UPDATE_USER)
26
+
27
+ const handleLinksChange = async (newLinks: string[]) => {
28
+ setLinks(newLinks)
29
+ try {
30
+ await updateUser({
31
+ variables: {
32
+ id: user.orcid,
33
+ links: newLinks,
34
+ },
35
+ refetchQueries: [
36
+ {
37
+ query: GET_USER_BY_ORCID,
38
+ variables: { id: user.orcid },
39
+ },
40
+ ],
41
+ })
42
+ } catch {
43
+ // Error handling can be implemented here if needed
44
+ }
45
+ }
46
+
47
+ const handleLocationChange = async (newLocation: string) => {
48
+ setLocation(newLocation)
49
+
50
+ try {
51
+ await updateUser({
52
+ variables: {
53
+ id: user.orcid,
54
+ location: newLocation,
55
+ },
56
+ refetchQueries: [
57
+ {
58
+ query: GET_USER_BY_ORCID,
59
+ variables: { id: user.orcid },
60
+ },
61
+ ],
62
+ })
63
+ } catch {
64
+ // Error handling can be implemented here if needed
65
+ }
66
+ }
67
+
68
+ const handleInstitutionChange = async (newInstitution: string) => {
69
+ setInstitution(newInstitution)
70
+
71
+ try {
72
+ await updateUser({
73
+ variables: {
74
+ id: user.orcid,
75
+ institution: newInstitution,
76
+ },
77
+ refetchQueries: [
78
+ {
79
+ query: GET_USER_BY_ORCID,
80
+ variables: { id: user.orcid },
81
+ },
82
+ ],
83
+ })
84
+ } catch {
85
+ // Error handling can be implemented here if needed
86
+ }
87
+ }
88
+
89
+ return (
90
+ <div data-testid="user-account-view" className={styles.useraccountview}>
91
+ <h3>Account</h3>
92
+ <ul className={styles.accountDetail}>
93
+ <li>
94
+ <span>Name:</span>
95
+ {user.name}
96
+ </li>
97
+ <li>
98
+ <span>Email:</span>
99
+ {user.email}
100
+ </li>
101
+ <li>
102
+ <span>ORCID:</span>
103
+ {user.orcid}
104
+ </li>
105
+ {user.github
106
+ ? (
107
+ <li>
108
+ <span>GitHub:</span>
109
+ {user.github}
110
+ </li>
111
+ )
112
+ : <li>Connect your GitHub</li>}
113
+ </ul>
114
+
115
+ <EditableContent
116
+ editableContent={userLinks}
117
+ setRows={handleLinksChange}
118
+ className="custom-class"
119
+ heading="Links"
120
+ // eslint-disable-next-line no-useless-escape
121
+ validation={/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/} // URL validation regex
122
+ validationMessage="Invalid URL format. Please use a valid link."
123
+ data-testid="links-section"
124
+ />
125
+
126
+ <EditableContent
127
+ editableContent={userLocation}
128
+ setRows={handleLocationChange}
129
+ className="custom-class"
130
+ heading="Location"
131
+ data-testid="location-section"
132
+ />
133
+ <EditableContent
134
+ editableContent={userInstitution}
135
+ setRows={handleInstitutionChange}
136
+ className="custom-class"
137
+ heading="Institution"
138
+ data-testid="institution-section"
139
+ />
140
+ </div>
141
+ )
142
+ }
@@ -0,0 +1,86 @@
1
+ import React from "react"
2
+ import styles from "./scss/usercard.module.scss"
3
+
4
+ export interface User {
5
+ name: string
6
+ location?: string
7
+ email: string
8
+ orcid: string
9
+ institution?: string
10
+ links?: string[]
11
+ github?: string
12
+ }
13
+
14
+ export interface UserCardProps {
15
+ user: User
16
+ }
17
+
18
+ export const UserCard: React.FC<UserCardProps> = ({ user }) => {
19
+ const { location, institution, email, orcid, links = [], github, name } = user
20
+
21
+ return (
22
+ <div className={styles.userCard}>
23
+ <ul>
24
+ {institution && (
25
+ <li>
26
+ <i className="fa fa-building"></i>
27
+ {institution}
28
+ </li>
29
+ )}
30
+ {location && (
31
+ <li>
32
+ <i className="fas fa-map-marker-alt"></i>
33
+ {location}
34
+ </li>
35
+ )}
36
+ <li>
37
+ <i className="fas fa-envelope"></i>
38
+ <a
39
+ href={"mailto:" + email}
40
+ target="_blank"
41
+ rel="noopener noreferrer"
42
+ >
43
+ {email}
44
+ </a>
45
+ </li>
46
+ {orcid && (
47
+ <li className={styles.orcid}>
48
+ <i className="fab fa-orcid" aria-hidden="true"></i>
49
+ <a
50
+ href={`https://orcid.org/${orcid}`}
51
+ target="_blank"
52
+ rel="noopener noreferrer"
53
+ aria-label={`ORCID profile of ${name}`}
54
+ >
55
+ {orcid}
56
+ </a>
57
+ </li>
58
+ )}
59
+ {github && (
60
+ <li>
61
+ <i className="fab fa-github"></i>
62
+ <a
63
+ href={`https://github.com/${github}`}
64
+ target="_blank"
65
+ rel="noopener noreferrer"
66
+ aria-label={`Github profile of ${name}`}
67
+ >
68
+ {github}
69
+ </a>
70
+ </li>
71
+ )}
72
+ {links.length > 0 &&
73
+ links
74
+ .filter(Boolean)
75
+ .map((link, index) => (
76
+ <li key={index}>
77
+ <i className="fa fa-link"></i>
78
+ <a href={link} target="_blank" rel="noopener noreferrer">
79
+ {link}
80
+ </a>
81
+ </li>
82
+ ))}
83
+ </ul>
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,49 @@
1
+ import React from "react"
2
+ import { Outlet } from "react-router-dom"
3
+ import { UserCard } from "./user-card"
4
+ import { UserAccountTabs } from "./user-tabs"
5
+ import styles from "./scss/usercontainer.module.scss"
6
+
7
+ interface User {
8
+ id: string
9
+ name: string
10
+ location: string
11
+ github?: string
12
+ institution: string
13
+ email: string
14
+ avatar: string
15
+ orcid: string
16
+ links: string[]
17
+ }
18
+
19
+ interface AccountContainerProps {
20
+ user: User
21
+ hasEdit: boolean
22
+ }
23
+
24
+ export const UserAccountContainer: React.FC<AccountContainerProps> = ({
25
+ user,
26
+ hasEdit,
27
+ }) => {
28
+ return (
29
+ <>
30
+ <div className="container">
31
+ <header className={styles.userHeader}>
32
+ {user.avatar && (
33
+ <img className={styles.avatar} src={user.avatar} alt={user.name} />
34
+ )}
35
+ <h2 className={styles.username}>{user.name}</h2>
36
+ </header>
37
+ </div>
38
+ <div className={styles.usercontainer + " container"}>
39
+ <section className={styles.userSidebar}>
40
+ <UserCard user={user} />
41
+ <UserAccountTabs hasEdit={hasEdit} />
42
+ </section>
43
+ <section className={styles.userViews}>
44
+ <Outlet />
45
+ </section>
46
+ </div>
47
+ </>
48
+ )
49
+ }
@@ -0,0 +1,53 @@
1
+ import React from "react"
2
+
3
+ interface User {
4
+ name: string
5
+ }
6
+
7
+ interface Dataset {
8
+ id: string
9
+ created: string
10
+ ownerId: string
11
+ name: string
12
+ type: string
13
+ }
14
+
15
+ interface UserDatasetsViewProps {
16
+ user: User
17
+ }
18
+
19
+ const dummyDatasets: Dataset[] = [
20
+ {
21
+ id: "ds00001",
22
+ created: "2023-11-01T12:00:00Z",
23
+ ownerId: "1",
24
+ name: "Dataset 1",
25
+ type: "public",
26
+ },
27
+ {
28
+ id: "ds00002",
29
+ created: "2023-11-02T12:00:00Z",
30
+ ownerId: "2",
31
+ name: "Dataset 2",
32
+ type: "private",
33
+ },
34
+ ]
35
+
36
+ export const UserDatasetsView: React.FC<UserDatasetsViewProps> = ({ user }) => {
37
+ return (
38
+ <div data-testid="user-datasets-view">
39
+ <h1>{user.name}'s Datasets</h1>
40
+ <div>
41
+ {dummyDatasets.map((dataset) => (
42
+ <div key={dataset.id} data-testid={`dataset-${dataset.id}`}>
43
+ <h2>{dataset.name}</h2>
44
+ <p>Type: {dataset.type}</p>
45
+ <p>Created: {dataset.created}</p>
46
+ </div>
47
+ ))}
48
+ </div>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export default UserDatasetsView
@@ -0,0 +1,11 @@
1
+ import React from "react"
2
+
3
+ export const UserNotificationsView = ({ user }) => {
4
+ // this is a placeholder for the user notification feature
5
+ return (
6
+ <div data-testid="user-notifications-view">
7
+ <h3>UserNotificationsPAge for {user.name}</h3>
8
+ <p>This should show user info</p>
9
+ </div>
10
+ )
11
+ }
@@ -0,0 +1,76 @@
1
+ import React from "react"
2
+ import { useParams } from "react-router-dom"
3
+ import { UserRoutes } from "./user-routes"
4
+ import FourOFourPage from "../errors/404page"
5
+ import { isValidOrcid } from "../utils/validationUtils"
6
+ import { gql, useQuery } from "@apollo/client"
7
+ import { isAdmin } from "../authentication/admin-user"
8
+ import { useCookies } from "react-cookie"
9
+ import { getProfile } from "../authentication/profile"
10
+
11
+ // GraphQL query to fetch user by ORCID
12
+ export const GET_USER_BY_ORCID = gql`
13
+ query User($userId: ID!) {
14
+ user(id: $userId) {
15
+ id
16
+ name
17
+ orcid
18
+ email
19
+ avatar
20
+ location
21
+ institution
22
+ links
23
+ }
24
+ }
25
+ `
26
+
27
+ export const UPDATE_USER = gql`
28
+ mutation updateUser($id: ID!, $location: String, $links: [String], $institution: String) {
29
+ updateUser(id: $id, location: $location, links: $links, institution: $institution) {
30
+ id
31
+ location
32
+ links
33
+ institution
34
+ }
35
+ }
36
+ `
37
+
38
+ export interface User {
39
+ id: string
40
+ name: string
41
+ location: string
42
+ github?: string
43
+ institution: string
44
+ email: string
45
+ avatar: string
46
+ orcid: string
47
+ links: string[]
48
+ }
49
+
50
+ export const UserQuery: React.FC = () => {
51
+ const { orcid } = useParams()
52
+ const isOrcidValid = orcid && isValidOrcid(orcid)
53
+ const { data, loading, error } = useQuery(GET_USER_BY_ORCID, {
54
+ variables: { userId: orcid },
55
+ skip: !isOrcidValid,
56
+ })
57
+
58
+ const [cookies] = useCookies()
59
+ const profile = getProfile(cookies)
60
+
61
+ if (!isOrcidValid) {
62
+ return <FourOFourPage />
63
+ }
64
+
65
+ if (loading) return <div>Loading...</div>
66
+
67
+ if (error || !data?.user || data.user.orcid !== orcid) {
68
+ return <FourOFourPage />
69
+ }
70
+
71
+ // is admin or profile matches id from the user data being returned
72
+ const hasEdit = isAdmin || data.user.id !== profile.sub ? true : false
73
+
74
+ // Render user data with UserRoutes
75
+ return <UserRoutes user={data.user} hasEdit={hasEdit} />
76
+ }
@@ -0,0 +1,52 @@
1
+ import React from "react"
2
+ import { Route, Routes } from "react-router-dom"
3
+ import { UserAccountContainer } from "./user-container"
4
+ import { UserAccountView } from "./user-account-view"
5
+ import { UserNotificationsView } from "./user-notifications-view"
6
+ import { UserDatasetsView } from "./user-datasets-view"
7
+ import FourOFourPage from "../errors/404page"
8
+ import FourOThreePage from "../errors/403page"
9
+
10
+ export interface User {
11
+ id: string
12
+ name: string
13
+ location: string
14
+ github?: string
15
+ institution: string
16
+ email: string
17
+ avatar: string
18
+ orcid: string
19
+ links: string[]
20
+ }
21
+
22
+ interface UserRoutesProps {
23
+ user: User
24
+ hasEdit: boolean
25
+ }
26
+
27
+ export const UserRoutes: React.FC<UserRoutesProps> = ({ user, hasEdit }) => {
28
+ return (
29
+ <Routes>
30
+ <Route path="/*" element={<FourOFourPage />} />
31
+ <Route
32
+ path="*"
33
+ element={<UserAccountContainer user={user} hasEdit={hasEdit} />}
34
+ >
35
+ <Route path="" element={<UserDatasetsView user={user} />} />
36
+ <Route
37
+ path="account"
38
+ element={hasEdit
39
+ ? <UserAccountView user={user} />
40
+ : <FourOThreePage />}
41
+ />
42
+ <Route
43
+ path="notifications"
44
+ element={hasEdit
45
+ ? <UserNotificationsView user={user} />
46
+ : <FourOThreePage />}
47
+ />
48
+ <Route path="*" element={<FourOFourPage />} />
49
+ </Route>
50
+ </Routes>
51
+ )
52
+ }
@@ -0,0 +1,74 @@
1
+ import React, { useEffect, useRef, useState } from "react"
2
+ import { NavLink, useLocation } from "react-router-dom"
3
+ import styles from "./scss/usertabs.module.scss"
4
+
5
+ export interface UserAccountTabsProps {
6
+ hasEdit: boolean
7
+ }
8
+
9
+ export const UserAccountTabs: React.FC<UserAccountTabsProps> = (
10
+ { hasEdit },
11
+ ) => {
12
+ const ulRef = useRef<HTMLUListElement>(null)
13
+ const [activePosition, setActivePosition] = useState<number>(0)
14
+ const [clicked, setClicked] = useState(false)
15
+ const location = useLocation()
16
+
17
+ useEffect(() => {
18
+ const activeLink = ulRef.current?.querySelector(
19
+ `.${styles.active}`,
20
+ ) as HTMLElement
21
+ if (activeLink) {
22
+ const li = activeLink.parentElement as HTMLElement
23
+ setActivePosition(li.offsetTop)
24
+ }
25
+ }, [location])
26
+
27
+ const handleClick = () => {
28
+ setClicked(true)
29
+ }
30
+
31
+ if (!hasEdit) return null
32
+
33
+ return (
34
+ <div className={styles.userAccountTabLinks}>
35
+ <ul
36
+ ref={ulRef}
37
+ className={`${clicked ? "clicked" : ""} ${styles.userAccountTabLinks}`}
38
+ style={{
39
+ "--active-offset": `${activePosition}px`,
40
+ } as React.CSSProperties}
41
+ >
42
+ <li>
43
+ <NavLink
44
+ to=""
45
+ end
46
+ className={({ isActive }) => (isActive ? styles.active : "")}
47
+ onClick={handleClick}
48
+ >
49
+ User Datasets
50
+ </NavLink>
51
+ </li>
52
+ <li>
53
+ <NavLink
54
+ data-testid="user-notifications-tab"
55
+ to="notifications"
56
+ className={({ isActive }) => (isActive ? styles.active : "")}
57
+ onClick={handleClick}
58
+ >
59
+ User Notifications
60
+ </NavLink>
61
+ </li>
62
+ <li>
63
+ <NavLink
64
+ to="account"
65
+ className={({ isActive }) => (isActive ? styles.active : "")}
66
+ onClick={handleClick}
67
+ >
68
+ Account Info
69
+ </NavLink>
70
+ </li>
71
+ </ul>
72
+ </div>
73
+ )
74
+ }
@@ -27,8 +27,7 @@ describe("Test <Markdown> component", () => {
27
27
  expect(asFragment()).toMatchSnapshot()
28
28
  })
29
29
  it("filters close-break tags", () => {
30
- const hrefExample =
31
- '<br>sample text</br>'
30
+ const hrefExample = "<br>sample text</br>"
32
31
  const { asFragment } = render(<Markdown>{hrefExample}</Markdown>)
33
32
  expect(asFragment()).toMatchSnapshot()
34
33
  })
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Validates an ORCID.
3
+ * @param orcid - The ORCID string to validate.
4
+ * @returns `true` if the ORCID is valid, `false` otherwise.
5
+ */
6
+ export function isValidOrcid(orcid: string): boolean {
7
+ return /^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$/.test(orcid || "")
8
+ }