@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.
- package/package.json +2 -2
- package/src/client.jsx +6 -4
- package/src/scripts/authentication/__tests__/profile.spec.js +79 -13
- package/src/scripts/authentication/profile.ts +9 -0
- package/src/scripts/contributors/contributor.tsx +3 -2
- package/src/scripts/contributors/contributors-list.tsx +0 -1
- package/src/scripts/datalad/dataset/dataset-query-fragments.js +0 -2
- package/src/scripts/datalad/mutations/delete-comment.jsx +1 -1
- package/src/scripts/dataset/components/dataset-event-item.tsx +4 -4
- package/src/scripts/dataset/download/__tests__/download-script.spec.tsx +1 -1
- package/src/scripts/dataset/download/download-script.tsx +21 -10
- package/src/scripts/dataset/files/__tests__/file-tree-unloaded-directory.spec.jsx +4 -4
- package/src/scripts/dataset/files/file-tree-unloaded-directory.jsx +2 -5
- package/src/scripts/dataset/files/file-tree.tsx +1 -1
- package/src/scripts/dataset/mutations/delete-file.jsx +11 -16
- package/src/scripts/dataset/mutations/hold-deletion.tsx +57 -0
- package/src/scripts/dataset/routes/__tests__/snapshot.spec.tsx +56 -0
- package/src/scripts/dataset/routes/admin-datalad.jsx +10 -0
- package/src/scripts/dataset/routes/snapshot.tsx +8 -2
- package/src/scripts/pages/front-page/aggregate-queries/use-publicDatasets-count.ts +2 -4
- package/src/scripts/queries/dataset.ts +1 -1
- package/src/scripts/queries/datasetEvents.ts +2 -2
- package/src/scripts/queries/user.ts +2 -12
- package/src/scripts/search/inputs/__tests__/sort-by-select.spec.tsx +4 -26
- package/src/scripts/search/inputs/sort-by-select.tsx +6 -6
- package/src/scripts/search/use-search-results.tsx +38 -256
- package/src/scripts/types/event-types.ts +21 -8
- package/src/scripts/users/__tests__/user-routes.spec.tsx +2 -11
- package/src/scripts/users/notifications/user-notification-accordion-actions.tsx +5 -5
- package/src/scripts/users/notifications/user-notification-accordion-header.tsx +4 -1
- package/src/scripts/users/notifications/user-notification-accordion.tsx +12 -5
- package/src/scripts/users/notifications/user-notification-reason-input.tsx +6 -3
- package/src/scripts/users/notifications/user-notifications-accordion-body.tsx +3 -3
- package/src/scripts/users/user-datasets-view.tsx +83 -164
- package/src/scripts/users/user-menu.tsx +1 -1
- package/src/scripts/datalad/dataset/comments-fragments.js +0 -22
- package/src/scripts/datalad/mutations/follow.jsx +0 -54
- package/src/scripts/datalad/mutations/publish.jsx +0 -58
- package/src/scripts/datalad/mutations/star.jsx +0 -54
- package/src/scripts/pages/admin/user-fragment.ts +0 -21
- 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": "
|
|
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": "
|
|
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 {
|
|
18
|
+
import { shouldRemoveToken } from "./scripts/authentication/profile"
|
|
19
19
|
|
|
20
|
-
// Clear expired JWT before the app mounts to avoid stale auth state
|
|
21
|
-
|
|
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 {
|
|
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
|
-
|
|
12
|
+
`${header}.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.${dummySig}`
|
|
5
13
|
const utf8Token =
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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?:
|
|
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 === "
|
|
38
|
+
const resolutionAccepted = true //contributor.resolutionStatus === "ACCEPTED"
|
|
38
39
|
|
|
39
40
|
return (
|
|
40
41
|
<>
|
|
@@ -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 "
|
|
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 === "
|
|
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?: "
|
|
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("
|
|
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("
|
|
249
|
+
onClick={() => handleSaveOrProcessRequest("DENIED")}
|
|
250
250
|
className={`${styles.eventActionButton} on-button on-button--small`}
|
|
251
251
|
>
|
|
252
252
|
Deny
|
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
|
62
|
-
|
|
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:
|
|
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 "../
|
|
6
|
+
import { DATASET_COMMENTS } from "../dataset/fragments/comments-fragments.js"
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Generate the dataset page query
|