@openneuro/app 5.0.0 → 5.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/app",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
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": "e3444f63f43cb7e9d3ecfac51d2a42cdcc4f4e60"
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
@@ -207,8 +207,15 @@ export const ADVANCED_SEARCH_DATASETS_QUERY = gql`
207
207
  }
208
208
  `
209
209
 
210
+ interface UseUserOptions {
211
+ errorPolicy: "none" | "ignore" | "all"
212
+ }
213
+
210
214
  // Reusable hook to fetch user data
211
- export const useUser = (userId?: string) => {
215
+ export const useUser = (
216
+ userId?: string,
217
+ options: UseUserOptions = { errorPolicy: "none" },
218
+ ) => {
212
219
  const [cookies] = useCookies()
213
220
  const profile = getProfile(cookies)
214
221
  const profileSub = profile?.sub
@@ -222,6 +229,7 @@ export const useUser = (userId?: string) => {
222
229
  } = useQuery(GET_USER, {
223
230
  variables: { userId: finalUserId },
224
231
  skip: !finalUserId,
232
+ errorPolicy: options.errorPolicy,
225
233
  })
226
234
 
227
235
  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"]
@@ -33,6 +33,7 @@ export interface UserCardProps {
33
33
 
34
34
  export interface UserAccountViewProps {
35
35
  orcidUser: User
36
+ hasEdit: boolean
36
37
  }
37
38
 
38
39
  /** ------------------ 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
  ? (
@@ -13,6 +13,7 @@ import { pageTitle } from "../resources/strings.js"
13
13
 
14
14
  export const UserAccountView: React.FC<UserAccountViewProps> = ({
15
15
  orcidUser,
16
+ hasEdit,
16
17
  }) => {
17
18
  const [userLinks, setLinks] = useState<string[]>(orcidUser.links ?? [])
18
19
  const [userLocation, setLocation] = useState<string>(
@@ -89,7 +90,7 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
89
90
  <>
90
91
  <Helmet>
91
92
  <title>
92
- {orcidUser.name || "User"} profile - {pageTitle}
93
+ {orcidUser.name || "User"} profile - {pageTitle}
93
94
  </title>
94
95
  </Helmet>
95
96
  <div data-testid="user-account-view" className={styles.useraccountview}>
@@ -126,9 +127,10 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
126
127
  validation={validateHttpHttpsUrl}
127
128
  validationMessage="Invalid URL format. Please start with http:// or https://"
128
129
  data-testid="links-section"
130
+ hasEdit={hasEdit}
129
131
  />
130
132
 
131
- {orcidUser.orcid !== undefined && (
133
+ {hasEdit && orcidUser.orcid !== undefined && (
132
134
  <div className={styles.umbOrcidConsent}>
133
135
  <div className={styles.umbOrcidHeading}>
134
136
  <h4>ORCID Integration</h4>
@@ -144,11 +146,13 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
144
146
  editableContent={userLocation}
145
147
  setRows={handleLocationChange}
146
148
  heading="Location"
149
+ hasEdit={hasEdit}
147
150
  data-testid="location-section"
148
151
  />
149
152
  <EditableContent
150
153
  editableContent={userInstitution}
151
154
  setRows={handleInstitutionChange}
155
+ hasEdit={hasEdit}
152
156
  heading="Institution"
153
157
  data-testid="institution-section"
154
158
  />
@@ -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
 
@@ -10,23 +10,18 @@ import { useUser } from "../queries/user"
10
10
 
11
11
  export const UserQuery: React.FC = () => {
12
12
  const { orcid } = useParams()
13
- const isOrcidValid = orcid && isValidOrcid(orcid)
14
- const { user, loading, error } = useUser(orcid)
13
+ const { user, loading } = useUser(orcid, { errorPolicy: "all" })
15
14
 
16
15
  const [cookies] = useCookies()
17
16
  const profile = getProfile(cookies)
18
17
  const isAdminUser = isAdmin()
19
18
 
20
- if (!isOrcidValid) {
19
+ if (!isValidOrcid(orcid)) {
21
20
  return <FourOFourPage />
22
21
  }
23
22
 
24
23
  if (loading) return <div>Loading...</div>
25
24
 
26
- if (error || !user) {
27
- return <FourOFourPage />
28
- }
29
-
30
25
  // is admin or profile matches id from the user data being returned
31
26
  const isUser = (user?.id === profile?.sub) ? true : false
32
27
  const hasEdit = isAdminUser || (user?.id === profile?.sub) ? true : false
@@ -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"