@openneuro/app 4.35.0 → 4.36.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 (51) hide show
  1. package/package.json +3 -3
  2. package/src/client.jsx +1 -0
  3. package/src/scripts/components/button/button.scss +14 -0
  4. package/src/scripts/components/page/page.scss +2 -70
  5. package/src/scripts/components/search-page/SearchResultItem.tsx +3 -1
  6. package/src/scripts/components/search-page/search-page.scss +1 -13
  7. package/src/scripts/config.ts +6 -0
  8. package/src/scripts/dataset/files/__tests__/__snapshots__/file.spec.jsx.snap +2 -2
  9. package/src/scripts/dataset/files/file.tsx +2 -2
  10. package/src/scripts/dataset/mutations/__tests__/update-file.spec.tsx +126 -0
  11. package/src/scripts/dataset/mutations/update-file.jsx +20 -5
  12. package/src/scripts/dataset/routes/snapshot.tsx +1 -1
  13. package/src/scripts/errors/errorRoute.tsx +2 -0
  14. package/src/scripts/pages/admin/__tests__/users.spec.tsx +51 -18
  15. package/src/scripts/pages/admin/admin.jsx +2 -2
  16. package/src/scripts/pages/admin/user-fragment.ts +10 -3
  17. package/src/scripts/pages/admin/user-summary.tsx +100 -0
  18. package/src/scripts/pages/admin/user-tools.tsx +81 -58
  19. package/src/scripts/pages/admin/users.module.scss +277 -0
  20. package/src/scripts/pages/admin/users.tsx +351 -152
  21. package/src/scripts/queries/user.ts +120 -3
  22. package/src/scripts/queries/users.ts +247 -0
  23. package/src/scripts/routes.tsx +7 -15
  24. package/src/scripts/types/user-types.ts +12 -13
  25. package/src/scripts/uploader/file-select.tsx +42 -57
  26. package/src/scripts/uploader/upload-select.jsx +1 -1
  27. package/src/scripts/users/__tests__/dataset-card.spec.tsx +127 -0
  28. package/src/scripts/users/__tests__/user-account-view.spec.tsx +150 -67
  29. package/src/scripts/users/__tests__/user-card.spec.tsx +6 -17
  30. package/src/scripts/users/__tests__/user-query.spec.tsx +133 -38
  31. package/src/scripts/users/__tests__/user-routes.spec.tsx +156 -27
  32. package/src/scripts/users/__tests__/user-tabs.spec.tsx +7 -7
  33. package/src/scripts/users/components/edit-list.tsx +26 -5
  34. package/src/scripts/users/components/edit-string.tsx +40 -13
  35. package/src/scripts/users/components/editable-content.tsx +10 -3
  36. package/src/scripts/users/components/user-dataset-filters.tsx +205 -121
  37. package/src/scripts/users/dataset-card.tsx +3 -2
  38. package/src/scripts/users/github-auth-button.tsx +98 -0
  39. package/src/scripts/users/scss/datasetcard.module.scss +65 -12
  40. package/src/scripts/users/scss/useraccountview.module.scss +1 -1
  41. package/src/scripts/users/user-account-view.tsx +43 -34
  42. package/src/scripts/users/user-card.tsx +23 -22
  43. package/src/scripts/users/user-container.tsx +9 -5
  44. package/src/scripts/users/user-datasets-view.tsx +350 -40
  45. package/src/scripts/users/user-menu.tsx +4 -9
  46. package/src/scripts/users/user-notifications-view.tsx +9 -7
  47. package/src/scripts/users/user-query.tsx +3 -6
  48. package/src/scripts/users/user-routes.tsx +11 -5
  49. package/src/scripts/users/user-tabs.tsx +4 -2
  50. package/src/scripts/users/__tests__/datasest-card.spec.tsx +0 -201
  51. package/src/scripts/users/fragments/query.js +0 -42
@@ -0,0 +1,247 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react"
2
+ import { gql, useQuery } from "@apollo/client"
3
+ import * as Sentry from "@sentry/react"
4
+ import type { User } from "../types/user-types"
5
+
6
+ // --- Fragments ---
7
+ export const USER_FRAGMENT = gql`
8
+ fragment userFields on User {
9
+ id
10
+ name
11
+ admin
12
+ blocked
13
+ email
14
+ provider
15
+ lastSeen
16
+ created
17
+ avatar
18
+ github
19
+ institution
20
+ location
21
+ modified
22
+ orcid
23
+ }
24
+ `
25
+
26
+ // --- GET_USERS QUERY ---
27
+ export const GET_USERS = gql`
28
+ query GetUsers(
29
+ $orderBy: [UserSortInput!]
30
+ $isAdmin: Boolean
31
+ $isBlocked: Boolean
32
+ $search: String
33
+ $limit: Int
34
+ $offset: Int
35
+ ) {
36
+ users(
37
+ orderBy: $orderBy
38
+ isAdmin: $isAdmin
39
+ isBlocked: $isBlocked
40
+ search: $search
41
+ limit: $limit
42
+ offset: $offset
43
+ ) {
44
+ users {
45
+ ...userFields
46
+ }
47
+ totalCount
48
+ }
49
+ }
50
+ ${USER_FRAGMENT}
51
+ `
52
+
53
+ export const SET_ADMIN_MUTATION = gql`
54
+ mutation SetAdmin($id: ID!, $admin: Boolean!) {
55
+ setAdmin(id: $id, admin: $admin) {
56
+ ...userFields
57
+ }
58
+ }
59
+ ${USER_FRAGMENT}
60
+ `
61
+
62
+ export const SET_BLOCKED_MUTATION = gql`
63
+ mutation SetBlocked($id: ID!, $blocked: Boolean!) {
64
+ setBlocked(id: $id, blocked: $blocked) {
65
+ ...userFields
66
+ }
67
+ }
68
+ ${USER_FRAGMENT}
69
+ `
70
+
71
+ // --- INTERFACES ---
72
+ export interface GetUsersQueryResult {
73
+ users?: {
74
+ users: User[]
75
+ totalCount: number
76
+ }
77
+ }
78
+
79
+ export interface GetUsersQueryVariables {
80
+ orderBy?: { field: string; order: "ascending" | "descending" }[]
81
+ isAdmin?: boolean
82
+ isBlocked?: boolean
83
+ search?: string
84
+ limit?: number
85
+ offset?: number
86
+ }
87
+
88
+ export interface UseUsersOptions {
89
+ orderBy?: { field: string | null; order: "ascending" | "descending" }
90
+ isAdmin?: boolean | null
91
+ isBlocked?: boolean | null
92
+ search?: string | undefined
93
+ initialLimit?: number
94
+ }
95
+
96
+ // --- useUsers HOOK ---
97
+ export const useUsers = (options: UseUsersOptions = {}) => {
98
+ const [currentLimit] = useState(options.initialLimit || 100)
99
+ const [offset, setOffset] = useState(0)
100
+
101
+ const filterSortSearchVariables = useRef({
102
+ orderBy: options.orderBy?.field
103
+ ? [{ field: options.orderBy.field, order: options.orderBy.order }]
104
+ : undefined,
105
+ search: options.search,
106
+ isAdmin: typeof options.isAdmin === "boolean" ? options.isAdmin : undefined,
107
+ isBlocked: typeof options.isBlocked === "boolean"
108
+ ? options.isBlocked
109
+ : undefined,
110
+ })
111
+
112
+ // Effect to update the ref when options change and reset offset
113
+ useEffect(() => {
114
+ filterSortSearchVariables.current = {
115
+ orderBy: options.orderBy?.field
116
+ ? [{ field: options.orderBy.field, order: options.orderBy.order }]
117
+ : undefined,
118
+ search: options.search,
119
+ isAdmin: typeof options.isAdmin === "boolean"
120
+ ? options.isAdmin
121
+ : undefined,
122
+ isBlocked: typeof options.isBlocked === "boolean"
123
+ ? options.isBlocked
124
+ : undefined,
125
+ }
126
+ // When filters/sort/search change, reset the offset to 0 to start a new list
127
+ setOffset(0)
128
+ }, [options.orderBy, options.search, options.isAdmin, options.isBlocked])
129
+
130
+ const { data, loading, error, fetchMore, refetch } = useQuery<
131
+ GetUsersQueryResult
132
+ >(GET_USERS, {
133
+ variables: {
134
+ ...filterSortSearchVariables.current,
135
+ limit: currentLimit,
136
+ offset: offset,
137
+ },
138
+ notifyOnNetworkStatusChange: true,
139
+ fetchPolicy: "cache-and-network",
140
+ })
141
+
142
+ const [allUsers, setAllUsers] = useState<User[]>([])
143
+
144
+ useEffect(() => {
145
+ if (data?.users?.users) {
146
+ if (offset === 0) {
147
+ setAllUsers(data.users.users)
148
+ } else {
149
+ setAllUsers((prevUsers) => {
150
+ const newUsers = data.users!.users
151
+ const uniqueNewUsers = newUsers.filter(
152
+ (newUser) =>
153
+ !prevUsers.some((existingUser) => existingUser.id === newUser.id),
154
+ )
155
+ return [...prevUsers, ...uniqueNewUsers]
156
+ })
157
+ }
158
+ }
159
+ }, [data?.users?.users, offset])
160
+
161
+ const totalCount = data?.users?.totalCount || 0
162
+ const hasMore = totalCount > allUsers.length || loading
163
+
164
+ const loadMore = useCallback(() => {
165
+ if (hasMore && !loading) {
166
+ const newOffset = allUsers.length
167
+ fetchMore({
168
+ variables: {
169
+ ...filterSortSearchVariables.current,
170
+ offset: newOffset,
171
+ limit: currentLimit,
172
+ },
173
+ updateQuery: (prev, { fetchMoreResult }) => {
174
+ if (!fetchMoreResult) return prev
175
+
176
+ const incomingUsers = fetchMoreResult.users.users
177
+ const currentUsers = prev.users?.users || []
178
+
179
+ const combinedUsers = currentUsers.concat(
180
+ incomingUsers.filter(
181
+ (newUser) =>
182
+ !currentUsers.some(
183
+ (existingUser) => existingUser.id === newUser.id,
184
+ ),
185
+ ),
186
+ )
187
+
188
+ return {
189
+ users: {
190
+ ...prev.users,
191
+ users: combinedUsers,
192
+ totalCount: fetchMoreResult.users.totalCount,
193
+ },
194
+ }
195
+ },
196
+ })
197
+ }
198
+ }, [
199
+ allUsers.length,
200
+ hasMore,
201
+ loading,
202
+ fetchMore,
203
+ currentLimit,
204
+ filterSortSearchVariables,
205
+ ])
206
+
207
+ const refetchFullList = useCallback(
208
+ async (variablesToRefetch?: GetUsersQueryVariables) => {
209
+ setOffset(0)
210
+ await refetch({
211
+ ...filterSortSearchVariables.current,
212
+ ...variablesToRefetch,
213
+ offset: 0,
214
+ limit: currentLimit,
215
+ })
216
+ },
217
+ [refetch, filterSortSearchVariables, currentLimit],
218
+ )
219
+
220
+ const refetchCurrentPage = useCallback(async () => {
221
+ await refetch({
222
+ ...filterSortSearchVariables.current,
223
+ offset: 0,
224
+ limit: allUsers.length > 0 ? allUsers.length : currentLimit,
225
+ })
226
+ }, [refetch, filterSortSearchVariables, allUsers.length, currentLimit])
227
+
228
+ useEffect(() => {
229
+ if (error) {
230
+ Sentry.captureException(error)
231
+ }
232
+ }, [error])
233
+
234
+ return {
235
+ users: allUsers,
236
+ loading,
237
+ error,
238
+ refetchFullList,
239
+ refetchCurrentPage,
240
+ loadMore,
241
+ hasMore,
242
+ totalCount,
243
+ currentLimit,
244
+ currentOffset: allUsers.length,
245
+ filterSortSearchVariables: filterSortSearchVariables.current,
246
+ }
247
+ }
@@ -4,7 +4,7 @@ import { Navigate, Route, Routes } from "react-router-dom"
4
4
  // TODO - Re-enable code splitting these when we can
5
5
  import DatasetQuery from "./dataset/dataset-query"
6
6
  //import PreRefactorDatasetProps from './dataset/dataset-pre-refactor-container'
7
-
7
+ import { isAdmin } from "./authentication/admin-user.jsx"
8
8
  import FaqPage from "./pages/faq/faq"
9
9
  import FrontPageContainer from "./pages/front-page/front-page"
10
10
  import Admin from "./pages/admin/admin"
@@ -19,10 +19,8 @@ import { DatasetMetadata } from "./pages/metadata/dataset-metadata"
19
19
  import { TermsPage } from "./pages/terms"
20
20
  import { ImageAttribution } from "./pages/image-attribution"
21
21
  import { UserQuery } from "./users/user-query"
22
- import LoggedIn from "../scripts/authentication/logged-in"
23
- import LoggedOut from "../scripts/authentication/logged-out"
24
- import FourOThreePage from "./errors/403page"
25
22
  import { OrcidLinkPage } from "./pages/orcid-link"
23
+ import FourOThreePage from "./errors/403page"
26
24
 
27
25
  const AppRoutes: React.VoidFunctionComponent = () => (
28
26
  <Routes>
@@ -31,7 +29,10 @@ const AppRoutes: React.VoidFunctionComponent = () => (
31
29
  <Route path="/keygen" element={<APIKey />} />
32
30
  <Route path="/datasets/:datasetId/*" element={<DatasetQuery />} />
33
31
  <Route path="/search/*" element={<SearchRoutes />} />
34
- <Route path="/admin/*" element={<Admin />} />
32
+ <Route
33
+ path="/admin/*"
34
+ element={isAdmin() ? <Admin /> : <FourOThreePage />}
35
+ />
35
36
  <Route path="/error/*" element={<ErrorRoute />} />
36
37
  <Route path="/pet" element={<PETRedirect />} />
37
38
  <Route path="/cite" element={<Citation />} />
@@ -43,16 +44,7 @@ const AppRoutes: React.VoidFunctionComponent = () => (
43
44
  <Route path="/orcid-link" element={<OrcidLinkPage />} />
44
45
  <Route
45
46
  path="/user/:orcid/*"
46
- element={
47
- <>
48
- <LoggedIn>
49
- <UserQuery />
50
- </LoggedIn>
51
- <LoggedOut>
52
- <FourOThreePage />
53
- </LoggedOut>
54
- </>
55
- }
47
+ element={<UserQuery />}
56
48
  />
57
49
  <Route
58
50
  path="/saved"
@@ -8,27 +8,26 @@ export interface User {
8
8
  avatar?: string
9
9
  orcid?: string
10
10
  links?: string[]
11
+ admin?: boolean
12
+ blocked?: boolean
13
+ lastSeen?: string
14
+ created?: string
15
+ provider?: string
16
+ modified?: string
17
+ githubSynced?: Date
11
18
  }
12
19
 
13
20
  export interface UserRoutesProps {
14
- user: User
21
+ orcidUser: User
15
22
  hasEdit: boolean
16
23
  isUser: boolean
17
24
  }
18
25
  export interface UserCardProps {
19
- user: User
26
+ orcidUser: User
20
27
  }
21
28
 
22
29
  export interface UserAccountViewProps {
23
- user: {
24
- name: string
25
- email: string
26
- orcid?: string
27
- links?: string[]
28
- location?: string
29
- institution?: string
30
- github?: string
31
- }
30
+ orcidUser: User
32
31
  }
33
32
 
34
33
  export interface Dataset {
@@ -63,12 +62,12 @@ export interface DatasetCardProps {
63
62
  }
64
63
 
65
64
  export interface UserDatasetsViewProps {
66
- user: User
65
+ orcidUser: User
67
66
  hasEdit: boolean
68
67
  }
69
68
 
70
69
  export interface AccountContainerProps {
71
- user: User
70
+ orcidUser: User
72
71
  hasEdit: boolean
73
72
  isUser: boolean
74
73
  }
@@ -1,73 +1,58 @@
1
- // dependencies -------------------------------------------------------
2
-
3
1
  import React from "react"
4
- import PropTypes from "prop-types"
5
2
 
6
- type UploadProps = {
3
+ type UploadFileSelectProps = {
7
4
  resume?: boolean
8
5
  onClick?: React.MouseEventHandler<HTMLButtonElement>
9
- onChange?: (e?: { files: File[] }) => void
6
+ onChange: (e?: { files: File[] }) => void
10
7
  disabled?: boolean
11
8
  }
12
9
 
13
- class Upload extends React.Component<UploadProps> {
14
- public static propTypes = {}
15
- // life cycle events --------------------------------------------------
16
- render(): React.ReactNode {
17
- const resumeIcon = (
18
- <span>
19
- <i className="fa fa-repeat" />
20
- &nbsp;
21
- </span>
22
- )
23
- const icon = this.props.resume ? resumeIcon : null
24
- const text = this.props.resume ? "Resume" : "Select folder"
25
-
26
- return (
27
- <div
28
- className={"fileupload-btn" + (this.props.disabled ? " disabled" : "")}
29
- >
30
- <span>
31
- {icon}
32
- {text}
33
- </span>
34
- <input
35
- type="file"
36
- id="multifile-select"
37
- className="multifile-select-btn"
38
- onClick={this._click.bind(this)}
39
- onChange={this._onFileSelect.bind(this)}
40
- webkitdirectory="true"
41
- directory="true"
42
- disabled={this.props.disabled}
43
- />
44
- </div>
45
- )
46
- }
47
-
48
- // custom methods -----------------------------------------------------
49
-
50
- _click(e): void {
10
+ export const UploadFileSelect: React.FC<UploadFileSelectProps> = (
11
+ { resume, onClick, onChange, disabled },
12
+ ) => {
13
+ const handleClick = (e: React.MouseEvent<HTMLInputElement>): void => {
51
14
  e.stopPropagation()
52
- e.target.value = null
53
- if (this.props.onClick) {
54
- this.props.onClick(e)
15
+ // Reset the input value to allow selecting the same file/folder again
16
+ if (e.target instanceof HTMLInputElement) {
17
+ e.target.value = ""
55
18
  }
19
+ if (onClick) onClick(e as React.MouseEvent<HTMLButtonElement>)
56
20
  }
57
21
 
58
- _onFileSelect(e): void {
59
- if (e.target && e.target.files.length > 0) {
60
- const files = e.target.files
61
- this.props.onChange({ files })
22
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>): void => {
23
+ if (e.target && e.target.files && e.target.files.length > 0) {
24
+ const files = Array.from(e.target.files)
25
+ onChange({ files })
62
26
  }
63
27
  }
64
- }
65
28
 
66
- Upload.propTypes = {
67
- resume: PropTypes.bool,
68
- onClick: PropTypes.func,
69
- onChange: PropTypes.func,
70
- disabled: PropTypes.bool,
29
+ const resumeIcon = (
30
+ <span>
31
+ <i className="fa fa-repeat" />
32
+ &nbsp;
33
+ </span>
34
+ )
35
+ const icon = resume ? resumeIcon : null
36
+ const text = resume ? "Resume" : "Select folder"
37
+
38
+ return (
39
+ <div className={`fileupload-btn${disabled ? " disabled" : ""}`}>
40
+ <span>
41
+ {icon}
42
+ {text}
43
+ </span>
44
+ <input
45
+ type="file"
46
+ id="multifile-select"
47
+ className="multifile-select-btn"
48
+ onClick={handleClick}
49
+ onChange={handleFileSelect}
50
+ webkitdirectory="true"
51
+ directory="true"
52
+ disabled={disabled}
53
+ />
54
+ </div>
55
+ )
71
56
  }
72
57
 
73
- export default Upload
58
+ export default UploadFileSelect
@@ -6,7 +6,7 @@ import { useUser } from "../queries/user"
6
6
  const UploadSelect = () => {
7
7
  const { user, loading, error } = useUser()
8
8
  const disabled = loading || Boolean(error) || !user || !user.email
9
- const noEmail = !user.email
9
+ const noEmail = !(user?.email)
10
10
  return (
11
11
  <div>
12
12
  <UploaderContext.Consumer>
@@ -0,0 +1,127 @@
1
+ import React from "react"
2
+ import { render, screen } from "@testing-library/react"
3
+ import DatasetCard from "../dataset-card"
4
+
5
+ const mockDataset = {
6
+ id: "ds000001",
7
+ name: "Test Dataset",
8
+ created: "2025-01-01T00:00:00Z",
9
+ public: true,
10
+ analytics: {
11
+ downloads: 12345,
12
+ views: 67890,
13
+ },
14
+ followers: [
15
+ { userId: "user1", datasetId: "ds000001" },
16
+ { userId: "user2", datasetId: "ds000001" },
17
+ ],
18
+ stars: [
19
+ { userId: "user1", datasetId: "ds000001" },
20
+ ],
21
+ latestSnapshot: {
22
+ id: "ds000001:1.0.0",
23
+ size: 1024 ** 3,
24
+ issues: [{ severity: "low" }],
25
+ created: "2025-01-01T00:00:00Z",
26
+ description: {
27
+ Name: "Test Dataset Description",
28
+ Authors: ["John Doe"],
29
+ SeniorAuthor: "Dr. Smith",
30
+ DatasetType: "fMRI",
31
+ },
32
+ summary: {
33
+ modalities: ["fMRI"],
34
+ secondaryModalities: [],
35
+ sessions: 1,
36
+ subjects: 1,
37
+ subjectMetadata: [],
38
+ tasks: ["rest"],
39
+ size: 1024 ** 3,
40
+ totalFiles: 10,
41
+ dataProcessed: true,
42
+ pet: null,
43
+ },
44
+ validation: {
45
+ errors: [],
46
+ warnings: [],
47
+ },
48
+ },
49
+ uploader: {
50
+ id: "uploaderId123",
51
+ name: "Uploader Name",
52
+ orcid: "1234-5678-9012-3456",
53
+ },
54
+ permissions: {
55
+ id: "somePermId",
56
+ userPermissions: [
57
+ {
58
+ userId: "someUserId",
59
+ level: "admin",
60
+ access: "admin",
61
+ user: {
62
+ id: "someUser",
63
+ name: "Some User",
64
+ email: "some@user.com",
65
+ provider: "github",
66
+ },
67
+ },
68
+ ],
69
+ },
70
+ metadata: { ages: [20] },
71
+ snapshots: [
72
+ {
73
+ id: "ds000001:1.0.0",
74
+ created: "2025-01-01T00:00:00Z",
75
+ tag: "1.0.0",
76
+ },
77
+ ],
78
+ }
79
+
80
+ describe("DatasetCard", () => {
81
+ it("should render dataset information correctly", () => {
82
+ render(<DatasetCard dataset={mockDataset} hasEdit={false} />)
83
+ expect(screen.getByText("Test Dataset")).toBeInTheDocument()
84
+ expect(screen.getByText("ds000001")).toBeInTheDocument()
85
+ })
86
+
87
+ it("should hide the dataset if not public and hasEdit is false", () => {
88
+ const privateDataset = { ...mockDataset, public: false }
89
+ const { container } = render(
90
+ <DatasetCard dataset={privateDataset} hasEdit={false} />,
91
+ )
92
+ expect(container).toBeEmptyDOMElement()
93
+ })
94
+
95
+ it("should show the dataset if not public but hasEdit is true", () => {
96
+ const privateDataset = { ...mockDataset, public: false }
97
+ render(<DatasetCard dataset={privateDataset} hasEdit={true} />)
98
+ expect(screen.getByText("Test Dataset")).toBeInTheDocument()
99
+ })
100
+
101
+ it("should render activity details correctly", () => {
102
+ render(<DatasetCard dataset={mockDataset} hasEdit={false} />)
103
+ expect(screen.getByRole("img", { name: /activity/i })).toBeInTheDocument()
104
+ })
105
+
106
+ it("should render public icon if dataset is public", () => {
107
+ render(<DatasetCard dataset={mockDataset} hasEdit={false} />)
108
+ expect(screen.getByLabelText("Public")).toBeInTheDocument()
109
+ })
110
+
111
+ it("should not render public icon if dataset is not public", () => {
112
+ const privateDataset = { ...mockDataset, public: false }
113
+ render(<DatasetCard dataset={privateDataset} hasEdit={true} />)
114
+ expect(screen.queryByLabelText("Public")).not.toBeInTheDocument()
115
+ })
116
+
117
+ it("should display 'Unknown size' if latestSnapshot or size is missing", () => {
118
+ const datasetWithoutSize = {
119
+ ...mockDataset,
120
+ latestSnapshot: { ...mockDataset.latestSnapshot, size: undefined },
121
+ }
122
+ render(<DatasetCard dataset={datasetWithoutSize} hasEdit={false} />)
123
+ expect(screen.getByText("Dataset Size:")).toHaveTextContent(
124
+ "Dataset Size: Unknown size",
125
+ )
126
+ })
127
+ })