@openneuro/app 4.11.0 → 4.12.0-alpha.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 +4 -5
- package/src/scripts/apm.js +0 -2
- package/src/scripts/dataset/__tests__/__snapshots__/snapshot-container.spec.tsx.snap +48 -68
- package/src/scripts/dataset/download/download-native.js +77 -30
- package/src/scripts/dataset/download/download-query.js +3 -1
- package/src/scripts/dataset/download/native-file-toast.jsx +24 -6
- package/src/scripts/dataset/files/__tests__/file-tree-unloaded-directory.spec.jsx +4 -16
- package/src/scripts/dataset/files/__tests__/file-tree.spec.jsx +104 -33
- package/src/scripts/dataset/files/file-tree-unloaded-directory.jsx +26 -31
- package/src/scripts/dataset/files/file-tree.tsx +158 -0
- package/src/scripts/dataset/files/file.tsx +2 -2
- package/src/scripts/dataset/files/{files.jsx → files.tsx} +21 -12
- package/src/scripts/types/dataset-file.ts +11 -0
- package/src/scripts/dataset/files/__tests__/file-tree-loading.spec.jsx +0 -18
- package/src/scripts/dataset/files/__tests__/flat-to-tree.spec.js +0 -55
- package/src/scripts/dataset/files/file-tree-loading.jsx +0 -65
- package/src/scripts/dataset/files/file-tree.jsx +0 -126
- package/src/scripts/dataset/files/flat-to-tree.js +0 -49
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
3
|
import { MockedProvider } from '@apollo/client/testing'
|
|
4
|
-
import FileTree, {
|
|
5
|
-
sortByFilename,
|
|
6
|
-
sortByName,
|
|
7
|
-
unescapePath,
|
|
8
|
-
} from '../file-tree.jsx'
|
|
4
|
+
import FileTree, { unescapePath, fileTreeLevels } from '../file-tree'
|
|
9
5
|
|
|
10
6
|
// official Jest workaround for mocking methods not implemented in JSDOM
|
|
11
7
|
window.matchMedia =
|
|
@@ -18,15 +14,110 @@ window.matchMedia =
|
|
|
18
14
|
}
|
|
19
15
|
}
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
const datasetFiles = [
|
|
18
|
+
{
|
|
19
|
+
id: 'b42624c6aea63fc5e3f6f3e712d9e44adc4dbfdc',
|
|
20
|
+
key: '834b88a80109f1b38e0ab85999090170889469ce',
|
|
21
|
+
filename: 'CHANGES',
|
|
22
|
+
size: 59,
|
|
23
|
+
directory: false,
|
|
24
|
+
annexed: false,
|
|
27
25
|
},
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
{
|
|
27
|
+
id: '63888a199a5ce37377b1cd708cda59577dad218f',
|
|
28
|
+
key: 'fa84e5f958ec72d42b3e196e592f6db9f7104b19',
|
|
29
|
+
filename: 'README',
|
|
30
|
+
size: 709,
|
|
31
|
+
directory: false,
|
|
32
|
+
annexed: false,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'a2922b427c5c997e77ce058e0ce57ffd17123a7f',
|
|
36
|
+
key: '0b1856b91c11f67098ce60114417a62dd55730a5',
|
|
37
|
+
filename: 'dataset_description.json',
|
|
38
|
+
size: 172,
|
|
39
|
+
directory: false,
|
|
40
|
+
annexed: false,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'a6378eea201d9cad639e0bee328e03132d30489d',
|
|
44
|
+
key: null,
|
|
45
|
+
filename: 'sub-01',
|
|
46
|
+
size: 0,
|
|
47
|
+
directory: true,
|
|
48
|
+
annexed: false,
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
const anatDir = {
|
|
53
|
+
id: '9703f3f6b4137c2b86a3a712eb34d78bdec9fd72',
|
|
54
|
+
key: null,
|
|
55
|
+
filename: 'sub-01:anat',
|
|
56
|
+
size: 0,
|
|
57
|
+
directory: true,
|
|
58
|
+
annexed: false,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const niftiFile = {
|
|
62
|
+
id: '69fd3617b27125c433ea5f8e0e2052c31828c253',
|
|
63
|
+
key: 'MD5E-s311112--bc8bbbacfd2ff823c2047ead1afec9b3.nii.gz',
|
|
64
|
+
filename: 'sub-01:anat:sub-01_T1w.nii.gz',
|
|
65
|
+
size: 311112,
|
|
66
|
+
directory: false,
|
|
67
|
+
annexed: true,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('fileTreeLevels()', () => {
|
|
71
|
+
it('handles top level files correctly', () => {
|
|
72
|
+
const { childFiles, currentFiles } = fileTreeLevels('', datasetFiles)
|
|
73
|
+
expect(currentFiles).toEqual(
|
|
74
|
+
expect.arrayContaining([
|
|
75
|
+
currentFiles.find(
|
|
76
|
+
f => f.id === 'a2922b427c5c997e77ce058e0ce57ffd17123a7f',
|
|
77
|
+
),
|
|
78
|
+
]),
|
|
79
|
+
)
|
|
80
|
+
expect(childFiles).toEqual({})
|
|
81
|
+
})
|
|
82
|
+
it('passes childFiles to the next level', () => {
|
|
83
|
+
const levelOneFiles = [...datasetFiles, anatDir]
|
|
84
|
+
const { childFiles, currentFiles } = fileTreeLevels('', levelOneFiles)
|
|
85
|
+
expect(currentFiles).toEqual(
|
|
86
|
+
expect.arrayContaining([
|
|
87
|
+
currentFiles.find(
|
|
88
|
+
f => f.id === 'a2922b427c5c997e77ce058e0ce57ffd17123a7f',
|
|
89
|
+
),
|
|
90
|
+
]),
|
|
91
|
+
)
|
|
92
|
+
expect(childFiles['sub-01']).toEqual(expect.arrayContaining([anatDir]))
|
|
93
|
+
})
|
|
94
|
+
it('passes two level deep childFiles to the next level', () => {
|
|
95
|
+
const levelTwoFiles = [...datasetFiles, anatDir, niftiFile]
|
|
96
|
+
const { childFiles, currentFiles } = fileTreeLevels('', levelTwoFiles)
|
|
97
|
+
expect(currentFiles).toEqual(
|
|
98
|
+
expect.arrayContaining([
|
|
99
|
+
currentFiles.find(
|
|
100
|
+
f => f.id === 'a2922b427c5c997e77ce058e0ce57ffd17123a7f',
|
|
101
|
+
),
|
|
102
|
+
]),
|
|
103
|
+
)
|
|
104
|
+
expect(childFiles['sub-01']).toEqual(expect.arrayContaining([niftiFile]))
|
|
105
|
+
})
|
|
106
|
+
it('passes two level deep childFiles to the next level', () => {
|
|
107
|
+
const levelTwoFiles = [anatDir, niftiFile]
|
|
108
|
+
const { childFiles, currentFiles } = fileTreeLevels('sub-01', levelTwoFiles)
|
|
109
|
+
expect(currentFiles).toEqual(
|
|
110
|
+
expect.arrayContaining([
|
|
111
|
+
currentFiles.find(
|
|
112
|
+
f => f.id === '9703f3f6b4137c2b86a3a712eb34d78bdec9fd72',
|
|
113
|
+
),
|
|
114
|
+
]),
|
|
115
|
+
)
|
|
116
|
+
expect(childFiles['sub-01:anat']).toEqual(
|
|
117
|
+
expect.arrayContaining([niftiFile]),
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
30
121
|
|
|
31
122
|
describe('FileTree component', () => {
|
|
32
123
|
it('renders with default props', () => {
|
|
@@ -56,26 +147,6 @@ describe('FileTree component', () => {
|
|
|
56
147
|
'fa-folder',
|
|
57
148
|
)
|
|
58
149
|
})
|
|
59
|
-
describe('sortByFilename()', () => {
|
|
60
|
-
it('sorts the expected filename properties', () => {
|
|
61
|
-
expect(
|
|
62
|
-
sortByFilename(
|
|
63
|
-
{ name: 'abc', filename: 'xyz' },
|
|
64
|
-
{ name: 'xyz', filename: 'abc' },
|
|
65
|
-
),
|
|
66
|
-
).toBe(1)
|
|
67
|
-
})
|
|
68
|
-
})
|
|
69
|
-
describe('sortByName()', () => {
|
|
70
|
-
it('sorts the expected name properties', () => {
|
|
71
|
-
expect(
|
|
72
|
-
sortByName(
|
|
73
|
-
{ name: 'abc', filename: 'xyz' },
|
|
74
|
-
{ name: 'xyz', filename: 'abc' },
|
|
75
|
-
),
|
|
76
|
-
).toBe(-1)
|
|
77
|
-
})
|
|
78
|
-
})
|
|
79
150
|
describe('unescapePath()', () => {
|
|
80
151
|
it('does not alter an already escaped path', () => {
|
|
81
152
|
expect(unescapePath('sub-01/anat')).toBe('sub-01/anat')
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import React, { useState, useContext, useEffect } from 'react'
|
|
2
2
|
import PropTypes from 'prop-types'
|
|
3
3
|
import DatasetQueryContext from '../../datalad/dataset/dataset-query-context.js'
|
|
4
|
-
import FileTreeLoading from './file-tree-loading.jsx'
|
|
5
4
|
import { gql } from '@apollo/client'
|
|
6
5
|
import { AccordionTab } from '@openneuro/components/accordion'
|
|
7
6
|
|
|
8
7
|
export const DRAFT_FILES_QUERY = gql`
|
|
9
|
-
query dataset($datasetId: ID!, $
|
|
8
|
+
query dataset($datasetId: ID!, $tree: String!) {
|
|
10
9
|
dataset(id: $datasetId) {
|
|
11
10
|
draft {
|
|
12
|
-
files(
|
|
11
|
+
files(tree: $tree) {
|
|
13
12
|
id
|
|
14
13
|
key
|
|
15
14
|
filename
|
|
@@ -23,9 +22,9 @@ export const DRAFT_FILES_QUERY = gql`
|
|
|
23
22
|
`
|
|
24
23
|
|
|
25
24
|
export const SNAPSHOT_FILES_QUERY = gql`
|
|
26
|
-
query snapshot($datasetId: ID!, $snapshotTag: String!, $
|
|
25
|
+
query snapshot($datasetId: ID!, $snapshotTag: String!, $tree: String!) {
|
|
27
26
|
snapshot(datasetId: $datasetId, tag: $snapshotTag) {
|
|
28
|
-
files(
|
|
27
|
+
files(tree: $tree) {
|
|
29
28
|
id
|
|
30
29
|
key
|
|
31
30
|
filename
|
|
@@ -37,24 +36,30 @@ export const SNAPSHOT_FILES_QUERY = gql`
|
|
|
37
36
|
}
|
|
38
37
|
`
|
|
39
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Prepend paths to the tree object returned to get absolute filenames
|
|
41
|
+
*/
|
|
42
|
+
export const nestFiles = path => file => ({
|
|
43
|
+
...file,
|
|
44
|
+
filename: `${path}:${file.filename}`,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Merge cached dataset files with newly received data
|
|
49
|
+
*/
|
|
40
50
|
export const mergeNewFiles =
|
|
41
51
|
(directory, snapshotTag) =>
|
|
42
52
|
(past, { fetchMoreResult }) => {
|
|
43
53
|
// Deep clone the old dataset object
|
|
54
|
+
const path = directory.filename
|
|
44
55
|
const newDatasetObj = JSON.parse(JSON.stringify(past))
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
newDatasetObj.dataset.draft.files =
|
|
53
|
-
newDatasetObj.dataset.draft.files.filter(mergeNewFileFilter)
|
|
54
|
-
newDatasetObj.dataset.draft.files.push(
|
|
55
|
-
...fetchMoreResult.dataset.draft.files,
|
|
56
|
-
)
|
|
57
|
-
}
|
|
56
|
+
const newFiles = snapshotTag
|
|
57
|
+
? newDatasetObj.snapshot.files
|
|
58
|
+
: newDatasetObj.dataset.draft.files
|
|
59
|
+
const fetchMoreData = snapshotTag
|
|
60
|
+
? fetchMoreResult.snapshot
|
|
61
|
+
: fetchMoreResult.dataset.draft
|
|
62
|
+
newFiles.push(...fetchMoreData.files.map(nestFiles(path)))
|
|
58
63
|
return newDatasetObj
|
|
59
64
|
}
|
|
60
65
|
|
|
@@ -66,31 +71,21 @@ export const fetchMoreDirectory = (
|
|
|
66
71
|
) =>
|
|
67
72
|
fetchMore({
|
|
68
73
|
query: snapshotTag ? SNAPSHOT_FILES_QUERY : DRAFT_FILES_QUERY,
|
|
69
|
-
variables: { datasetId, snapshotTag,
|
|
74
|
+
variables: { datasetId, snapshotTag, tree: directory.id },
|
|
70
75
|
updateQuery: mergeNewFiles(directory, snapshotTag),
|
|
71
76
|
})
|
|
72
77
|
|
|
73
78
|
const FileTreeUnloadedDirectory = ({ datasetId, snapshotTag, directory }) => {
|
|
74
|
-
const [loading, setLoading] = useState(false)
|
|
75
|
-
const [displayLoading, setDisplayLoading] = useState(false)
|
|
76
79
|
const { fetchMore } = useContext(DatasetQueryContext)
|
|
77
|
-
useEffect(() => {
|
|
78
|
-
if (loading) {
|
|
79
|
-
const timer = setTimeout(() => setDisplayLoading(true), 150)
|
|
80
|
-
return () => clearTimeout(timer)
|
|
81
|
-
}
|
|
82
|
-
}, [loading])
|
|
83
80
|
return (
|
|
84
81
|
<AccordionTab
|
|
85
|
-
label={directory.filename}
|
|
82
|
+
label={directory.filename.split(':').pop()}
|
|
86
83
|
accordionStyle="file-tree"
|
|
87
84
|
onClick={() => {
|
|
88
|
-
// Show a loading state while we wait on the directory to stream in
|
|
89
|
-
setLoading(true)
|
|
90
85
|
fetchMoreDirectory(fetchMore, datasetId, snapshotTag, directory)
|
|
91
86
|
// No need to clear since this component is unmounted immediately
|
|
92
87
|
}}>
|
|
93
|
-
<
|
|
88
|
+
<div>Loading...</div>
|
|
94
89
|
</AccordionTab>
|
|
95
90
|
)
|
|
96
91
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import File from './file'
|
|
3
|
+
import UpdateFile from '../mutations/update-file.jsx'
|
|
4
|
+
import DeleteDir from '../mutations/delete-dir.jsx'
|
|
5
|
+
import FileTreeUnloadedDirectory from './file-tree-unloaded-directory.jsx'
|
|
6
|
+
import { Media } from '../../styles/media'
|
|
7
|
+
import { AccordionTab } from '@openneuro/components/accordion'
|
|
8
|
+
import { DatasetFile } from '../../types/dataset-file'
|
|
9
|
+
|
|
10
|
+
export const unescapePath = (path: string): string => path.replace(/:/g, '/')
|
|
11
|
+
|
|
12
|
+
interface FileTreeProps {
|
|
13
|
+
datasetId: string
|
|
14
|
+
snapshotTag: string
|
|
15
|
+
path: string
|
|
16
|
+
name: string
|
|
17
|
+
files: DatasetFile[]
|
|
18
|
+
editMode: boolean
|
|
19
|
+
defaultExpanded: boolean
|
|
20
|
+
datasetPermissions: any
|
|
21
|
+
toggleFileToDelete: ({ id, path, filename }) => void
|
|
22
|
+
isFileToBeDeleted: (id: string) => boolean
|
|
23
|
+
bulkDeleteButton: JSX.Element
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function fileTreeLevels(
|
|
27
|
+
path: string,
|
|
28
|
+
files: DatasetFile[],
|
|
29
|
+
): { currentFiles: DatasetFile[]; childFiles: Record<string, DatasetFile[]> } {
|
|
30
|
+
// Split files into a tree for this level and child levels
|
|
31
|
+
// Special cases for root (path === '')
|
|
32
|
+
const currentFiles = []
|
|
33
|
+
const childFiles = {}
|
|
34
|
+
for (const f of files) {
|
|
35
|
+
// Any paths in this filename below the current path value
|
|
36
|
+
const lowerPath = f.filename.substring(`${path}:`.length)
|
|
37
|
+
if (path === '' ? f.filename.includes(':') : lowerPath.includes(':')) {
|
|
38
|
+
// At the top level, use the directory component (first segment)
|
|
39
|
+
// Below that, use all paths before the filename (sub-01:anat) for (sub-01:anat:sub-01_T1w.nii.gz)
|
|
40
|
+
const childPath =
|
|
41
|
+
path === ''
|
|
42
|
+
? f.filename.split(':')[0]
|
|
43
|
+
: f.filename.split(':').slice(0, -1).join(':')
|
|
44
|
+
if (childFiles.hasOwnProperty(childPath)) {
|
|
45
|
+
childFiles[childPath].push(f)
|
|
46
|
+
} else {
|
|
47
|
+
childFiles[childPath] = [f]
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
currentFiles.push(f)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { currentFiles, childFiles }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const FileTree = ({
|
|
57
|
+
datasetId,
|
|
58
|
+
snapshotTag = null,
|
|
59
|
+
path = '',
|
|
60
|
+
name = '',
|
|
61
|
+
files = [],
|
|
62
|
+
editMode = false,
|
|
63
|
+
defaultExpanded = false,
|
|
64
|
+
datasetPermissions,
|
|
65
|
+
toggleFileToDelete,
|
|
66
|
+
isFileToBeDeleted,
|
|
67
|
+
bulkDeleteButton,
|
|
68
|
+
}: FileTreeProps): JSX.Element => {
|
|
69
|
+
const { childFiles, currentFiles } = fileTreeLevels(path, files)
|
|
70
|
+
return (
|
|
71
|
+
<AccordionTab
|
|
72
|
+
className=""
|
|
73
|
+
label={name}
|
|
74
|
+
accordionStyle="file-tree"
|
|
75
|
+
startOpen={defaultExpanded}>
|
|
76
|
+
{editMode && (
|
|
77
|
+
<Media className="filetree-dir-tools" greaterThanOrEqual="medium">
|
|
78
|
+
<span className="filetree-dir">
|
|
79
|
+
<UpdateFile
|
|
80
|
+
datasetId={datasetId}
|
|
81
|
+
path={unescapePath(path)}
|
|
82
|
+
tooltip={`Choose one or more files to be added to ${name}.`}
|
|
83
|
+
multiple>
|
|
84
|
+
<i className="fa fa-plus" /> Add Files
|
|
85
|
+
</UpdateFile>
|
|
86
|
+
<UpdateFile
|
|
87
|
+
datasetId={datasetId}
|
|
88
|
+
path={unescapePath(path)}
|
|
89
|
+
tooltip={`Choose a folder to be added to ${name}. Adding a folder with an existing name will overwrite that folder.`}
|
|
90
|
+
directory>
|
|
91
|
+
<i className="fa fa-plus" /> Add Directory
|
|
92
|
+
</UpdateFile>
|
|
93
|
+
{bulkDeleteButton || (
|
|
94
|
+
<DeleteDir datasetId={datasetId} path={path} name={name} />
|
|
95
|
+
)}
|
|
96
|
+
</span>
|
|
97
|
+
</Media>
|
|
98
|
+
)}
|
|
99
|
+
<ul className="child-files">
|
|
100
|
+
{currentFiles.map((file, index) => {
|
|
101
|
+
if (file.directory) {
|
|
102
|
+
if (childFiles.hasOwnProperty(file.filename)) {
|
|
103
|
+
return (
|
|
104
|
+
<li className="clearfix filetree-item filetree-dir" key={index}>
|
|
105
|
+
<FileTree
|
|
106
|
+
datasetId={datasetId}
|
|
107
|
+
snapshotTag={snapshotTag}
|
|
108
|
+
editMode={editMode}
|
|
109
|
+
defaultExpanded={true}
|
|
110
|
+
datasetPermissions={datasetPermissions}
|
|
111
|
+
toggleFileToDelete={toggleFileToDelete}
|
|
112
|
+
isFileToBeDeleted={isFileToBeDeleted}
|
|
113
|
+
files={childFiles[file.filename]}
|
|
114
|
+
path={file.filename}
|
|
115
|
+
name={file.filename.split(':').pop()}
|
|
116
|
+
bulkDeleteButton={bulkDeleteButton}
|
|
117
|
+
/>
|
|
118
|
+
</li>
|
|
119
|
+
)
|
|
120
|
+
} else {
|
|
121
|
+
return (
|
|
122
|
+
<li className="clearfix filetree-item filetree-dir" key={index}>
|
|
123
|
+
<FileTreeUnloadedDirectory
|
|
124
|
+
datasetId={datasetId}
|
|
125
|
+
snapshotTag={snapshotTag}
|
|
126
|
+
directory={file}
|
|
127
|
+
/>
|
|
128
|
+
</li>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
return (
|
|
133
|
+
<li className="clearfix filetree-item filetree-file" key={index}>
|
|
134
|
+
<File
|
|
135
|
+
id={file.id}
|
|
136
|
+
datasetId={datasetId}
|
|
137
|
+
snapshotTag={snapshotTag}
|
|
138
|
+
path={path}
|
|
139
|
+
size={file.size}
|
|
140
|
+
editMode={editMode}
|
|
141
|
+
toggleFileToDelete={toggleFileToDelete}
|
|
142
|
+
isFileToBeDeleted={isFileToBeDeleted}
|
|
143
|
+
filename={file.filename.split(':').pop()}
|
|
144
|
+
annexKey={file.key}
|
|
145
|
+
datasetPermissions={datasetPermissions}
|
|
146
|
+
annexed={file.annexed}
|
|
147
|
+
isMobile={false}
|
|
148
|
+
/>
|
|
149
|
+
</li>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
})}
|
|
153
|
+
</ul>
|
|
154
|
+
</AccordionTab>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default FileTree
|
|
@@ -81,7 +81,7 @@ export const apiPath = (datasetId, snapshotTag, filePath) => {
|
|
|
81
81
|
|
|
82
82
|
interface FileProps {
|
|
83
83
|
id: string
|
|
84
|
-
size:
|
|
84
|
+
size: bigint
|
|
85
85
|
datasetId: string
|
|
86
86
|
path: string
|
|
87
87
|
filename: string
|
|
@@ -99,7 +99,7 @@ interface FileProps {
|
|
|
99
99
|
id: string
|
|
100
100
|
path: string
|
|
101
101
|
filename: string
|
|
102
|
-
}) =>
|
|
102
|
+
}) => void
|
|
103
103
|
isFileToBeDeleted: (id: string) => boolean
|
|
104
104
|
}
|
|
105
105
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React, { useState } from 'react'
|
|
2
2
|
import PropTypes from 'prop-types'
|
|
3
|
-
import
|
|
4
|
-
import FileTree from './file-tree.jsx'
|
|
3
|
+
import FileTree from './file-tree'
|
|
5
4
|
import { Media } from '../../styles/media'
|
|
6
5
|
import { useMutation, gql } from '@apollo/client'
|
|
7
6
|
import { WarnButton } from '@openneuro/components/warn-button'
|
|
8
7
|
import { AccordionWrap } from '@openneuro/components/accordion'
|
|
9
8
|
import styled from '@emotion/styled'
|
|
10
9
|
import { Tooltip } from '@openneuro/components/tooltip'
|
|
10
|
+
import { DatasetFile } from '../../types/dataset-file'
|
|
11
11
|
import bytes from 'bytes'
|
|
12
12
|
|
|
13
13
|
const FileTreeMeta = styled.span`
|
|
@@ -33,6 +33,16 @@ const DELETE_FILES = gql`
|
|
|
33
33
|
}
|
|
34
34
|
`
|
|
35
35
|
|
|
36
|
+
interface FilesProps {
|
|
37
|
+
datasetId: string
|
|
38
|
+
snapshotTag: string
|
|
39
|
+
datasetName: string
|
|
40
|
+
files: DatasetFile[]
|
|
41
|
+
editMode: boolean
|
|
42
|
+
datasetPermissions: any
|
|
43
|
+
summary: any
|
|
44
|
+
}
|
|
45
|
+
|
|
36
46
|
const Files = ({
|
|
37
47
|
datasetId,
|
|
38
48
|
snapshotTag,
|
|
@@ -41,14 +51,14 @@ const Files = ({
|
|
|
41
51
|
editMode = false,
|
|
42
52
|
datasetPermissions,
|
|
43
53
|
summary,
|
|
44
|
-
}) => {
|
|
54
|
+
}: FilesProps): JSX.Element => {
|
|
45
55
|
const [filesToDelete, setFilesToDelete] = useState({})
|
|
46
56
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
47
57
|
const [deleteFiles] = useMutation(DELETE_FILES)
|
|
48
58
|
|
|
49
|
-
const isFileToBeDeleted = id => id in filesToDelete
|
|
59
|
+
const isFileToBeDeleted = (id: string): boolean => id in filesToDelete
|
|
50
60
|
|
|
51
|
-
const toggleFileToDelete = ({ id, path, filename }) =>
|
|
61
|
+
const toggleFileToDelete = ({ id, path, filename }): void =>
|
|
52
62
|
setFilesToDelete(prevFilesToDelete => {
|
|
53
63
|
if (isFileToBeDeleted(id)) {
|
|
54
64
|
delete prevFilesToDelete[id]
|
|
@@ -60,10 +70,10 @@ const Files = ({
|
|
|
60
70
|
}
|
|
61
71
|
})
|
|
62
72
|
|
|
63
|
-
const bulkDelete = () => {
|
|
73
|
+
const bulkDelete = (): void => {
|
|
64
74
|
if (Object.values(filesToDelete).length) {
|
|
65
75
|
setIsDeleting(true)
|
|
66
|
-
deleteFiles({
|
|
76
|
+
void deleteFiles({
|
|
67
77
|
variables: { datasetId, files: Object.values(filesToDelete) },
|
|
68
78
|
}).then(() => {
|
|
69
79
|
setIsDeleting(false)
|
|
@@ -72,7 +82,6 @@ const Files = ({
|
|
|
72
82
|
}
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
const fileTree = flatToTree(files)
|
|
76
85
|
const disableBtn = Object.values(filesToDelete).length ? null : true
|
|
77
86
|
const filesCount = Object.values(filesToDelete).length
|
|
78
87
|
const bulkDeleteButton =
|
|
@@ -88,11 +97,11 @@ const Files = ({
|
|
|
88
97
|
</Tooltip>
|
|
89
98
|
) : (
|
|
90
99
|
<WarnButton
|
|
91
|
-
message={
|
|
100
|
+
message={`Bulk Delete (${filesCount})`}
|
|
92
101
|
icon="fas fa-dumpster"
|
|
93
102
|
iconOnly={true}
|
|
94
103
|
className="edit-file"
|
|
95
|
-
tooltip={
|
|
104
|
+
tooltip={`Delete ${filesCount}`}
|
|
96
105
|
onConfirmedClick={bulkDelete}
|
|
97
106
|
/>
|
|
98
107
|
)}
|
|
@@ -108,7 +117,7 @@ const Files = ({
|
|
|
108
117
|
datasetId={datasetId}
|
|
109
118
|
snapshotTag={snapshotTag}
|
|
110
119
|
path={''}
|
|
111
|
-
{
|
|
120
|
+
files={files}
|
|
112
121
|
name={datasetName}
|
|
113
122
|
editMode={editMode}
|
|
114
123
|
defaultExpanded={false}
|
|
@@ -133,7 +142,7 @@ const Files = ({
|
|
|
133
142
|
datasetId={datasetId}
|
|
134
143
|
snapshotTag={snapshotTag}
|
|
135
144
|
path={''}
|
|
136
|
-
{
|
|
145
|
+
files={files}
|
|
137
146
|
name={datasetName}
|
|
138
147
|
editMode={editMode}
|
|
139
148
|
defaultExpanded={true}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Temporary type representing the GraphQL DatasetFile type
|
|
2
|
+
// TODO - Derive this from the GraphQL schema
|
|
3
|
+
export interface DatasetFile {
|
|
4
|
+
id: string
|
|
5
|
+
key?: string
|
|
6
|
+
filename: string
|
|
7
|
+
size?: bigint
|
|
8
|
+
annexed?: boolean
|
|
9
|
+
urls?: string[]
|
|
10
|
+
directory?: boolean
|
|
11
|
+
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { estimateDuration } from '../file-tree-loading.jsx'
|
|
2
|
-
|
|
3
|
-
describe('FileTreeLoading component', () => {
|
|
4
|
-
describe('estimateDuration()', () => {
|
|
5
|
-
it('returns an estimate if navigator has a downlink set', () => {
|
|
6
|
-
const mockNavigator = {
|
|
7
|
-
connection: {
|
|
8
|
-
downlink: 20,
|
|
9
|
-
},
|
|
10
|
-
}
|
|
11
|
-
expect(estimateDuration(mockNavigator)(250)).toEqual(1270.703125)
|
|
12
|
-
})
|
|
13
|
-
it('returns an estimate if navigator does not have a downlink', () => {
|
|
14
|
-
const mockNavigator = {}
|
|
15
|
-
expect(estimateDuration(mockNavigator)(250)).toEqual(2491.40625)
|
|
16
|
-
})
|
|
17
|
-
})
|
|
18
|
-
})
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { flatToTree } from '../flat-to-tree.js'
|
|
2
|
-
|
|
3
|
-
const CHANGES = Object.freeze({
|
|
4
|
-
id: '3d9b15b3ef4e9da06e265e6078d3b4ddf8495102',
|
|
5
|
-
filename: 'CHANGES',
|
|
6
|
-
size: 39,
|
|
7
|
-
})
|
|
8
|
-
|
|
9
|
-
const nifti = Object.freeze({
|
|
10
|
-
id: '50512c7261fc006eb59bfd16f2a9d3140c9efe62',
|
|
11
|
-
filename: 'sub-01/anat/sub-01_T1w.nii.gz',
|
|
12
|
-
size: 311112,
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
const sub01Unloaded = Object.freeze({
|
|
16
|
-
id: 'directory:sub-01',
|
|
17
|
-
filename: 'sub-01',
|
|
18
|
-
size: 1,
|
|
19
|
-
directory: true,
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
const exampleFiles = [CHANGES, nifti]
|
|
23
|
-
|
|
24
|
-
describe('FileTree', () => {
|
|
25
|
-
describe('flatToTree()', () => {
|
|
26
|
-
it('accepts an array and returns a tree', () => {
|
|
27
|
-
expect(flatToTree(exampleFiles)).toEqual({
|
|
28
|
-
name: '',
|
|
29
|
-
files: [CHANGES],
|
|
30
|
-
directories: [
|
|
31
|
-
{
|
|
32
|
-
name: 'sub-01',
|
|
33
|
-
path: 'sub-01',
|
|
34
|
-
files: [],
|
|
35
|
-
directories: [
|
|
36
|
-
{
|
|
37
|
-
name: 'anat',
|
|
38
|
-
path: 'sub-01:anat',
|
|
39
|
-
files: [{ ...nifti, filename: 'sub-01_T1w.nii.gz' }],
|
|
40
|
-
directories: [],
|
|
41
|
-
},
|
|
42
|
-
],
|
|
43
|
-
},
|
|
44
|
-
],
|
|
45
|
-
})
|
|
46
|
-
})
|
|
47
|
-
it('accepts directory stubs and returns them as directories', () => {
|
|
48
|
-
expect(flatToTree([CHANGES, sub01Unloaded])).toEqual({
|
|
49
|
-
name: '',
|
|
50
|
-
files: [CHANGES],
|
|
51
|
-
directories: [{ ...sub01Unloaded, name: sub01Unloaded.filename }],
|
|
52
|
-
})
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
})
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import PropTypes from 'prop-types'
|
|
3
|
-
import { useSpring, animated } from 'react-spring'
|
|
4
|
-
import styled from '@emotion/styled'
|
|
5
|
-
|
|
6
|
-
const ProgressOuter = styled.div`
|
|
7
|
-
width: 100%;
|
|
8
|
-
height: 2px;
|
|
9
|
-
background-color: #f5f5f5;
|
|
10
|
-
margin: 10px 0;
|
|
11
|
-
`
|
|
12
|
-
const ProgressInner = styled(animated.div)`
|
|
13
|
-
height: 100%;
|
|
14
|
-
color: white;
|
|
15
|
-
line-height: 2px;
|
|
16
|
-
text-align: center;
|
|
17
|
-
width: 0%;
|
|
18
|
-
`
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Estimate time to fetch files
|
|
22
|
-
* @param {Navigator} [navigator]
|
|
23
|
-
* @returns {(size: number) => number}
|
|
24
|
-
*/
|
|
25
|
-
export const estimateDuration = navigator => size => {
|
|
26
|
-
// One file is about 100 bytes
|
|
27
|
-
const estimatedBytes = size * 100
|
|
28
|
-
if (navigator && 'connection' in navigator) {
|
|
29
|
-
// Estimate duration precisely if we can
|
|
30
|
-
const downlink = navigator.connection.downlink
|
|
31
|
-
return (estimatedBytes / (downlink * 1024)) * 1000 + 50
|
|
32
|
-
} else {
|
|
33
|
-
// Fallback estimate of a conservative general connection (10 mbps)
|
|
34
|
-
return (estimatedBytes / (10 * 1024)) * 1000 + 50
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const FileTreeLoading = ({ size }) => {
|
|
39
|
-
const navRef = typeof navigator === 'undefined' ? undefined : navigator
|
|
40
|
-
const config = {
|
|
41
|
-
mass: 5,
|
|
42
|
-
tension: 2000,
|
|
43
|
-
friction: 200,
|
|
44
|
-
duration: estimateDuration(navRef)(size),
|
|
45
|
-
}
|
|
46
|
-
const props = useSpring({
|
|
47
|
-
config,
|
|
48
|
-
from: {
|
|
49
|
-
width: '0%',
|
|
50
|
-
backgroundColor: '#f5f5f5',
|
|
51
|
-
},
|
|
52
|
-
to: { width: '100%', backgroundColor: 'var(--secondary)' },
|
|
53
|
-
})
|
|
54
|
-
return (
|
|
55
|
-
<ProgressOuter>
|
|
56
|
-
<ProgressInner style={props} />
|
|
57
|
-
</ProgressOuter>
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
FileTreeLoading.propTypes = {
|
|
62
|
-
size: PropTypes.number,
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export default FileTreeLoading
|