@openneuro/app 4.36.0-alpha.0 → 4.36.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.
@@ -1,124 +1,121 @@
1
- import React, { useState } from "react"
2
- import { Query } from "@apollo/client/react/components"
3
- import { gql } from "@apollo/client"
4
- import parseISO from "date-fns/parseISO"
5
- import formatDistanceToNow from "date-fns/formatDistanceToNow"
6
- import { Input } from "../../components/input/Input"
1
+ // packages/openneuro-app/src/scripts/pages/admin/users.tsx
2
+
3
+ import React, { useCallback, useEffect, useState } from "react"
7
4
  import { Loading } from "../../components/loading/Loading"
8
- import { formatDate } from "../../utils/date.js"
9
5
  import Helmet from "react-helmet"
10
6
  import { pageTitle } from "../../resources/strings.js"
11
- import { UserTools } from "./user-tools.js"
12
- import { USER_FRAGMENT } from "./user-fragment.js"
13
-
14
- export const GET_USERS = gql`
15
- query {
16
- users {
17
- ...userFields
18
- }
19
- }
20
- ${USER_FRAGMENT}
21
- `
22
-
23
- // TODO - Use the GraphQL type
24
- export interface User {
25
- id: string
26
- name: string
27
- admin: boolean
28
- blocked: boolean
29
- email?: string
30
- provider: string
31
- lastSeen?: string
32
- created: string
7
+ import type { User } from "../../types/user-types"
8
+ import styles from "./users.module.scss"
9
+ import { useUsers } from "../../queries/users"
10
+ import UserSummary from "./user-summary"
11
+ import * as Sentry from "@sentry/react"
12
+
13
+ const SORT_ASC_ICON = "fa fa-sort-asc"
14
+ const SORT_DESC_ICON = "fa fa-sort-desc"
15
+
16
+ const noResults = (loading: boolean) =>
17
+ loading ? <Loading /> : <h3>No Results Found</h3>
18
+
19
+ interface SortConfig {
20
+ field: string | null
21
+ order: "ascending" | "descending"
33
22
  }
34
23
 
35
- interface UsersQueryResultProps {
24
+ interface UsersProps {
25
+ users: User[]
26
+ refetchCurrentPage: () => void
36
27
  loading: boolean
37
- data: { users: User[] }
38
- refetch: () => void
28
+ onSortChange: (
29
+ field: string | null,
30
+ order: "ascending" | "descending",
31
+ ) => void
32
+ sortConfig: SortConfig
33
+ onFilterChange: (
34
+ filterType: "admin" | "blocked" | null,
35
+ value: boolean | null,
36
+ ) => void
37
+ filters: { admin: boolean | null; blocked: boolean | null }
38
+ onSearchChange: (searchValue: string | undefined) => void
39
+ currentSearchTerm: string | undefined
40
+ loadMore: () => void
41
+ hasMore: boolean
42
+ totalCount: number
39
43
  }
40
44
 
41
- export const UsersQueryResult = (
42
- { loading, data, refetch }: UsersQueryResultProps,
43
- ) => {
44
- if (loading) {
45
- return <Loading />
46
- } else {
47
- return (
48
- <Users loading={loading} users={data.users || []} refetch={refetch} />
49
- )
50
- }
51
- }
45
+ const Users = ({
46
+ users,
47
+ refetchCurrentPage,
48
+ loading,
49
+ onSortChange,
50
+ sortConfig,
51
+ onFilterChange,
52
+ filters,
53
+ onSearchChange,
54
+ currentSearchTerm,
55
+ loadMore,
56
+ hasMore,
57
+ totalCount,
58
+ }: UsersProps) => {
59
+ const hasUsers = users && users.length > 0
60
+ const [searchTerm, setSearchTerm] = useState<string | undefined>(
61
+ currentSearchTerm,
62
+ )
52
63
 
53
- export const UsersQuery = () => (
54
- <Query query={GET_USERS}>{UsersQueryResult}</Query>
55
- )
64
+ useEffect(() => {
65
+ setSearchTerm(currentSearchTerm)
66
+ }, [currentSearchTerm])
56
67
 
57
- const userSummary = (user) => {
58
- const lastLogin = user.lastlogin ? user.lastlogin : user.created
59
- const created = user.created
60
- return (
61
- <>
62
- <div className="summary-data">
63
- <b>Signed Up:</b>{" "}
64
- <div>
65
- {formatDate(created)} - {formatDistanceToNow(parseISO(created))} ago
66
- </div>
67
- </div>
68
- <div className="summary-data">
69
- <b>Last Signed In:</b>{" "}
70
- <div>
71
- {formatDate(lastLogin)} - {formatDistanceToNow(parseISO(lastLogin))}
72
- {" "}
73
- ago
74
- </div>
75
- </div>
76
- </>
68
+ const handleAdminFilterCheckboxChange = useCallback(
69
+ (e: React.ChangeEvent<HTMLInputElement>) => {
70
+ onFilterChange("admin", e.target.checked)
71
+ },
72
+ [onFilterChange],
77
73
  )
78
- }
79
74
 
80
- const noResults = (loading) => {
81
- return loading ? <Loading /> : <h4>No Results Found</h4>
82
- }
75
+ const handleBlockedFilterCheckboxChange = useCallback(
76
+ (e: React.ChangeEvent<HTMLInputElement>) => {
77
+ onFilterChange("blocked", e.target.checked)
78
+ },
79
+ [onFilterChange],
80
+ )
83
81
 
84
- const Users = ({ users, refetch, loading }) => {
85
- const [stringFilter, setStringFilter] = useState(null)
86
- const [adminFilter, setAdminFilter] = useState(false)
87
- const [blacklistFilter, setBlacklistFilter] = useState(false)
88
-
89
- const filteredUsers = users
90
- .filter((user) => !adminFilter || user.admin)
91
- .filter(
92
- (user) =>
93
- !stringFilter ||
94
- user.email?.toLowerCase().includes(stringFilter.toLowerCase()) ||
95
- user.name?.toLowerCase().includes(stringFilter.toLowerCase()),
96
- )
97
- .map((user, index) => {
98
- const adminBadge = user.admin ? "Admin" : null
99
- const userEmail = Object.hasOwn(user, "email") ? user.email : user.id
100
- return (
101
- <div className="fade-in user-panel panel panel-default" key={index}>
102
- <div className="user-col uc-name">
103
- <div>
104
- {user.name}{" "}
105
- {adminBadge && <span className="badge">{adminBadge}</span>}
106
- <UserTools user={user} refetch={refetch} />
107
- </div>
108
- </div>
109
- <div className="user-col user-panel-inner">
110
- <div className=" user-col uc-email">
111
- {userEmail}
112
- <div className=" uc-provider">
113
- <b>Provider:</b> {user.provider}
114
- </div>
115
- </div>
82
+ const handleInputChange = useCallback(
83
+ (e: React.ChangeEvent<HTMLInputElement>) => {
84
+ setSearchTerm(e.target.value || undefined)
85
+ },
86
+ [setSearchTerm],
87
+ )
116
88
 
117
- <div className=" user-col uc-summary">{userSummary(user)}</div>
118
- </div>
119
- </div>
120
- )
121
- })
89
+ const handleSearchSubmit = useCallback(() => {
90
+ onSearchChange(searchTerm)
91
+ }, [onSearchChange, searchTerm])
92
+
93
+ const handleClearSearch = useCallback(() => {
94
+ setSearchTerm(undefined)
95
+ onSearchChange(undefined)
96
+ }, [onSearchChange, setSearchTerm])
97
+
98
+ const handleSortButtonClick = useCallback(
99
+ (field: string) => {
100
+ let newOrder: "ascending" | "descending" = "ascending"
101
+
102
+ if (sortConfig.field === field) {
103
+ newOrder = sortConfig.order === "ascending" ? "descending" : "ascending"
104
+ } else {
105
+ if (field === "name" || field === "email" || field === "orcid") {
106
+ newOrder = "ascending"
107
+ } else if (
108
+ field === "created" ||
109
+ field === "lastSeen" ||
110
+ field === "modified"
111
+ ) {
112
+ newOrder = "descending"
113
+ }
114
+ }
115
+ onSortChange(field, newOrder)
116
+ },
117
+ [onSortChange, sortConfig],
118
+ )
122
119
 
123
120
  return (
124
121
  <>
@@ -128,58 +125,260 @@ const Users = ({ users, refetch, loading }) => {
128
125
  <div className="admin-users">
129
126
  <div className="header-wrap ">
130
127
  <h2>Current Users</h2>
131
-
132
- <Input
133
- name="Search Name Or Email"
134
- type="text"
135
- placeholder="Search Name or Email"
136
- onKeyDown={(e) => setStringFilter(e.target.value)}
137
- setValue={(_) => {}}
138
- />
139
- </div>
140
-
141
- <div className="filters-sort-wrap ">
142
- <span>
143
- <div className="filters">
144
- <label>Filter By:</label>
145
- <button
146
- className={adminFilter ? "active" : null}
147
- onClick={() => setAdminFilter(!adminFilter)}
148
- >
149
- <span className="filter-admin">
150
- <i
151
- className={adminFilter
152
- ? "fa fa-check-square-o"
153
- : "fa fa-square-o"}
154
- />{" "}
155
- Admin
156
- </span>
157
- </button>
158
- <button
159
- className={blacklistFilter ? "active" : null}
160
- onClick={() => setBlacklistFilter(!blacklistFilter)}
161
- >
162
- <span className="filter-admin">
163
- <i
164
- className={blacklistFilter
165
- ? "fa fa-check-square-o"
166
- : "fa fa-square-o"}
167
- />{" "}
168
- Blocked
169
- </span>
170
- </button>
128
+ <div className={styles.filterControls}>
129
+ <div>Filter:</div>
130
+ <label>
131
+ Admin:
132
+ <input
133
+ type="checkbox"
134
+ checked={filters.admin === true}
135
+ onChange={handleAdminFilterCheckboxChange}
136
+ />
137
+ {filters.admin === true
138
+ ? <i className="fa fa-check-square-o"></i>
139
+ : <i className="fa fa-square-o"></i>}
140
+ </label>
141
+ <label>
142
+ Blocked:
143
+ <input
144
+ type="checkbox"
145
+ checked={filters.blocked === true}
146
+ onChange={handleBlockedFilterCheckboxChange}
147
+ />
148
+ {filters.blocked === true
149
+ ? <i className="fa fa-check-square-o"></i>
150
+ : <i className="fa fa-square-o"></i>}
151
+ </label>
152
+ </div>
153
+ <div className={styles.searchControl}>
154
+ <div className={styles.searchInputWrapper}>
155
+ <input
156
+ type="text"
157
+ placeholder="Search name or email"
158
+ value={searchTerm || ""}
159
+ onChange={handleInputChange}
160
+ />
161
+ {searchTerm && (
162
+ <button
163
+ className={styles.clearSearchButton}
164
+ onClick={handleClearSearch}
165
+ >
166
+ &#x2715;
167
+ </button>
168
+ )}
171
169
  </div>
172
- </span>
170
+ <button
171
+ className={styles.searchSubmitButton}
172
+ onClick={handleSearchSubmit}
173
+ >
174
+ Search
175
+ </button>
176
+ </div>
173
177
  </div>
174
178
 
175
- <div>
176
- <div className="users-panel-wrap">
177
- {filteredUsers.length ? filteredUsers : noResults(loading)}
179
+ <div className={styles.gridContainer}>
180
+ <div className={styles.gridHead}>
181
+ <button
182
+ className={`${styles.sortButton} ${styles.colLarge} ${
183
+ sortConfig.field === "name" ? styles.active : ""
184
+ }`}
185
+ onClick={() => handleSortButtonClick("name")}
186
+ >
187
+ Name {sortConfig.field === "name" && (
188
+ <i
189
+ className={sortConfig.order === "ascending"
190
+ ? SORT_ASC_ICON
191
+ : SORT_DESC_ICON}
192
+ >
193
+ </i>
194
+ )}
195
+ </button>
196
+ <button
197
+ className={`${styles.sortButton} ${styles.colSmall} ${
198
+ sortConfig.field === "email" ? styles.active : ""
199
+ }`}
200
+ onClick={() => handleSortButtonClick("email")}
201
+ >
202
+ Email {sortConfig.field === "email" && (
203
+ <i
204
+ className={sortConfig.order === "ascending"
205
+ ? SORT_ASC_ICON
206
+ : SORT_DESC_ICON}
207
+ >
208
+ </i>
209
+ )}
210
+ </button>
211
+ <button
212
+ className={`${styles.sortButton} ${styles.colSmall} ${
213
+ sortConfig.field === "orcid" ? styles.active : ""
214
+ }`}
215
+ onClick={() => handleSortButtonClick("orcid")}
216
+ >
217
+ ORCID {sortConfig.field === "orcid" && (
218
+ <i
219
+ className={sortConfig.order === "ascending"
220
+ ? SORT_ASC_ICON
221
+ : SORT_DESC_ICON}
222
+ >
223
+ </i>
224
+ )}
225
+ </button>
226
+ <button
227
+ className={`${styles.sortButton} ${styles.colSmall} ${
228
+ sortConfig.field === "created" ? styles.active : ""
229
+ }`}
230
+ onClick={() => handleSortButtonClick("created")}
231
+ >
232
+ Created {sortConfig.field === "created" && (
233
+ <i
234
+ className={sortConfig.order === "ascending"
235
+ ? SORT_ASC_ICON
236
+ : SORT_DESC_ICON}
237
+ >
238
+ </i>
239
+ )}
240
+ </button>
241
+ <button
242
+ className={`${styles.sortButton} ${styles.colSmall} ${
243
+ sortConfig.field === "lastSeen" ? styles.active : ""
244
+ }`}
245
+ onClick={() => handleSortButtonClick("lastSeen")}
246
+ >
247
+ Login {sortConfig.field === "lastSeen" && (
248
+ <i
249
+ className={sortConfig.order === "ascending"
250
+ ? SORT_ASC_ICON
251
+ : SORT_DESC_ICON}
252
+ >
253
+ </i>
254
+ )}
255
+ </button>
256
+ <button
257
+ className={`${styles.sortButton} ${styles.colSmall} ${
258
+ sortConfig.field === "modified" ? styles.active : ""
259
+ }`}
260
+ onClick={() => handleSortButtonClick("modified")}
261
+ >
262
+ Modified {sortConfig.field === "modified" && (
263
+ <i
264
+ className={sortConfig.order === "ascending"
265
+ ? SORT_ASC_ICON
266
+ : SORT_DESC_ICON}
267
+ >
268
+ </i>
269
+ )}
270
+ </button>
271
+ <span className={`${styles.headingCol} ${styles.colFlex}`}>
272
+ Actions
273
+ </span>
178
274
  </div>
275
+ <ul className={styles.usersWrap}>
276
+ {hasUsers
277
+ ? users.map((user, index) => (
278
+ <li
279
+ className={styles.userPanel + " panel panel-default fade-in"}
280
+ key={user.id || index}
281
+ >
282
+ <UserSummary
283
+ user={user}
284
+ refetchCurrentPage={refetchCurrentPage}
285
+ />
286
+ </li>
287
+ ))
288
+ : noResults(loading)}
289
+ </ul>
290
+ </div>
291
+
292
+ <div className={styles.loadMoreContainer}>
293
+ {loading && users.length > 0 && <p>Loading more users...</p>}
294
+ {!loading && hasMore && (
295
+ <button onClick={loadMore} className={styles.loadMoreButton}>
296
+ Load More ({users.length} of {totalCount})
297
+ </button>
298
+ )}
299
+ {!hasMore && users.length > 0 && !loading && (
300
+ <p>All {totalCount} users loaded.</p>
301
+ )}
179
302
  </div>
180
303
  </div>
181
304
  </>
182
305
  )
183
306
  }
184
307
 
185
- export default UsersQuery
308
+ export const UsersPage = () => {
309
+ const [sortConfig, setSortConfig] = useState<{
310
+ field: string | null
311
+ order: "ascending" | "descending"
312
+ }>({ field: "name", order: "ascending" })
313
+ const [filters, setFilters] = useState<{
314
+ admin: boolean | null
315
+ blocked: boolean | null
316
+ }>({ admin: null, blocked: null })
317
+ const [search, setSearch] = useState<string | undefined>(undefined)
318
+
319
+ const {
320
+ users,
321
+ loading,
322
+ error,
323
+ refetchCurrentPage,
324
+ loadMore,
325
+ hasMore,
326
+ totalCount,
327
+ } = useUsers({
328
+ orderBy: sortConfig,
329
+ isAdmin: filters.admin,
330
+ isBlocked: filters.blocked,
331
+ search: search,
332
+ initialLimit: 100,
333
+ })
334
+
335
+ const handleSortChange = useCallback(
336
+ (field: string | null, order: "ascending" | "descending") => {
337
+ setSortConfig({ field, order })
338
+ },
339
+ [setSortConfig],
340
+ )
341
+
342
+ const handleFilterChange = useCallback(
343
+ (filterType: "admin" | "blocked" | null, value: boolean) => {
344
+ setFilters((prevFilters) => ({
345
+ ...prevFilters,
346
+ [filterType]: value ? true : null,
347
+ }))
348
+ },
349
+ [setFilters],
350
+ )
351
+
352
+ const handleSearchChange = useCallback(
353
+ (searchValue: string | undefined) => {
354
+ setSearch(searchValue)
355
+ },
356
+ [setSearch],
357
+ )
358
+
359
+ if (loading && users.length === 0) {
360
+ return <Loading />
361
+ }
362
+
363
+ if (error) {
364
+ Sentry.captureException(error)
365
+ return <p>Error loading users...</p>
366
+ }
367
+
368
+ return (
369
+ <Users
370
+ users={users || []}
371
+ refetchCurrentPage={refetchCurrentPage}
372
+ loading={loading}
373
+ onSortChange={handleSortChange}
374
+ sortConfig={sortConfig}
375
+ onFilterChange={handleFilterChange}
376
+ filters={filters}
377
+ onSearchChange={handleSearchChange}
378
+ currentSearchTerm={search}
379
+ loadMore={loadMore}
380
+ hasMore={hasMore}
381
+ totalCount={totalCount}
382
+ />
383
+ )
384
+ }