@openneuro/app 5.0.0 → 5.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/app",
3
- "version": "5.0.0",
3
+ "version": "5.1.1",
4
4
  "description": "React JS web frontend for the OpenNeuro platform.",
5
5
  "license": "MIT",
6
6
  "main": "public/client.js",
@@ -79,5 +79,5 @@
79
79
  "publishConfig": {
80
80
  "access": "public"
81
81
  },
82
- "gitHead": "437b7f1a10abf79d6e49058b86cf0ddb625de684"
82
+ "gitHead": "035b0b4544d4b287506ea00aff3b424ed816611a"
83
83
  }
@@ -43,65 +43,48 @@ class DownloadAbortError extends Error {
43
43
  let downloadCanceled
44
44
 
45
45
  /**
46
- * Recursive download for file trees via browser file access API
46
+ * Download for file trees via browser file access API
47
47
  */
48
48
  const downloadTree = async (
49
49
  { datasetId, snapshotTag, client, dirHandle, toastId },
50
50
  path = "",
51
- tree = null,
52
51
  ) => {
53
52
  const filesToDownload = await downloadDataset(client)({
54
53
  datasetId,
55
54
  snapshotTag,
56
- tree,
57
55
  })
58
56
  for (const [_index, file] of filesToDownload.entries()) {
59
57
  const downloadPath = path ? `${path}/${file.filename}` : file.filename
60
- if (file.directory) {
61
- // Next tree level
62
- await downloadTree(
63
- {
58
+ // Regular file
59
+ if (downloadCanceled) {
60
+ throw new DownloadAbortError("Download canceled by user request")
61
+ }
62
+ const fileHandle = await openFileTree(
63
+ dirHandle,
64
+ path ? `${path}/${file.filename}` : file.filename,
65
+ )
66
+ // Skip files which are already complete
67
+ if (fileHandle.size == file.size) continue
68
+ const writable = await fileHandle.createWritable()
69
+ const { body, status, statusText } = await fetch(file.urls[0])
70
+ let loaded = 0
71
+ const progress = new TransformStream({
72
+ transform(chunk, controller) {
73
+ downloadToastUpdate(toastId, loaded / file.size, {
64
74
  datasetId,
65
75
  snapshotTag,
66
- client,
67
- dirHandle,
68
- toastId,
69
- },
70
- downloadPath,
71
- file.key,
72
- )
76
+ downloadPath,
77
+ dirName: dirHandle.name,
78
+ })
79
+ loaded += chunk.length
80
+ controller.enqueue(chunk)
81
+ },
82
+ })
83
+ if (status === 200) {
84
+ await body.pipeThrough(progress).pipeTo(writable)
73
85
  } else {
74
- // Regular file
75
- if (downloadCanceled) {
76
- throw new DownloadAbortError("Download canceled by user request")
77
- }
78
- const fileHandle = await openFileTree(
79
- dirHandle,
80
- path ? `${path}/${file.filename}` : file.filename,
81
- )
82
- // Skip files which are already complete
83
- if (fileHandle.size == file.size) continue
84
- const writable = await fileHandle.createWritable()
85
- const { body, status, statusText } = await fetch(file.urls[0])
86
- let loaded = 0
87
- const progress = new TransformStream({
88
- transform(chunk, controller) {
89
- downloadToastUpdate(toastId, loaded / file.size, {
90
- datasetId,
91
- snapshotTag,
92
- downloadPath,
93
- dirName: dirHandle.name,
94
- })
95
- loaded += chunk.length
96
- controller.enqueue(chunk)
97
- },
98
- })
99
- if (status === 200) {
100
- await body.pipeThrough(progress).pipeTo(writable)
101
- } else {
102
- Sentry.captureException(statusText)
103
- return requestFailureToast(file.filename)
104
- }
86
+ Sentry.captureException(statusText)
87
+ return requestFailureToast(file.filename)
105
88
  }
106
89
  }
107
90
  }
@@ -1,31 +1,29 @@
1
1
  import { gql } from "@apollo/client"
2
2
 
3
3
  export const DOWNLOAD_DATASET = gql`
4
- query downloadDraft($datasetId: ID!, $tree: String) {
5
- dataset(id: $datasetId) {
6
- id
7
- draft {
4
+ query downloadDraft($datasetId: ID!) {
5
+ dataset(id: $datasetId) {
8
6
  id
9
- files(tree: $tree) {
7
+ draft {
10
8
  id
11
- key
12
- directory
13
- filename
14
- size
15
- urls
9
+ files(recursive: true) {
10
+ id
11
+ directory
12
+ filename
13
+ size
14
+ urls
15
+ }
16
16
  }
17
17
  }
18
18
  }
19
- }
20
19
  `
21
20
 
22
21
  export const DOWNLOAD_SNAPSHOT = gql`
23
- query downloadSnapshot($datasetId: ID!, $tag: String!, $tree: String) {
22
+ query downloadSnapshot($datasetId: ID!, $tag: String!) {
24
23
  snapshot(datasetId: $datasetId, tag: $tag) {
25
24
  id
26
- files(tree: $tree) {
25
+ files(recursive: true) {
27
26
  id
28
- key
29
27
  directory
30
28
  filename
31
29
  size
@@ -36,14 +34,13 @@ export const DOWNLOAD_SNAPSHOT = gql`
36
34
  `
37
35
 
38
36
  export const downloadDataset =
39
- (client) => async ({ datasetId, snapshotTag, tree = null }) => {
37
+ (client) => async ({ datasetId, snapshotTag }) => {
40
38
  if (snapshotTag) {
41
39
  const { data } = await client.query({
42
40
  query: DOWNLOAD_SNAPSHOT,
43
41
  variables: {
44
42
  datasetId,
45
43
  tag: snapshotTag,
46
- tree: tree,
47
44
  },
48
45
  })
49
46
  return data.snapshot.files
@@ -52,7 +49,6 @@ export const downloadDataset =
52
49
  query: DOWNLOAD_DATASET,
53
50
  variables: {
54
51
  datasetId,
55
- tree,
56
52
  },
57
53
  })
58
54
  return data.dataset.draft.files
@@ -64,7 +64,8 @@ export const GET_USER = gql`
64
64
  status
65
65
  }
66
66
  }
67
- orcidConsent
67
+ orcidConsent
68
+ profilePrivate
68
69
  }
69
70
  }
70
71
  `
@@ -74,22 +75,25 @@ export const UPDATE_USER = gql`
74
75
  mutation updateUser(
75
76
  $id: ID!
76
77
  $location: String
77
- $links: [String]
78
+ $links: [String!]
78
79
  $institution: String
79
- $orcidConsent: Boolean
80
+ $orcidConsent: Boolean
81
+ $profilePrivate: Boolean
80
82
  ) {
81
83
  updateUser(
82
84
  id: $id
83
85
  location: $location
84
86
  links: $links
85
87
  institution: $institution
86
- orcidConsent: $orcidConsent
88
+ orcidConsent: $orcidConsent
89
+ profilePrivate: $profilePrivate
87
90
  ) {
88
91
  id
89
92
  location
90
93
  links
91
94
  institution
92
95
  orcidConsent
96
+ profilePrivate
93
97
  }
94
98
  }
95
99
  `
@@ -207,8 +211,15 @@ export const ADVANCED_SEARCH_DATASETS_QUERY = gql`
207
211
  }
208
212
  `
209
213
 
214
+ interface UseUserOptions {
215
+ errorPolicy: "none" | "ignore" | "all"
216
+ }
217
+
210
218
  // Reusable hook to fetch user data
211
- export const useUser = (userId?: string) => {
219
+ export const useUser = (
220
+ userId?: string,
221
+ options: UseUserOptions = { errorPolicy: "none" },
222
+ ) => {
212
223
  const [cookies] = useCookies()
213
224
  const profile = getProfile(cookies)
214
225
  const profileSub = profile?.sub
@@ -222,6 +233,7 @@ export const useUser = (userId?: string) => {
222
233
  } = useQuery(GET_USER, {
223
234
  variables: { userId: finalUserId },
224
235
  skip: !finalUserId,
236
+ errorPolicy: options.errorPolicy,
225
237
  })
226
238
 
227
239
  if (userError) {
@@ -212,6 +212,7 @@ export const SearchResultItem = ({
212
212
  </Tooltip>
213
213
  )
214
214
 
215
+ // eslint-disable-next-line no-useless-assignment
215
216
  let invalid = false
216
217
  if (node.latestSnapshot.issues) {
217
218
  invalid = node.latestSnapshot.issues.some(
@@ -93,6 +93,7 @@ export const mapRawEventToMappedNotification = (
93
93
  reason,
94
94
  } = event
95
95
 
96
+ // eslint-disable-next-line no-useless-assignment
96
97
  let title = "General Notification"
97
98
  const mappedType: MappedNotification["type"] = type
98
99
  let approval: MappedNotification["approval"]
@@ -20,6 +20,7 @@ export interface User {
20
20
  githubSynced?: Date
21
21
  notifications?: Event[]
22
22
  orcidConsent?: boolean | null
23
+ profilePrivate?: boolean
23
24
  }
24
25
 
25
26
  export interface UserRoutesProps {
@@ -33,6 +34,7 @@ export interface UserCardProps {
33
34
 
34
35
  export interface UserAccountViewProps {
35
36
  orcidUser: User
37
+ hasEdit: boolean
36
38
  }
37
39
 
38
40
  /** ------------------ Dataset ------------------ */
@@ -68,7 +68,7 @@ describe("<UserAccountView />", () => {
68
68
  render(
69
69
  <BrowserRouter>
70
70
  <MockedProvider mocks={mocks} addTypename={false}>
71
- <UserAccountView orcidUser={baseUser} />
71
+ <UserAccountView orcidUser={baseUser} hasEdit={true} />
72
72
  </MockedProvider>
73
73
  </BrowserRouter>,
74
74
  )
@@ -106,7 +106,7 @@ describe("<UserAccountView />", () => {
106
106
  render(
107
107
  <BrowserRouter>
108
108
  <MockedProvider mocks={mocks} addTypename={false}>
109
- <UserAccountView orcidUser={baseUser} />
109
+ <UserAccountView orcidUser={baseUser} hasEdit={true} />
110
110
  </MockedProvider>
111
111
  </BrowserRouter>,
112
112
  )
@@ -146,7 +146,7 @@ describe("<UserAccountView />", () => {
146
146
  render(
147
147
  <BrowserRouter>
148
148
  <MockedProvider mocks={mocks} addTypename={false}>
149
- <UserAccountView orcidUser={baseUser} />
149
+ <UserAccountView orcidUser={baseUser} hasEdit={true} />
150
150
  </MockedProvider>,
151
151
  </BrowserRouter>,
152
152
  )
@@ -186,7 +186,7 @@ describe("<UserAccountView />", () => {
186
186
  render(
187
187
  <BrowserRouter>
188
188
  <MockedProvider mocks={mocks} addTypename={false}>
189
- <UserAccountView orcidUser={baseUser} />
189
+ <UserAccountView orcidUser={baseUser} hasEdit={true} />
190
190
  </MockedProvider>,
191
191
  </BrowserRouter>,
192
192
  )
@@ -226,7 +226,7 @@ describe("<UserAccountView />", () => {
226
226
  render(
227
227
  <BrowserRouter>
228
228
  <MockedProvider mocks={mocks} addTypename={false}>
229
- <UserAccountView orcidUser={baseUser} />
229
+ <UserAccountView orcidUser={baseUser} hasEdit={true} />
230
230
  </MockedProvider>,
231
231
  </BrowserRouter>,
232
232
  )
@@ -285,11 +285,6 @@ describe("UserRoutes Component", () => {
285
285
  expect(await screen.findByTestId("404-page")).toBeInTheDocument()
286
286
  })
287
287
 
288
- it("renders FourOThreePage for restricted route /account when hasEdit is false", async () => {
289
- setupUserRoutes(userToPass, "/account", false, true)
290
- expect(await screen.findByTestId("403-page")).toBeInTheDocument()
291
- })
292
-
293
288
  it("renders FourOThreePage for restricted route /notifications when hasEdit is false", async () => {
294
289
  setupUserRoutes(userToPass, "/notifications", false, true)
295
290
  expect(await screen.findByTestId("403-page")).toBeInTheDocument()
@@ -23,14 +23,14 @@ const UserAccountTabsWrapper: React.FC = () => {
23
23
  }
24
24
 
25
25
  describe("UserAccountTabs Component", () => {
26
- it("should not render tabs when hasEdit is false", () => {
26
+ it("should not notification render tab when hasEdit is false", () => {
27
27
  render(<UserAccountTabsWrapper />)
28
28
 
29
29
  expect(screen.getByText("My Datasets")).toBeInTheDocument()
30
30
 
31
31
  fireEvent.click(screen.getByText("Toggle hasEdit"))
32
32
 
33
- expect(screen.queryByText("My Datasets")).not.toBeInTheDocument()
33
+ expect(screen.queryByText("Notifications")).not.toBeInTheDocument()
34
34
  })
35
35
 
36
36
  it("should render tabs when hasEdit is toggled back to true", () => {
@@ -39,10 +39,10 @@ describe("UserAccountTabs Component", () => {
39
39
  expect(screen.getByText("My Datasets")).toBeInTheDocument()
40
40
 
41
41
  fireEvent.click(screen.getByText("Toggle hasEdit"))
42
- expect(screen.queryByText("My Datasets")).not.toBeInTheDocument()
42
+ expect(screen.queryByText("Notifications")).not.toBeInTheDocument()
43
43
 
44
44
  fireEvent.click(screen.getByText("Toggle hasEdit"))
45
- expect(screen.getByText("My Datasets")).toBeInTheDocument()
45
+ expect(screen.getByText("Notifications")).toBeInTheDocument()
46
46
  })
47
47
 
48
48
  it("should update active class on the correct NavLink based on route", () => {
@@ -13,6 +13,7 @@ interface EditableContentProps {
13
13
  heading: string
14
14
  validation?: RegExp | ((value: string) => boolean)
15
15
  validationMessage?: string
16
+ hasEdit?: boolean
16
17
  "data-testid"?: string
17
18
  }
18
19
 
@@ -23,6 +24,7 @@ export const EditableContent: React.FC<EditableContentProps> = ({
23
24
  heading,
24
25
  validation,
25
26
  validationMessage,
27
+ hasEdit,
26
28
  "data-testid": testId,
27
29
  }) => {
28
30
  const [editing, setEditing] = useState(false)
@@ -52,7 +54,7 @@ export const EditableContent: React.FC<EditableContentProps> = ({
52
54
  <h4>{heading}</h4>
53
55
  {editing
54
56
  ? <CloseButton action={closeEditing} />
55
- : <EditButton action={() => setEditing(true)} />}
57
+ : hasEdit && <EditButton action={() => setEditing(true)} />}
56
58
  </span>
57
59
  {editing
58
60
  ? (
@@ -0,0 +1,62 @@
1
+ import React, { useEffect, useState } from "react"
2
+ import * as Sentry from "@sentry/react"
3
+ import { useMutation } from "@apollo/client"
4
+ import { GET_USER, UPDATE_USER } from "../../queries/user"
5
+ import { RadioGroup } from "../../components/radio/RadioGroup"
6
+
7
+ export interface ProfilePrivacyProps {
8
+ userId: string
9
+ initialProfilePrivate: boolean
10
+ }
11
+
12
+ export const ProfilePrivacy: React.FC<ProfilePrivacyProps> = ({
13
+ userId,
14
+ initialProfilePrivate,
15
+ }) => {
16
+ const initialValue = initialProfilePrivate ? "true" : "false"
17
+ const [value, setValue] = useState(initialValue)
18
+
19
+ useEffect(() => {
20
+ setValue(initialProfilePrivate ? "true" : "false")
21
+ }, [initialProfilePrivate])
22
+
23
+ const [updateUser] = useMutation(UPDATE_USER, {
24
+ refetchQueries: [{ query: GET_USER, variables: { userId } }],
25
+ onError: (error) => {
26
+ Sentry.captureException(error)
27
+ },
28
+ })
29
+
30
+ const handleChange = async (newValue: string) => {
31
+ setValue(newValue)
32
+ try {
33
+ await updateUser({
34
+ variables: { id: userId, profilePrivate: newValue === "true" },
35
+ })
36
+ } catch (error) {
37
+ Sentry.captureException(error)
38
+ }
39
+ }
40
+
41
+ const radioOptions = [
42
+ { label: "Public", value: "false" },
43
+ { label: "Private", value: "true" },
44
+ ]
45
+
46
+ return (
47
+ <div>
48
+ <p>
49
+ Set your profile to private to hide your profile page from public view.
50
+ Your datasets and contributions will still be visible based on each
51
+ dataset's published state.
52
+ </p>
53
+ <RadioGroup
54
+ name="profilePrivate"
55
+ layout="row"
56
+ radioArr={radioOptions}
57
+ selected={value}
58
+ setSelected={handleChange}
59
+ />
60
+ </div>
61
+ )
62
+ }
@@ -8,11 +8,13 @@ import styles from "./scss/useraccountview.module.scss"
8
8
  import { GitHubAuthButton } from "./github-auth-button"
9
9
  import type { UserAccountViewProps } from "../types/user-types"
10
10
  import { OrcidConsentForm } from "./components/orcid-consent-form"
11
+ import { ProfilePrivacy } from "./components/profile-privacy"
11
12
  import { validateHttpHttpsUrl } from "../utils/validationUtils"
12
13
  import { pageTitle } from "../resources/strings.js"
13
14
 
14
15
  export const UserAccountView: React.FC<UserAccountViewProps> = ({
15
16
  orcidUser,
17
+ hasEdit,
16
18
  }) => {
17
19
  const [userLinks, setLinks] = useState<string[]>(orcidUser.links ?? [])
18
20
  const [userLocation, setLocation] = useState<string>(
@@ -89,7 +91,7 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
89
91
  <>
90
92
  <Helmet>
91
93
  <title>
92
- {orcidUser.name || "User"} profile - {pageTitle}
94
+ {orcidUser.name || "User"} profile - {pageTitle}
93
95
  </title>
94
96
  </Helmet>
95
97
  <div data-testid="user-account-view" className={styles.useraccountview}>
@@ -114,21 +116,26 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
114
116
  {orcidUser.github}
115
117
  </li>
116
118
  )}
117
- <li>
118
- <GitHubAuthButton sync={orcidUser.githubSynced} />
119
- </li>
119
+ {hasEdit && (
120
+ <li>
121
+ <GitHubAuthButton sync={orcidUser.githubSynced} />
122
+ </li>
123
+ )}
120
124
  </ul>
121
125
 
122
- <EditableContent
123
- editableContent={userLinks}
124
- setRows={handleLinksChange}
125
- heading="Links"
126
- validation={validateHttpHttpsUrl}
127
- validationMessage="Invalid URL format. Please start with http:// or https://"
128
- data-testid="links-section"
129
- />
126
+ {hasEdit && (
127
+ <div className={styles.umbOrcidConsent}>
128
+ <div className={styles.umbOrcidHeading}>
129
+ <h4>Profile Privacy</h4>
130
+ </div>
131
+ <ProfilePrivacy
132
+ userId={orcidUser.id}
133
+ initialProfilePrivate={orcidUser.profilePrivate ?? false}
134
+ />
135
+ </div>
136
+ )}
130
137
 
131
- {orcidUser.orcid !== undefined && (
138
+ {hasEdit && orcidUser.orcid !== undefined && (
132
139
  <div className={styles.umbOrcidConsent}>
133
140
  <div className={styles.umbOrcidHeading}>
134
141
  <h4>ORCID Integration</h4>
@@ -140,15 +147,26 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
140
147
  </div>
141
148
  )}
142
149
 
150
+ <EditableContent
151
+ editableContent={userLinks}
152
+ setRows={handleLinksChange}
153
+ heading="Links"
154
+ validation={validateHttpHttpsUrl}
155
+ validationMessage="Invalid URL format. Please start with http:// or https://"
156
+ data-testid="links-section"
157
+ hasEdit={hasEdit}
158
+ />
143
159
  <EditableContent
144
160
  editableContent={userLocation}
145
161
  setRows={handleLocationChange}
146
162
  heading="Location"
163
+ hasEdit={hasEdit}
147
164
  data-testid="location-section"
148
165
  />
149
166
  <EditableContent
150
167
  editableContent={userInstitution}
151
168
  setRows={handleInstitutionChange}
169
+ hasEdit={hasEdit}
152
170
  heading="Institution"
153
171
  data-testid="institution-section"
154
172
  />
@@ -1,6 +1,8 @@
1
1
  import React, { useCallback, useEffect, useState } from "react"
2
2
  import { useQuery } from "@apollo/client"
3
3
  import * as Sentry from "@sentry/react"
4
+ import Helmet from "react-helmet"
5
+ import { pageTitle } from "../resources/strings"
4
6
  import { DatasetCard } from "./dataset-card"
5
7
  import { UserDatasetFilters } from "./components/user-dataset-filters"
6
8
  import { ADVANCED_SEARCH_DATASETS_QUERY } from "../queries/user"
@@ -260,6 +262,11 @@ export const UserDatasetsView: React.FC<UserDatasetsViewProps> = ({
260
262
  className={styles.userDatasetsWrapper}
261
263
  data-testid="user-datasets-view"
262
264
  >
265
+ <Helmet>
266
+ <title>
267
+ {orcidUser.name || "User"}'s Datasets - {pageTitle}
268
+ </title>
269
+ </Helmet>
263
270
  <h3>{orcidUser.name}'s Datasets</h3>
264
271
 
265
272
  <UserDatasetFilters
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom"
3
3
  import { Dropdown } from "../components/dropdown/Dropdown"
4
4
  import { useUser } from "../queries/user"
5
5
  import { useNotifications } from "./notifications/user-notifications-context"
6
+ import orcidIcon from "../../assets/orcid_24x24.png"
6
7
  import "./scss/user-menu.scss"
7
8
 
8
9
  interface UserMenuListProps {
@@ -101,10 +102,18 @@ export const UserMenu: React.FC<UserMenuProps> = ({ signOutAndRedirect }) => {
101
102
  <p>
102
103
  <span>Hello</span> <br />
103
104
  {user.name} <br />
104
- {user.email}
105
105
  </p>
106
106
  <p>
107
107
  <span>signed in via {user.provider}</span>
108
+ {user?.orcid && (
109
+ <a
110
+ href={`https://orcid.org/${user.orcid}`}
111
+ target="_blank"
112
+ rel="noopener noreferrer"
113
+ >
114
+ <img src={orcidIcon} alt="ORCID iD" /> {user.orcid}
115
+ </a>
116
+ ) || user.email}
108
117
  </p>
109
118
  </li>
110
119
 
@@ -7,24 +7,24 @@ import { isAdmin } from "../authentication/admin-user"
7
7
  import { useCookies } from "react-cookie"
8
8
  import { getProfile } from "../authentication/profile"
9
9
  import { useUser } from "../queries/user"
10
+ import FourOThreePage from "../errors/403page"
10
11
 
11
12
  export const UserQuery: React.FC = () => {
12
13
  const { orcid } = useParams()
13
- const isOrcidValid = orcid && isValidOrcid(orcid)
14
- const { user, loading, error } = useUser(orcid)
14
+ const { user, loading } = useUser(orcid, { errorPolicy: "all" })
15
15
 
16
16
  const [cookies] = useCookies()
17
17
  const profile = getProfile(cookies)
18
18
  const isAdminUser = isAdmin()
19
19
 
20
- if (!isOrcidValid) {
20
+ if (!isValidOrcid(orcid)) {
21
21
  return <FourOFourPage />
22
22
  }
23
23
 
24
24
  if (loading) return <div>Loading...</div>
25
25
 
26
- if (error || !user) {
27
- return <FourOFourPage />
26
+ if (!profile?.admin && user.profilePrivate) {
27
+ return <FourOThreePage />
28
28
  }
29
29
 
30
30
  // is admin or profile matches id from the user data being returned
@@ -60,9 +60,9 @@ export const UserRoutes: React.FC<UserRoutesProps> = (
60
60
  {/* This route handles the user account page */}
61
61
  <Route
62
62
  path="account"
63
- element={hasEdit
64
- ? <UserAccountView orcidUser={orcidUser} />
65
- : <FourOThreePage />}
63
+ element={
64
+ <UserAccountView orcidUser={orcidUser} hasEdit={hasEdit} />
65
+ }
66
66
  />
67
67
  {/* This route handles the user notifications and its sub-routes */}
68
68
  <Route
@@ -29,8 +29,6 @@ export const UserAccountTabs: React.FC<UserAccountTabsProps> = (
29
29
  setClicked(true)
30
30
  }
31
31
 
32
- if (!hasEdit) return null
33
-
34
32
  return (
35
33
  <div className={styles.userAccountTabLinks}>
36
34
  <ul
@@ -50,16 +48,19 @@ export const UserAccountTabs: React.FC<UserAccountTabsProps> = (
50
48
  {isUser ? "My" : "User"} Datasets
51
49
  </NavLink>
52
50
  </li>
53
- <li>
54
- <NavLink
55
- data-testid="user-notifications-tab"
56
- to="notifications"
57
- className={({ isActive }) => (isActive ? styles.active : "")}
58
- onClick={handleClick}
59
- >
60
- Notifications
61
- </NavLink>
62
- </li>
51
+
52
+ {hasEdit && (
53
+ <li>
54
+ <NavLink
55
+ data-testid="user-notifications-tab"
56
+ to="notifications"
57
+ className={({ isActive }) => (isActive ? styles.active : "")}
58
+ onClick={handleClick}
59
+ >
60
+ Notifications
61
+ </NavLink>
62
+ </li>
63
+ )}
63
64
 
64
65
  <li>
65
66
  <NavLink
@@ -24,6 +24,7 @@ export function filterAndSortDatasets(
24
24
  })
25
25
 
26
26
  const sortedDatasets = filteredDatasets.sort((a, b) => {
27
+ // eslint-disable-next-line no-useless-assignment
27
28
  let comparisonResult = 0
28
29
 
29
30
  // Move declarations outside the switch block
@@ -1,4 +1,3 @@
1
- /* eslint-env worker */
2
1
  import { fileListToTree, validate } from "@bids/validator/main"
3
2
  import type { ValidationResult } from "@bids/validator/main"
4
3
  import type { Config, ValidatorOptions } from "@bids/validator/options"