@openneuro/app 4.35.0 → 4.36.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 +3 -3
  2. package/src/client.jsx +1 -0
  3. package/src/scripts/components/button/button.scss +14 -0
  4. package/src/scripts/components/search-page/SearchResultItem.tsx +3 -1
  5. package/src/scripts/components/search-page/search-page.scss +1 -13
  6. package/src/scripts/config.ts +6 -0
  7. package/src/scripts/dataset/files/__tests__/__snapshots__/file.spec.jsx.snap +2 -2
  8. package/src/scripts/dataset/files/file.tsx +2 -2
  9. package/src/scripts/dataset/mutations/__tests__/update-file.spec.tsx +126 -0
  10. package/src/scripts/dataset/mutations/update-file.jsx +20 -5
  11. package/src/scripts/dataset/routes/snapshot.tsx +1 -1
  12. package/src/scripts/errors/errorRoute.tsx +2 -0
  13. package/src/scripts/queries/user.ts +120 -3
  14. package/src/scripts/types/user-types.ts +11 -13
  15. package/src/scripts/uploader/file-select.tsx +42 -57
  16. package/src/scripts/uploader/upload-select.jsx +1 -1
  17. package/src/scripts/users/__tests__/dataset-card.spec.tsx +127 -0
  18. package/src/scripts/users/__tests__/user-account-view.spec.tsx +150 -67
  19. package/src/scripts/users/__tests__/user-card.spec.tsx +6 -17
  20. package/src/scripts/users/__tests__/user-query.spec.tsx +133 -38
  21. package/src/scripts/users/__tests__/user-routes.spec.tsx +156 -27
  22. package/src/scripts/users/__tests__/user-tabs.spec.tsx +7 -7
  23. package/src/scripts/users/components/edit-list.tsx +26 -5
  24. package/src/scripts/users/components/edit-string.tsx +40 -13
  25. package/src/scripts/users/components/editable-content.tsx +10 -3
  26. package/src/scripts/users/components/user-dataset-filters.tsx +205 -121
  27. package/src/scripts/users/dataset-card.tsx +3 -2
  28. package/src/scripts/users/github-auth-button.tsx +98 -0
  29. package/src/scripts/users/scss/datasetcard.module.scss +65 -12
  30. package/src/scripts/users/scss/useraccountview.module.scss +1 -1
  31. package/src/scripts/users/user-account-view.tsx +43 -34
  32. package/src/scripts/users/user-card.tsx +12 -17
  33. package/src/scripts/users/user-container.tsx +9 -5
  34. package/src/scripts/users/user-datasets-view.tsx +350 -40
  35. package/src/scripts/users/user-menu.tsx +4 -9
  36. package/src/scripts/users/user-notifications-view.tsx +9 -7
  37. package/src/scripts/users/user-query.tsx +3 -3
  38. package/src/scripts/users/user-routes.tsx +11 -5
  39. package/src/scripts/users/user-tabs.tsx +4 -2
  40. package/src/scripts/users/__tests__/datasest-card.spec.tsx +0 -201
  41. package/src/scripts/users/fragments/query.js +0 -42
@@ -1,11 +1,86 @@
1
+ import { vi } from "vitest"
1
2
  import React from "react"
2
3
  import { cleanup, render, screen } from "@testing-library/react"
3
4
  import { MemoryRouter } from "react-router-dom"
4
5
  import { MockedProvider } from "@apollo/client/testing"
6
+
7
+ // Component under test
5
8
  import { UserRoutes } from "../user-routes"
9
+
10
+ // Types and Queries
6
11
  import type { User } from "../../types/user-types"
7
- import { DATASETS_QUERY } from "../user-datasets-view"
8
- import { GET_USER } from "../../queries/user"
12
+ import { ADVANCED_SEARCH_DATASETS_QUERY, GET_USER } from "../../queries/user"
13
+
14
+ vi.mock("./user-container", () => {
15
+ return {
16
+ UserAccountContainer: vi.fn((props) => (
17
+ <div data-testid="mock-user-account-container">
18
+ Mocked UserAccountContainer
19
+ {props.children}
20
+ <p>Container ORCID: {props.orcidUser?.orcid}</p>
21
+ <p>Container Has Edit: {props.hasEdit ? "true" : "false"}</p>
22
+ <p>Container Is User: {props.isUser ? "true" : "false"}</p>
23
+ </div>
24
+ )),
25
+ }
26
+ })
27
+
28
+ vi.mock("./user-account-view", () => ({
29
+ UserAccountView: vi.fn((props) => (
30
+ <div data-testid="user-account-view">
31
+ Mocked UserAccountView
32
+ <p>View ORCID: {props.orcidUser?.orcid}</p>
33
+ </div>
34
+ )),
35
+ }))
36
+
37
+ vi.mock("./user-notifications-view", () => ({
38
+ UserNotificationsView: vi.fn((props) => (
39
+ <div data-testid="user-notifications-view">
40
+ Mocked UserNotificationsView
41
+ {props.children}
42
+ <p>Notifications ORCID: {props.orcidUser?.orcid}</p>
43
+ </div>
44
+ )),
45
+ }))
46
+
47
+ vi.mock("./user-datasets-view", () => ({
48
+ UserDatasetsView: vi.fn((props) => (
49
+ <div data-testid="user-datasets-view">
50
+ Mocked UserDatasetsView
51
+ <p>Datasets ORCID: {props.orcidUser?.orcid}</p>
52
+ <p>Datasets Has Edit: {props.hasEdit ? "true" : "false"}</p>
53
+ </div>
54
+ )),
55
+ }))
56
+
57
+ vi.mock("../errors/404page", () => ({
58
+ default: vi.fn(() => (
59
+ <div data-testid="404-page">
60
+ 404: The page you are looking for does not exist.
61
+ </div>
62
+ )),
63
+ }))
64
+
65
+ vi.mock("../errors/403page", () => ({
66
+ default: vi.fn(() => (
67
+ <div data-testid="403-page">
68
+ 403: You do not have access to this page, you may need to sign in.
69
+ </div>
70
+ )),
71
+ }))
72
+
73
+ vi.mock("./user-notifications-tab-content", () => ({
74
+ UnreadNotifications: vi.fn(() => (
75
+ <div data-testid="unread-notifications">Unread Notifications</div>
76
+ )),
77
+ SavedNotifications: vi.fn(() => (
78
+ <div data-testid="saved-notifications">Saved Notifications</div>
79
+ )),
80
+ ArchivedNotifications: vi.fn(() => (
81
+ <div data-testid="archived-notifications">Archived Notifications</div>
82
+ )),
83
+ }))
9
84
 
10
85
  const defaultUser: User = {
11
86
  id: "1",
@@ -15,15 +90,33 @@ const defaultUser: User = {
15
90
  institution: "Unknown Institution",
16
91
  email: "john.doe@example.com",
17
92
  avatar: "https://dummyimage.com/200x200/000/fff",
18
- orcid: "0000-0000-0000-0000",
93
+ orcid: "0000-0000-0000-0000", // Ensure ORCID is present for mocks
19
94
  links: [],
20
95
  }
21
96
 
22
97
  const mocks = [
23
98
  {
24
99
  request: {
25
- query: DATASETS_QUERY,
26
- variables: { first: 25 },
100
+ query: ADVANCED_SEARCH_DATASETS_QUERY,
101
+ variables: {
102
+ first: 26,
103
+ query: {
104
+ bool: {
105
+ filter: [
106
+ {
107
+ terms: {
108
+ "permissions.userPermissions.user.id": [defaultUser.id],
109
+ },
110
+ },
111
+ ],
112
+ must: [{ match_all: {} }],
113
+ },
114
+ },
115
+ sortBy: null,
116
+ cursor: null,
117
+ allDatasets: true,
118
+ datasetStatus: undefined,
119
+ },
27
120
  },
28
121
  result: {
29
122
  data: {
@@ -72,7 +165,7 @@ const mocks = [
72
165
  {
73
166
  request: {
74
167
  query: GET_USER,
75
- variables: { userId: defaultUser.id },
168
+ variables: { id: defaultUser.orcid },
76
169
  },
77
170
  result: {
78
171
  data: {
@@ -81,9 +174,8 @@ const mocks = [
81
174
  },
82
175
  },
83
176
  ]
84
-
85
177
  const renderWithRouter = (
86
- user: User,
178
+ orcidUser: User,
87
179
  route: string,
88
180
  hasEdit: boolean,
89
181
  isUser: boolean,
@@ -91,53 +183,90 @@ const renderWithRouter = (
91
183
  return render(
92
184
  <MockedProvider mocks={mocks} addTypename={false}>
93
185
  <MemoryRouter initialEntries={[route]}>
94
- <UserRoutes user={user} hasEdit={hasEdit} isUser={isUser} />
186
+ <UserRoutes orcidUser={orcidUser} hasEdit={hasEdit} isUser={isUser} />
95
187
  </MemoryRouter>
96
188
  </MockedProvider>,
97
189
  )
98
190
  }
99
191
 
100
192
  describe("UserRoutes Component", () => {
101
- const user: User = defaultUser
193
+ const userToPass: User = defaultUser
194
+
195
+ afterEach(() => {
196
+ cleanup()
197
+ vi.clearAllMocks()
198
+ vi.resetAllMocks()
199
+ })
102
200
 
103
201
  it("renders UserDatasetsView for the default route", async () => {
104
- renderWithRouter(user, "/", true, true)
202
+ renderWithRouter(userToPass, "/", true, true)
203
+ // Expect the default to be the datasets view
105
204
  const datasetsView = await screen.findByTestId("user-datasets-view")
106
205
  expect(datasetsView).toBeInTheDocument()
206
+ expect(screen.getByText(userToPass.orcid)).toBeInTheDocument()
107
207
  })
108
208
 
109
209
  it("renders FourOFourPage for an invalid route", async () => {
110
- renderWithRouter(user, "/nonexistent-route", true, true)
111
- const errorMessage = await screen.findByText(
112
- /404: The page you are looking for does not exist./i,
113
- )
114
- expect(errorMessage).toBeInTheDocument()
210
+ renderWithRouter(userToPass, "/nonexistent-route", true, true)
211
+ // Expect the mocked 404 page
212
+ expect(
213
+ screen.getByText(/404: The page you are looking for does not exist./i),
214
+ ).toBeInTheDocument()
115
215
  })
116
216
 
117
- // it("renders UserAccountView when hasEdit is true", async () => {
118
- // renderWithRouter(user, "/account", true, true);
119
- // const accountView = await screen.findByTestId("user-account-view");
120
- // expect(accountView).toBeInTheDocument();
121
- // });
217
+ it("renders UserAccountView when hasEdit is true", async () => {
218
+ renderWithRouter(userToPass, "/account", true, true)
219
+ // Expect the mocked account view
220
+ const accountView = await screen.findByTestId("user-account-view")
221
+ expect(accountView).toBeInTheDocument()
222
+ })
122
223
 
123
224
  it("renders UserNotificationsView when hasEdit is true", async () => {
124
- renderWithRouter(user, "/notifications", true, true)
225
+ renderWithRouter(userToPass, "/notifications", true, true)
226
+ // Expect the mocked notifications view
227
+ const notificationsView = await screen.findByTestId(
228
+ "user-notifications-view",
229
+ )
230
+ expect(notificationsView).toBeInTheDocument()
231
+ })
232
+
233
+ it("renders SavedNotifications within UserNotificationsView", async () => {
234
+ renderWithRouter(userToPass, "/notifications/saved", true, true)
125
235
  const notificationsView = await screen.findByTestId(
126
236
  "user-notifications-view",
127
237
  )
128
238
  expect(notificationsView).toBeInTheDocument()
239
+ expect(screen.getByText("Saved Notification Example")).toBeInTheDocument()
240
+ })
241
+
242
+ it("renders ArchivedNotifications within UserNotificationsView", async () => {
243
+ renderWithRouter(userToPass, "/notifications/archived", true, true)
244
+ const notificationsView = await screen.findByTestId(
245
+ "user-notifications-view",
246
+ )
247
+ expect(notificationsView).toBeInTheDocument()
248
+ expect(screen.getByText("Archived Notification Example"))
249
+ .toBeInTheDocument()
250
+ })
251
+
252
+ it("renders 404 for unknown notification sub-route", async () => {
253
+ renderWithRouter(userToPass, "/notifications/nonexistent", true, true)
254
+ expect(
255
+ screen.getByText(/404: The page you are looking for does not exist./i),
256
+ ).toBeInTheDocument()
129
257
  })
130
258
 
131
259
  it("renders FourOThreePage when hasEdit is false for restricted routes", async () => {
132
260
  const restrictedRoutes = ["/account", "/notifications"]
133
261
 
134
262
  for (const route of restrictedRoutes) {
263
+ renderWithRouter(userToPass, route, false, true)
264
+ expect(
265
+ screen.getByText(
266
+ /403: You do not have access to this page, you may need to sign in./i,
267
+ ),
268
+ ).toBeInTheDocument()
135
269
  cleanup()
136
- renderWithRouter(user, route, false, true)
137
- const errorMessage = await screen.findByText(
138
- /403: You do not have access to this page, you may need to sign in./i,
139
- )
140
- expect(errorMessage).toBeInTheDocument()
141
270
  }
142
271
  })
143
272
  })
@@ -54,12 +54,11 @@ describe("UserAccountTabs Component", () => {
54
54
  const datasetsTab = screen.getByText("My Datasets")
55
55
  expect(hasActiveClass(datasetsTab)).toBe(true)
56
56
 
57
- const notificationsTab = screen.getByText("Notifications")
57
+ //const notificationsTab = screen.getByText("Notifications")
58
58
 
59
- fireEvent.click(notificationsTab)
59
+ //fireEvent.click(notificationsTab)
60
60
 
61
- expect(hasActiveClass(notificationsTab)).toBe(true)
62
- expect(hasActiveClass(datasetsTab)).toBe(false)
61
+ //expect(hasActiveClass(notificationsTab)).toBe(true)
63
62
 
64
63
  const accountTab = screen.getByText("Account Info")
65
64
 
@@ -67,20 +66,21 @@ describe("UserAccountTabs Component", () => {
67
66
 
68
67
  expect(hasActiveClass(accountTab)).toBe(true)
69
68
  expect(hasActiveClass(datasetsTab)).toBe(false)
70
- expect(hasActiveClass(notificationsTab)).toBe(false)
69
+ //expect(hasActiveClass(notificationsTab)).toBe(false)
70
+ expect(hasActiveClass(datasetsTab)).toBe(false)
71
71
  })
72
72
 
73
73
  it("should trigger animation state when a tab is clicked", async () => {
74
74
  render(<UserAccountTabsWrapper />)
75
75
 
76
- const notificationsTab = screen.getByText("Notifications")
76
+ const accountTab = screen.getByText("Account Info")
77
77
  // Utility function to check if an element has 'clicked' class - used because of CSS module discrepancies between classNames
78
78
  const hasClickedClass = (element) => element.className.includes("clicked")
79
79
  const tabsContainer = await screen.findByRole("list")
80
80
 
81
81
  expect(hasClickedClass(tabsContainer)).toBe(false)
82
82
 
83
- fireEvent.click(notificationsTab)
83
+ fireEvent.click(accountTab)
84
84
 
85
85
  expect(hasClickedClass(tabsContainer)).toBe(true)
86
86
  })
@@ -6,7 +6,7 @@ interface EditListProps {
6
6
  placeholder?: string
7
7
  elements?: string[]
8
8
  setElements: (elements: string[]) => void
9
- validation?: RegExp
9
+ validation?: RegExp | ((value: string) => boolean)
10
10
  validationMessage?: string
11
11
  }
12
12
 
@@ -30,17 +30,32 @@ export const EditList: React.FC<EditListProps> = (
30
30
  const removeElement = (index: number): void => {
31
31
  setElements(elements.filter((_, i) => i !== index))
32
32
  }
33
+ const isInputValid = (value: string): boolean => {
34
+ if (!validation) {
35
+ return true
36
+ }
37
+
38
+ if (validation instanceof RegExp) {
39
+ return validation.test(value)
40
+ } else if (typeof validation === "function") {
41
+ return validation(value)
42
+ }
43
+
44
+ return true
45
+ }
33
46
 
34
47
  // Add a new element to the list
35
48
  const addElement = (): void => {
36
- if (!newElement.trim()) {
49
+ const trimmedNewElement = newElement.trim()
50
+
51
+ if (!trimmedNewElement) {
37
52
  setWarnEmpty(true)
38
53
  setWarnValidation(null)
39
- } else if (validation && !validation.test(newElement.trim())) {
54
+ } else if (!isInputValid(trimmedNewElement)) {
40
55
  setWarnValidation(validationMessage || "Invalid input format")
41
56
  setWarnEmpty(false)
42
57
  } else {
43
- setElements([...elements, newElement.trim()])
58
+ setElements([...elements, trimmedNewElement])
44
59
  setWarnEmpty(false)
45
60
  setWarnValidation(null)
46
61
  setNewElement("")
@@ -62,7 +77,13 @@ export const EditList: React.FC<EditListProps> = (
62
77
  className="form-control"
63
78
  placeholder={placeholder}
64
79
  value={newElement}
65
- onChange={(e) => setNewElement(e.target.value)}
80
+ onChange={(e) => {
81
+ setNewElement(e.target.value)
82
+ if (warnEmpty || warnValidation) {
83
+ setWarnEmpty(false)
84
+ setWarnValidation(null)
85
+ }
86
+ }}
66
87
  onKeyDown={handleKeyDown}
67
88
  />
68
89
  <Button
@@ -7,7 +7,7 @@ interface EditStringProps {
7
7
  setValue: (value: string) => void
8
8
  placeholder?: string
9
9
  closeEditing: () => void
10
- validation?: RegExp
10
+ validation?: RegExp | ((value: string) => boolean)
11
11
  validationMessage?: string
12
12
  }
13
13
 
@@ -25,18 +25,35 @@ export const EditString: React.FC<EditStringProps> = (
25
25
  const [warnEmpty, setWarnEmpty] = useState<string | null>(null)
26
26
  const [warnValidation, setWarnValidation] = useState<string | null>(null)
27
27
 
28
+ // Helper for validation logic
29
+ const isInputValid = (inputValue: string): boolean => {
30
+ if (!validation) {
31
+ return true
32
+ }
33
+
34
+ if (validation instanceof RegExp) {
35
+ return validation.test(inputValue)
36
+ } else if (typeof validation === "function") {
37
+ return validation(inputValue)
38
+ }
39
+
40
+ return true
41
+ }
42
+
43
+ const trimmedCurrentValue = currentValue.trim()
44
+
28
45
  useEffect(() => {
29
- // Show warning only if there was an initial value and it was deleted
30
- if (value !== "" && currentValue === "") {
46
+ // Logic for "empty" warning
47
+ if (value !== "" && trimmedCurrentValue === "") {
31
48
  setWarnEmpty(
32
- "Your input is empty. This will delete the previously saved value..",
49
+ "Your input is empty. This will delete the previously saved value.",
33
50
  )
34
51
  } else {
35
52
  setWarnEmpty(null)
36
53
  }
37
54
 
38
- // Validation logic
39
- if (validation && currentValue && !validation.test(currentValue)) {
55
+ // Logic for "validation" warning
56
+ if (trimmedCurrentValue !== "" && !isInputValid(trimmedCurrentValue)) {
40
57
  setWarnValidation(validationMessage || "Invalid input")
41
58
  } else {
42
59
  setWarnValidation(null)
@@ -44,10 +61,13 @@ export const EditString: React.FC<EditStringProps> = (
44
61
  }, [currentValue, value, validation, validationMessage])
45
62
 
46
63
  const handleSave = (): void => {
47
- if (!warnValidation) {
48
- setValue(currentValue.trim())
49
- closeEditing()
64
+ if (warnValidation) {
65
+ // Do not save if there's a validation error
66
+ return
50
67
  }
68
+
69
+ setValue(trimmedCurrentValue)
70
+ closeEditing()
51
71
  }
52
72
 
53
73
  // Handle Enter key press for saving
@@ -66,7 +86,14 @@ export const EditString: React.FC<EditStringProps> = (
66
86
  className="form-control"
67
87
  placeholder={placeholder}
68
88
  value={currentValue}
69
- onChange={(e) => setCurrentValue(e.target.value)}
89
+ onChange={(e) => {
90
+ setCurrentValue(e.target.value)
91
+ // Clear warnings as user types again
92
+ if (warnEmpty || warnValidation) {
93
+ setWarnEmpty(null)
94
+ setWarnValidation(null)
95
+ }
96
+ }}
70
97
  onKeyDown={handleKeyDown}
71
98
  />
72
99
  <Button
@@ -77,11 +104,11 @@ export const EditString: React.FC<EditStringProps> = (
77
104
  onClick={handleSave}
78
105
  />
79
106
  </div>
80
- {/* Show empty value warning only if content was deleted */}
81
- {warnEmpty && currentValue === "" && (
107
+ {/* Display warning about deleting previous value if applicable */}
108
+ {warnEmpty && trimmedCurrentValue === "" && (
82
109
  <small className="warning-text">{warnEmpty}</small>
83
110
  )}
84
- {/* Show validation error */}
111
+ {/* Display validation error */}
85
112
  {warnValidation && (
86
113
  <small className="warning-text">{warnValidation}</small>
87
114
  )}
@@ -11,7 +11,7 @@ interface EditableContentProps {
11
11
  setRows: React.Dispatch<React.SetStateAction<string[] | string>>
12
12
  className: string
13
13
  heading: string
14
- validation?: RegExp
14
+ validation?: RegExp | ((value: string) => boolean)
15
15
  validationMessage?: string
16
16
  "data-testid"?: string
17
17
  }
@@ -33,9 +33,16 @@ export const EditableContent: React.FC<EditableContentProps> = ({
33
33
 
34
34
  // Function to handle validation of user input
35
35
  const handleValidation = (value: string): boolean => {
36
- if (validation && !validation.test(value)) {
37
- return false
36
+ if (!validation) {
37
+ return true
38
38
  }
39
+
40
+ if (validation instanceof RegExp) {
41
+ return validation.test(value)
42
+ } else if (typeof validation === "function") {
43
+ return validation(value)
44
+ }
45
+
39
46
  return true
40
47
  }
41
48