@openneuro/app 4.47.6 → 5.0.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 (41) hide show
  1. package/package.json +2 -2
  2. package/src/client.jsx +6 -4
  3. package/src/scripts/authentication/__tests__/profile.spec.js +79 -13
  4. package/src/scripts/authentication/profile.ts +9 -0
  5. package/src/scripts/contributors/contributor.tsx +3 -2
  6. package/src/scripts/contributors/contributors-list.tsx +0 -1
  7. package/src/scripts/datalad/dataset/dataset-query-fragments.js +0 -2
  8. package/src/scripts/datalad/mutations/delete-comment.jsx +1 -1
  9. package/src/scripts/dataset/components/dataset-event-item.tsx +4 -4
  10. package/src/scripts/dataset/download/__tests__/download-script.spec.tsx +1 -1
  11. package/src/scripts/dataset/download/download-script.tsx +21 -10
  12. package/src/scripts/dataset/files/__tests__/file-tree-unloaded-directory.spec.jsx +4 -4
  13. package/src/scripts/dataset/files/file-tree-unloaded-directory.jsx +2 -5
  14. package/src/scripts/dataset/files/file-tree.tsx +1 -1
  15. package/src/scripts/dataset/mutations/delete-file.jsx +11 -16
  16. package/src/scripts/dataset/mutations/hold-deletion.tsx +57 -0
  17. package/src/scripts/dataset/routes/__tests__/snapshot.spec.tsx +56 -0
  18. package/src/scripts/dataset/routes/admin-datalad.jsx +10 -0
  19. package/src/scripts/dataset/routes/snapshot.tsx +8 -2
  20. package/src/scripts/pages/front-page/aggregate-queries/use-publicDatasets-count.ts +2 -4
  21. package/src/scripts/queries/dataset.ts +1 -1
  22. package/src/scripts/queries/datasetEvents.ts +2 -2
  23. package/src/scripts/queries/user.ts +2 -12
  24. package/src/scripts/search/inputs/__tests__/sort-by-select.spec.tsx +4 -26
  25. package/src/scripts/search/inputs/sort-by-select.tsx +6 -6
  26. package/src/scripts/search/use-search-results.tsx +38 -256
  27. package/src/scripts/types/event-types.ts +21 -8
  28. package/src/scripts/users/__tests__/user-routes.spec.tsx +2 -11
  29. package/src/scripts/users/notifications/user-notification-accordion-actions.tsx +5 -5
  30. package/src/scripts/users/notifications/user-notification-accordion-header.tsx +4 -1
  31. package/src/scripts/users/notifications/user-notification-accordion.tsx +12 -5
  32. package/src/scripts/users/notifications/user-notification-reason-input.tsx +6 -3
  33. package/src/scripts/users/notifications/user-notifications-accordion-body.tsx +3 -3
  34. package/src/scripts/users/user-datasets-view.tsx +83 -164
  35. package/src/scripts/users/user-menu.tsx +1 -1
  36. package/src/scripts/datalad/dataset/comments-fragments.js +0 -22
  37. package/src/scripts/datalad/mutations/follow.jsx +0 -54
  38. package/src/scripts/datalad/mutations/publish.jsx +0 -58
  39. package/src/scripts/datalad/mutations/star.jsx +0 -54
  40. package/src/scripts/pages/admin/user-fragment.ts +0 -21
  41. package/src/scripts/search/es-query-builders.ts +0 -107
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/app",
3
- "version": "4.47.6",
3
+ "version": "5.0.0-alpha.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": "e00e369a538900524544a396e7fd60e5e41d715e"
82
+ "gitHead": "ee5b58a1623626ef79a439918e6c065a1c7d5e2e"
83
83
  }
package/src/client.jsx CHANGED
@@ -15,11 +15,10 @@ import { relayStylePagination } from "@apollo/client/utilities"
15
15
  // TODO - This should be a global SCSS?
16
16
  import "./scripts/components/page/page.scss"
17
17
  import cookies from "./scripts/utils/cookies.js"
18
- import { getProfile, guardExpired } from "./scripts/authentication/profile"
18
+ import { shouldRemoveToken } from "./scripts/authentication/profile"
19
19
 
20
- // Clear expired JWT before the app mounts to avoid stale auth state
21
- const profile = getProfile(cookies.getAll())
22
- if (profile && !guardExpired(profile)) {
20
+ // Clear expired or corrupted JWT before the app mounts to avoid stale auth state
21
+ if (shouldRemoveToken(cookies.getAll())) {
23
22
  cookies.remove("accessToken", { path: "/" })
24
23
  }
25
24
 
@@ -36,6 +35,9 @@ const client = new ApolloClient({
36
35
  advancedSearch: relayStylePagination(),
37
36
  },
38
37
  },
38
+ DatasetFile: {
39
+ keyFields: false,
40
+ },
39
41
  },
40
42
  }),
41
43
  connectToDevTools: config.sentry.environment !== "production",
@@ -1,23 +1,89 @@
1
- import { parseJwt } from "../profile"
1
+ import {
2
+ getProfile,
3
+ guardExpired,
4
+ parseJwt,
5
+ shouldRemoveToken,
6
+ } from "../profile"
7
+
8
+ const header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
9
+ const dummySig = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
2
10
 
3
11
  const asciiToken =
4
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
12
+ `${header}.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.${dummySig}`
5
13
  const utf8Token =
6
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Iuelnue1jOenkeWtpuiAhSIsImlhdCI6MTUxNjIzOTAyMn0.pUw2ARoXv4LkJXB1ZR3Th6xG83URT6mn1TftC7ac_O8"
14
+ `${header}.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Iuelnue1jOenkeWtpuiAhSIsImlhdCI6MTUxNjIzOTAyMn0.${dummySig}`
15
+
16
+ // Token with exp far in the future
17
+ const validToken =
18
+ `${header}.eyJzdWIiOiJ1c2VyMTIzIiwiYWRtaW4iOmZhbHNlLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6OTk5OTk5OTk5OX0.${dummySig}`
19
+ // Token with exp = 1 (long expired)
20
+ const expiredToken =
21
+ `${header}.eyJzdWIiOiJ1c2VyMTIzIiwiYWRtaW4iOmZhbHNlLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MX0.${dummySig}`
7
22
 
8
23
  describe("authentication/profile", () => {
9
- it("decodes a JWT to Javascript object", () => {
10
- expect(parseJwt(asciiToken)).toEqual({
11
- sub: "1234567890",
12
- name: "John Doe",
13
- iat: 1516239022,
24
+ describe("parseJwt", () => {
25
+ it("decodes a JWT to Javascript object", () => {
26
+ expect(parseJwt(asciiToken)).toEqual({
27
+ sub: "1234567890",
28
+ name: "John Doe",
29
+ iat: 1516239022,
30
+ })
31
+ })
32
+ it("decodes a JWT with Unicode strings", () => {
33
+ expect(parseJwt(utf8Token)).toEqual({
34
+ sub: "1234567890",
35
+ name: "神経科学者",
36
+ iat: 1516239022,
37
+ })
38
+ })
39
+ })
40
+
41
+ describe("getProfile", () => {
42
+ it("returns null when no accessToken cookie exists", () => {
43
+ expect(getProfile({})).toBeNull()
44
+ })
45
+ it("returns null for a corrupted token", () => {
46
+ expect(getProfile({ accessToken: "not-a-jwt" })).toBeNull()
47
+ })
48
+ it("returns the decoded profile for a valid token", () => {
49
+ const profile = getProfile({ accessToken: validToken })
50
+ expect(profile).toMatchObject({
51
+ sub: "user123",
52
+ admin: false,
53
+ exp: 9999999999,
54
+ })
55
+ })
56
+ })
57
+
58
+ describe("guardExpired", () => {
59
+ it("returns true for an unexpired token", () => {
60
+ const profile = getProfile({ accessToken: validToken })
61
+ expect(guardExpired(profile)).toBe(true)
62
+ })
63
+ it("returns false for an expired token", () => {
64
+ const profile = getProfile({ accessToken: expiredToken })
65
+ expect(guardExpired(profile)).toBe(false)
66
+ })
67
+ it("returns false for a null profile", () => {
68
+ expect(guardExpired(null)).toBe(false)
14
69
  })
15
70
  })
16
- it("decodes a JWT with Unicode strings", () => {
17
- expect(parseJwt(utf8Token)).toEqual({
18
- sub: "1234567890",
19
- name: "神経科学者",
20
- iat: 1516239022,
71
+
72
+ describe("shouldRemoveToken", () => {
73
+ it("removes nothing when no token is present", () => {
74
+ expect(shouldRemoveToken({})).toBe(false)
75
+ })
76
+ it("keeps a valid unexpired token", () => {
77
+ expect(shouldRemoveToken({ accessToken: validToken })).toBe(false)
78
+ })
79
+ it("removes an expired token", () => {
80
+ expect(shouldRemoveToken({ accessToken: expiredToken })).toBe(true)
81
+ })
82
+ it("removes a corrupted token", () => {
83
+ expect(shouldRemoveToken({ accessToken: "garbage" })).toBe(true)
84
+ })
85
+ it("removes an empty string token", () => {
86
+ expect(shouldRemoveToken({ accessToken: "" })).toBe(false)
21
87
  })
22
88
  })
23
89
  })
@@ -36,6 +36,15 @@ export const getUnexpiredProfile = (cookies) => {
36
36
  if (guardExpired(profile)) return profile
37
37
  }
38
38
 
39
+ /**
40
+ * Check if the accessToken cookie should be removed due to expiration or corruption
41
+ */
42
+ export function shouldRemoveToken(cookies): boolean {
43
+ const profile = getProfile(cookies)
44
+ const hasToken = !!cookies["accessToken"]
45
+ return hasToken && (!profile || !guardExpired(profile))
46
+ }
47
+
39
48
  /**
40
49
  * Test for an expired token
41
50
  * @param {object} profile A profile returned by getProfile()
@@ -2,11 +2,12 @@ import React from "react"
2
2
  import { Link } from "react-router-dom"
3
3
  import { useUser } from "../queries/user"
4
4
  import type { Contributor } from "../types/datacite"
5
+ import type { RequestStatus } from "../types/event-types.ts"
5
6
  import ORCIDiDLogo from "../../assets/ORCIDiD_iconvector.svg"
6
7
 
7
8
  interface SingleContributorDisplayProps {
8
9
  contributor: Contributor & {
9
- resolutionStatus?: "pending" | "accepted" | "denied"
10
+ resolutionStatus?: RequestStatus
10
11
  }
11
12
  isLast: boolean
12
13
  separator: React.ReactNode
@@ -34,7 +35,7 @@ export const SingleContributorDisplay: React.FC<SingleContributorDisplayProps> =
34
35
  const userExists = !!user?.id
35
36
 
36
37
  // TODO get resolutionStatus from event/contributor
37
- const resolutionAccepted = true //contributor.resolutionStatus === "accepted"
38
+ const resolutionAccepted = true //contributor.resolutionStatus === "ACCEPTED"
38
39
 
39
40
  return (
40
41
  <>
@@ -44,7 +44,6 @@ const UPDATE_CONTRIBUTORS = gql`
44
44
  files {
45
45
  id
46
46
  filename
47
- key
48
47
  size
49
48
  annexed
50
49
  urls
@@ -77,7 +77,6 @@ export const DRAFT_FILES_FRAGMENT = gql`
77
77
  id
78
78
  files {
79
79
  id
80
- key
81
80
  filename
82
81
  size
83
82
  directory
@@ -202,7 +201,6 @@ export const SNAPSHOT_FIELDS = gql`
202
201
  }
203
202
  files {
204
203
  id
205
- key
206
204
  filename
207
205
  size
208
206
  directory
@@ -2,7 +2,7 @@ import React from "react"
2
2
  import PropTypes from "prop-types"
3
3
  import { gql } from "@apollo/client"
4
4
  import { Mutation } from "@apollo/client/react/components"
5
- import { DATASET_COMMENTS } from "../dataset/comments-fragments.js"
5
+ import { DATASET_COMMENTS } from "../../dataset/fragments/comments-fragments.js"
6
6
  import { datasetCacheId } from "./cache-id.js"
7
7
 
8
8
  const deleteComment = gql`
@@ -102,7 +102,7 @@ export const DatasetEventItem: React.FC<DatasetEventItemProps> = ({
102
102
  )
103
103
  } else {
104
104
  if (event.event.type === "contributorResponse") {
105
- const statusText = event.event.resolutionStatus === "accepted"
105
+ const statusText = event.event.resolutionStatus === "ACCEPTED"
106
106
  ? "Accepted"
107
107
  : "Denied"
108
108
  return (
@@ -160,7 +160,7 @@ export const DatasetEventItem: React.FC<DatasetEventItemProps> = ({
160
160
  }
161
161
  }
162
162
 
163
- const handleSaveOrProcessRequest = async (status?: "accepted" | "denied") => {
163
+ const handleSaveOrProcessRequest = async (status?: "ACCEPTED" | "DENIED") => {
164
164
  if (status) {
165
165
  if (!event.user?.id) {
166
166
  toast.error("Cannot process request: User ID not found.")
@@ -239,14 +239,14 @@ export const DatasetEventItem: React.FC<DatasetEventItemProps> = ({
239
239
  {editingNoteId === event.id && (
240
240
  <>
241
241
  <button
242
- onClick={() => handleSaveOrProcessRequest("accepted")}
242
+ onClick={() => handleSaveOrProcessRequest("ACCEPTED")}
243
243
  className={`${styles.eventActionButton} on-button on-button--small on-button--secondary`}
244
244
  style={{ marginBottom: "5px" }}
245
245
  >
246
246
  Accept
247
247
  </button>
248
248
  <button
249
- onClick={() => handleSaveOrProcessRequest("denied")}
249
+ onClick={() => handleSaveOrProcessRequest("DENIED")}
250
250
  className={`${styles.eventActionButton} on-button on-button--small`}
251
251
  >
252
252
  Deny
@@ -11,7 +11,7 @@ describe("DownloadScript", () => {
11
11
  const mockData = {
12
12
  snapshot: {
13
13
  id: "ds000001:1.0.0",
14
- downloadFiles: [
14
+ files: [
15
15
  {
16
16
  id: "a2776e2e194d72419638d7611ddef7efa9c9f643",
17
17
  directory: false,
@@ -24,7 +24,7 @@ export function generateDownloadScript(data): string {
24
24
  // ds000001:1.0.0 -> ds000001-1.0.0 for directories
25
25
  const directory = data.snapshot.id.split(":").join("-")
26
26
  let script = "#!/bin/sh\n"
27
- for (const f of data.snapshot.downloadFiles) {
27
+ for (const f of data.snapshot.files) {
28
28
  script += `curl --create-dirs ${f.urls[0]} -o ${directory}/${f.filename}\n`
29
29
  }
30
30
  return script
@@ -39,7 +39,7 @@ export const getSnapshotDownload = gql`
39
39
  query snapshot($datasetId: ID!, $tag: String!) {
40
40
  snapshot(datasetId: $datasetId, tag: $tag) {
41
41
  id
42
- downloadFiles {
42
+ files(recursive: true) {
43
43
  id
44
44
  directory
45
45
  filename
@@ -54,14 +54,17 @@ export const DownloadScript = ({
54
54
  datasetId,
55
55
  snapshotTag,
56
56
  }: DownloadS3DerivativesProps): JSX.Element => {
57
- const [getDownload, { loading, data }] = useLazyQuery(getSnapshotDownload, {
58
- variables: {
59
- datasetId: datasetId,
60
- tag: snapshotTag,
57
+ const [getDownload, { loading, data, error }] = useLazyQuery(
58
+ getSnapshotDownload,
59
+ {
60
+ variables: {
61
+ datasetId: datasetId,
62
+ tag: snapshotTag,
63
+ },
64
+ errorPolicy: "all",
61
65
  },
62
- errorPolicy: "all",
63
- })
64
- if (data) {
66
+ )
67
+ if (data?.snapshot?.files) {
65
68
  const script = generateDownloadScript(data)
66
69
  inlineDownload(`${datasetId}-${snapshotTag}.sh`, script)
67
70
  }
@@ -76,10 +79,18 @@ export const DownloadScript = ({
76
79
  Node.js.
77
80
  </p>
78
81
  <p>
79
- {loading
82
+ {error
83
+ ? (
84
+ "Failed to generate download script."
85
+ )
86
+ : loading
80
87
  ? (
81
88
  "Loading..."
82
89
  )
90
+ : data?.snapshot && !data.snapshot.files
91
+ ? (
92
+ "Download is not yet available for this version. Please try again later."
93
+ )
83
94
  : (
84
95
  <a
85
96
  href="#"
@@ -38,7 +38,7 @@ describe("FileTreeUnloadedDirectory component", () => {
38
38
  expect(
39
39
  mergeNewFiles(dir)(defaultObj, { fetchMoreResult: updatedObj }).dataset
40
40
  .draft.files,
41
- ).toEqual([a, b, { ...c, id: "sub-01:91011", filename: "sub-01:c" }])
41
+ ).toEqual([a, b, { ...c, id: "91011", filename: "sub-01:c" }])
42
42
  })
43
43
  it("works with snapshots", () => {
44
44
  const dir = { filename: "sub-01", directory: true }
@@ -52,7 +52,7 @@ describe("FileTreeUnloadedDirectory component", () => {
52
52
  .snapshot.files,
53
53
  ).toEqual([dir, a, b, {
54
54
  ...c,
55
- id: "sub-01:91011",
55
+ id: "91011",
56
56
  filename: "sub-01:c",
57
57
  }])
58
58
  })
@@ -67,7 +67,7 @@ describe("FileTreeUnloadedDirectory component", () => {
67
67
  .snapshot.files,
68
68
  ).toEqual([{
69
69
  ...c,
70
- id: "sub-01:91011",
70
+ id: "91011",
71
71
  filename: "sub-01:c",
72
72
  }])
73
73
  })
@@ -83,7 +83,7 @@ describe("FileTreeUnloadedDirectory component", () => {
83
83
  .dataset.draft.files,
84
84
  ).toEqual([{
85
85
  ...c,
86
- id: "sub-01:91011",
86
+ id: "91011",
87
87
  filename: "sub-01:c",
88
88
  }])
89
89
  })
@@ -10,7 +10,6 @@ export const DRAFT_FILES_QUERY = gql`
10
10
  draft {
11
11
  files(tree: $tree) {
12
12
  id
13
- key
14
13
  filename
15
14
  size
16
15
  directory
@@ -27,7 +26,6 @@ export const SNAPSHOT_FILES_QUERY = gql`
27
26
  snapshot(datasetId: $datasetId, tag: $snapshotTag) {
28
27
  files(tree: $tree) {
29
28
  id
30
- key
31
29
  filename
32
30
  size
33
31
  directory
@@ -43,8 +41,7 @@ export const SNAPSHOT_FILES_QUERY = gql`
43
41
  */
44
42
  export const nestFiles = (path) => (file) => ({
45
43
  ...file,
46
- // Generate a unique id for any nested files to avoid overwriting trees with two parents
47
- id: `${path}:${file.id}`,
44
+ id: file.id,
48
45
  filename: `${path}:${file.filename}`,
49
46
  })
50
47
 
@@ -85,7 +82,7 @@ export const fetchMoreDirectory = (
85
82
  ) =>
86
83
  fetchMore({
87
84
  query: snapshotTag ? SNAPSHOT_FILES_QUERY : DRAFT_FILES_QUERY,
88
- variables: { datasetId, snapshotTag, tree: directory.key },
85
+ variables: { datasetId, snapshotTag, tree: directory.id },
89
86
  updateQuery: mergeNewFiles(directory, snapshotTag),
90
87
  })
91
88
 
@@ -146,7 +146,7 @@ const FileTree = ({
146
146
  toggleFileToDelete={toggleFileToDelete}
147
147
  isFileToBeDeleted={isFileToBeDeleted}
148
148
  filename={file.filename.split(":").pop()}
149
- annexKey={file.key}
149
+ annexKey={file.id}
150
150
  datasetPermissions={datasetPermissions}
151
151
  annexed={file.annexed}
152
152
  isMobile={false}
@@ -9,6 +9,15 @@ const DELETE_FILE = gql`
9
9
  }
10
10
  `
11
11
 
12
+ const DELETED_FILE_FRAGMENT = gql`
13
+ fragment DeletedFile on DatasetFile {
14
+ id
15
+ key
16
+ filename
17
+ directory
18
+ }
19
+ `
20
+
12
21
  /**
13
22
  * Given a file object, path/filename for deletion, and a list of currently loaded files, filter any that will be deleted and orphan directories
14
23
  */
@@ -48,28 +57,14 @@ const DeleteFile = ({ datasetId, path, filename }) => {
48
57
  const cachedFileObjects = cachedFiles.map((f) =>
49
58
  cache.readFragment({
50
59
  id: cache.identify(f),
51
- fragment: gql`
52
- fragment DeletedFile on DatasetFile {
53
- id
54
- key
55
- filename
56
- directory
57
- }
58
- `,
60
+ fragment: DELETED_FILE_FRAGMENT,
59
61
  })
60
62
  )
61
63
  const remainingFiles = cachedFiles.filter((f) => {
62
64
  // Get the cache key for each file we have loaded
63
65
  const file = cache.readFragment({
64
66
  id: cache.identify(f),
65
- fragment: gql`
66
- fragment DeletedFile on DatasetFile {
67
- id
68
- key
69
- filename
70
- directory
71
- }
72
- `,
67
+ fragment: DELETED_FILE_FRAGMENT,
73
68
  })
74
69
  return fileCacheDeleteFilter(
75
70
  file,
@@ -0,0 +1,57 @@
1
+ import React from "react"
2
+ import { gql, useMutation, useQuery } from "@apollo/client"
3
+ import { Button } from "../../components/button/Button"
4
+
5
+ const GET_HOLD_DELETION = gql`
6
+ query getHoldDeletion($datasetId: ID!) {
7
+ dataset(id: $datasetId) {
8
+ id
9
+ holdDeletion
10
+ }
11
+ }
12
+ `
13
+
14
+ const HOLD_DELETION = gql`
15
+ mutation holdDeletion($datasetId: ID!, $hold: Boolean!) {
16
+ holdDeletion(datasetId: $datasetId, hold: $hold)
17
+ }
18
+ `
19
+
20
+ export const HoldDeletion = ({ datasetId }: { datasetId: string }) => {
21
+ const { data, loading: queryLoading } = useQuery(GET_HOLD_DELETION, {
22
+ variables: { datasetId },
23
+ })
24
+ const [holdDeletion, { loading: mutationLoading }] = useMutation(
25
+ HOLD_DELETION,
26
+ {
27
+ refetchQueries: [{ query: GET_HOLD_DELETION, variables: { datasetId } }],
28
+ },
29
+ )
30
+
31
+ const held = data?.dataset?.holdDeletion ?? false
32
+ const loading = queryLoading || mutationLoading
33
+
34
+ return (
35
+ <span>
36
+ <Button
37
+ icon={loading
38
+ ? "fa fa-spin fa-repeat"
39
+ : held
40
+ ? "fa fa-lock"
41
+ : "fa fa-unlock"}
42
+ label={loading
43
+ ? "Updating..."
44
+ : held
45
+ ? "Deletion Held"
46
+ : "Hold Deletion"}
47
+ primary={!held}
48
+ secondary={held}
49
+ size="small"
50
+ disabled={loading}
51
+ onClick={() => {
52
+ holdDeletion({ variables: { datasetId, hold: !held } })
53
+ }}
54
+ />
55
+ </span>
56
+ )
57
+ }
@@ -0,0 +1,56 @@
1
+ import React from "react"
2
+ import { cleanup, render, screen } from "@testing-library/react"
3
+ import { vi } from "vitest"
4
+ import { NoErrors } from "../snapshot"
5
+
6
+ const mockUseUser = vi.fn()
7
+
8
+ vi.mock("../../../queries/user", () => ({
9
+ useUser: () => mockUseUser(),
10
+ }))
11
+
12
+ vi.mock("../../mutations/fsck-dataset", () => ({
13
+ FsckDataset: ({ disabled }) => (
14
+ <button data-testid="mock-fsck-button" disabled={disabled}>
15
+ Rerun File Checks
16
+ </button>
17
+ ),
18
+ }))
19
+
20
+ vi.mock("../../fragments/file-check-list", () => ({
21
+ FileCheckList: () => <div>Mock File Check List</div>,
22
+ }))
23
+
24
+ describe("NoErrors Component", () => {
25
+ afterEach(() => {
26
+ cleanup()
27
+ vi.clearAllMocks()
28
+ })
29
+
30
+ const baseProps = {
31
+ datasetId: "ds000001",
32
+ modified: new Date().toISOString(), // Ensures recheckEnabled is false
33
+ validation: { errors: 0 },
34
+ authors: [{ name: "Author" }],
35
+ fileCheck: { annexFsck: ["badfile.nii.gz"] }, // Ensures !noBadFiles is true
36
+ children: <div data-testid="children">Children</div>,
37
+ }
38
+
39
+ it("does not disable FsckDataset for admin users", () => {
40
+ mockUseUser.mockReturnValue({ user: { admin: true } })
41
+ render(<NoErrors {...baseProps} />)
42
+ const fsckButton = screen.getByTestId("mock-fsck-button")
43
+ expect(fsckButton).not.toBeDisabled()
44
+ expect(screen.queryByText(/A recheck can be requested in/)).not
45
+ .toBeInTheDocument()
46
+ })
47
+
48
+ it("disables FsckDataset for non-admin users", () => {
49
+ mockUseUser.mockReturnValue({ user: { admin: false } })
50
+ render(<NoErrors {...baseProps} />)
51
+ const fsckButton = screen.getByTestId("mock-fsck-button")
52
+ expect(fsckButton).toBeDisabled()
53
+ expect(screen.getByText(/A recheck can be requested in/))
54
+ .toBeInTheDocument()
55
+ })
56
+ })
@@ -2,6 +2,7 @@ import React from "react"
2
2
  import PropTypes from "prop-types"
3
3
  import DatasetHistory from "../fragments/dataset-history.jsx"
4
4
  import CacheClear from "../mutations/cache-clear.jsx"
5
+ import { HoldDeletion } from "../mutations/hold-deletion"
5
6
  import AdminExports from "../mutations/admin-exports"
6
7
  import { DatasetPageBorder } from "./styles/dataset-page-border"
7
8
  import { HeaderRow3, HeaderRow4 } from "./styles/header-row"
@@ -19,6 +20,15 @@ const AdminDataset = ({ dataset }) => (
19
20
  <CacheClear datasetId={dataset.id} />
20
21
  </div>
21
22
  <hr />
23
+ <HeaderRow4>Hold Automated Draft Deletion</HeaderRow4>
24
+ <p>
25
+ Pause automated deletion of stale draft data and prevent sending a final
26
+ notice for this dataset.
27
+ </p>
28
+ <div className="dataset-form-controls">
29
+ <HoldDeletion datasetId={dataset.id} />
30
+ </div>
31
+ <hr />
22
32
  <HeaderRow4>Rerun Exports</HeaderRow4>
23
33
  <p>
24
34
  Correct most temporary issues with a dataset export by reattempting to
@@ -11,6 +11,7 @@ import styled from "@emotion/styled"
11
11
  import { apiPath } from "../files/file"
12
12
  import { FileCheckList } from "../fragments/file-check-list"
13
13
  import { FsckDataset } from "../mutations/fsck-dataset"
14
+ import { useUser } from "../../queries/user"
14
15
 
15
16
  const FormRow = styled.div`
16
17
  margin-top: 0;
@@ -20,6 +21,8 @@ const FormRow = styled.div`
20
21
  export const NoErrors = (
21
22
  { datasetId, modified, validation, authors, fileCheck, children },
22
23
  ) => {
24
+ const { user } = useUser()
25
+ const adminUser = user?.admin || false
23
26
  const noErrors = validation?.errors === 0
24
27
  // zero authors will cause DOI minting to fail
25
28
  const hasAuthor = authors?.length > 0
@@ -58,8 +61,11 @@ export const NoErrors = (
58
61
  )}
59
62
  {!noBadFiles && (
60
63
  <span>
61
- <FsckDataset datasetId={datasetId} disabled={!recheckEnabled} />
62
- {!recheckEnabled && (
64
+ <FsckDataset
65
+ datasetId={datasetId}
66
+ disabled={!recheckEnabled && !adminUser}
67
+ />
68
+ {!recheckEnabled && !adminUser && (
63
69
  <p>A recheck can be requested in {minutesDiff} minutes.</p>
64
70
  )}
65
71
  </span>
@@ -11,7 +11,7 @@ const PUBLIC_DATASETS_COUNT = gql`
11
11
  `
12
12
 
13
13
  const BRAIN_INITIATIVE_COUNT = gql`
14
- query AdvancedSearch($query: JSON!, $datasetType: String!) {
14
+ query AdvancedSearch($query: DatasetSearchInput!, $datasetType: String!) {
15
15
  advancedSearch(query: $query, datasetType: $datasetType) {
16
16
  pageInfo {
17
17
  count
@@ -26,9 +26,7 @@ const usePublicDatasetsCount = (modality?: string) => {
26
26
  const query = isNIH ? BRAIN_INITIATIVE_COUNT : PUBLIC_DATASETS_COUNT
27
27
  const variables = isNIH
28
28
  ? {
29
- query: {
30
- bool: { filter: [{ match: { brainInitiative: { query: "true" } } }] },
31
- },
29
+ query: { brainInitiative: true },
32
30
  datasetType: "public",
33
31
  }
34
32
  : { modality }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { gql } from "@apollo/client"
5
5
  import * as DatasetQueryFragments from "../datalad/dataset/dataset-query-fragments.js"
6
- import { DATASET_COMMENTS } from "../datalad/dataset/comments-fragments.js"
6
+ import { DATASET_COMMENTS } from "../dataset/fragments/comments-fragments.js"
7
7
 
8
8
  /**
9
9
  * Generate the dataset page query