@openneuro/app 4.29.8 → 4.30.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 (36) hide show
  1. package/.scss-lint.yml +11 -11
  2. package/maintenance.html +26 -20
  3. package/package.json +3 -3
  4. package/src/@types/custom.d.ts +6 -0
  5. package/src/index.html +14 -10
  6. package/src/scripts/datalad/routes/dataset-redirect.tsx +2 -0
  7. package/src/scripts/dataset/mutations/__tests__/update-permissions.spec.jsx +2 -1
  8. package/src/scripts/dataset/mutations/update-permissions.tsx +1 -9
  9. package/src/scripts/routes.tsx +17 -0
  10. package/src/scripts/users/__tests__/user-account-view.spec.tsx +152 -0
  11. package/src/scripts/users/__tests__/user-card.spec.tsx +110 -0
  12. package/src/scripts/users/__tests__/user-query.spec.tsx +65 -0
  13. package/src/scripts/users/__tests__/user-routes.spec.tsx +102 -0
  14. package/src/scripts/users/__tests__/user-tabs.spec.tsx +84 -0
  15. package/src/scripts/users/components/close-button.tsx +20 -0
  16. package/src/scripts/users/components/edit-button.tsx +20 -0
  17. package/src/scripts/users/components/edit-list.tsx +103 -0
  18. package/src/scripts/users/components/edit-string.tsx +90 -0
  19. package/src/scripts/users/components/editable-content.tsx +98 -0
  20. package/src/scripts/users/scss/editable-content.scss +15 -0
  21. package/src/scripts/users/scss/user-meta-blocks.scss +14 -0
  22. package/src/scripts/users/scss/useraccountview.module.scss +20 -0
  23. package/src/scripts/users/scss/usercard.module.scss +24 -0
  24. package/src/scripts/users/scss/usercontainer.module.scss +38 -0
  25. package/src/scripts/users/scss/usertabs.module.scss +63 -0
  26. package/src/scripts/users/user-account-view.tsx +142 -0
  27. package/src/scripts/users/user-card.tsx +86 -0
  28. package/src/scripts/users/user-container.tsx +49 -0
  29. package/src/scripts/users/user-datasets-view.tsx +53 -0
  30. package/src/scripts/users/user-notifications-view.tsx +11 -0
  31. package/src/scripts/users/user-query.tsx +76 -0
  32. package/src/scripts/users/user-routes.tsx +52 -0
  33. package/src/scripts/users/user-tabs.tsx +74 -0
  34. package/src/scripts/utils/__tests__/markdown.spec.tsx +1 -2
  35. package/src/scripts/utils/validationUtils.ts +8 -0
  36. package/src/scripts/validation/validation.tsx +11 -8
@@ -0,0 +1,65 @@
1
+ import React from "react"
2
+ import { render, screen, waitFor } from "@testing-library/react"
3
+ import { MockedProvider } from "@apollo/client/testing"
4
+ import { MemoryRouter, Route, Routes } from "react-router-dom"
5
+ import { UserQuery } from "../user-query"
6
+ import { GET_USER_BY_ORCID } from "../user-query"
7
+
8
+ const validOrcid = "0009-0001-9689-7232"
9
+
10
+ const userMock = {
11
+ request: {
12
+ query: GET_USER_BY_ORCID,
13
+ variables: { userId: validOrcid },
14
+ },
15
+ result: {
16
+ data: {
17
+ user: {
18
+ __typename: "User",
19
+ id: "1",
20
+ name: "Test User",
21
+ orcid: validOrcid,
22
+ email: "test@example.com",
23
+ avatar: "http://example.com/avatar.png",
24
+ },
25
+ },
26
+ },
27
+ }
28
+
29
+ const mocks = [userMock]
30
+
31
+ describe("UserQuery component", () => {
32
+ it("displays the ORCID on the page for a valid ORCID", async () => {
33
+ render(
34
+ <MockedProvider mocks={mocks} addTypename={true}>
35
+ <MemoryRouter initialEntries={[`/user/${validOrcid}`]}>
36
+ <Routes>
37
+ <Route path="/user/:orcid" element={<UserQuery />} />
38
+ </Routes>
39
+ </MemoryRouter>
40
+ </MockedProvider>,
41
+ )
42
+
43
+ await waitFor(() =>
44
+ expect(screen.queryByText("Loading...")).not.toBeInTheDocument()
45
+ )
46
+
47
+ expect(screen.getByText(validOrcid)).toBeInTheDocument()
48
+ })
49
+
50
+ it("shows 404 page for invalid ORCID", async () => {
51
+ render(
52
+ <MockedProvider mocks={[]} addTypename={true}>
53
+ <MemoryRouter initialEntries={[`/user/invalid-orcid`]}>
54
+ <Routes>
55
+ <Route path="/user/:orcid" element={<UserQuery />} />
56
+ </Routes>
57
+ </MemoryRouter>
58
+ </MockedProvider>,
59
+ )
60
+
61
+ await waitFor(() => screen.getByText(/404/i))
62
+
63
+ expect(screen.getByText(/404/i)).toBeInTheDocument()
64
+ })
65
+ })
@@ -0,0 +1,102 @@
1
+ import React from "react"
2
+ import { cleanup, render, screen } from "@testing-library/react"
3
+ import { MemoryRouter } from "react-router-dom"
4
+ import { MockedProvider } from "@apollo/client/testing"
5
+ import { UserRoutes } from "../user-routes"
6
+ import type { User } from "../user-routes"
7
+ import { UPDATE_USER } from "../user-query"
8
+
9
+ const defaultUser: User = {
10
+ id: "1",
11
+ name: "John Doe",
12
+ location: "Unknown",
13
+ github: "",
14
+ institution: "Unknown Institution",
15
+ email: "john.doe@example.com",
16
+ avatar: "https://dummyimage.com/200x200/000/fff",
17
+ orcid: "0000-0000-0000-0000",
18
+ links: [],
19
+ }
20
+
21
+ const mocks = [
22
+ {
23
+ request: {
24
+ query: UPDATE_USER,
25
+ variables: {
26
+ id: "1",
27
+ name: "John Doe",
28
+ location: "Unknown",
29
+ github: "",
30
+ institution: "Unknown Institution",
31
+ email: "john.doe@example.com",
32
+ avatar: "https://dummyimage.com/200x200/000/fff",
33
+ orcid: "0000-0000-0000-0000",
34
+ links: [],
35
+ },
36
+ },
37
+ result: {
38
+ data: {
39
+ updateUser: {
40
+ id: "1",
41
+ name: "John Doe",
42
+ },
43
+ },
44
+ },
45
+ },
46
+ ]
47
+
48
+ const renderWithRouter = (user: User, route: string, hasEdit: boolean) => {
49
+ return render(
50
+ <MockedProvider mocks={mocks} addTypename={false}>
51
+ <MemoryRouter initialEntries={[route]}>
52
+ <UserRoutes user={user} hasEdit={hasEdit} />
53
+ </MemoryRouter>
54
+ </MockedProvider>,
55
+ )
56
+ }
57
+
58
+ describe("UserRoutes Component", () => {
59
+ const user: User = defaultUser
60
+
61
+ it("renders UserDatasetsView for the default route", async () => {
62
+ renderWithRouter(user, "/", true)
63
+ expect(screen.getByText(`${user.name}'s Datasets`)).toBeInTheDocument()
64
+ const datasetsView = await screen.findByTestId("user-datasets-view")
65
+ expect(datasetsView).toBeInTheDocument()
66
+ })
67
+
68
+ it("renders FourOFourPage for an invalid route", async () => {
69
+ renderWithRouter(user, "/nonexistent-route", true)
70
+ const errorMessage = await screen.findByText(
71
+ /404: The page you are looking for does not exist./i,
72
+ )
73
+ expect(errorMessage).toBeInTheDocument()
74
+ })
75
+
76
+ it("renders UserAccountView when hasEdit is true", async () => {
77
+ renderWithRouter(user, "/account", true)
78
+ const accountView = await screen.findByTestId("user-account-view")
79
+ expect(accountView).toBeInTheDocument()
80
+ })
81
+
82
+ it("renders UserNotificationsView when hasEdit is true", async () => {
83
+ renderWithRouter(user, "/notifications", true)
84
+ const notificationsView = await screen.findByTestId(
85
+ "user-notifications-view",
86
+ )
87
+ expect(notificationsView).toBeInTheDocument()
88
+ })
89
+
90
+ it("renders FourOThreePage when hasEdit is false for restricted routes", async () => {
91
+ const restrictedRoutes = ["/account", "/notifications"]
92
+
93
+ for (const route of restrictedRoutes) {
94
+ cleanup()
95
+ renderWithRouter(user, route, false)
96
+ const errorMessage = await screen.findByText(
97
+ /403: You do not have access to this page, you may need to sign in./i,
98
+ )
99
+ expect(errorMessage).toBeInTheDocument()
100
+ }
101
+ })
102
+ })
@@ -0,0 +1,84 @@
1
+ import React, { useState } from "react"
2
+ import { fireEvent, render, screen } from "@testing-library/react"
3
+ import { MemoryRouter, Route, Routes } from "react-router-dom"
4
+ import { UserAccountTabs } from "../user-tabs"
5
+
6
+ // Wrapper component to allow dynamic modification of `hasEdit`
7
+ const UserAccountTabsWrapper: React.FC = () => {
8
+ const [hasEdit, setHasEdit] = useState(true)
9
+
10
+ return (
11
+ <>
12
+ <button onClick={() => setHasEdit(!hasEdit)}>Toggle hasEdit</button>
13
+ <MemoryRouter>
14
+ <Routes>
15
+ <Route path="*" element={<UserAccountTabs hasEdit={hasEdit} />} />
16
+ </Routes>
17
+ </MemoryRouter>
18
+ </>
19
+ )
20
+ }
21
+
22
+ describe("UserAccountTabs Component", () => {
23
+ it("should not render tabs when hasEdit is false", () => {
24
+ render(<UserAccountTabsWrapper />)
25
+
26
+ expect(screen.getByText("User Datasets")).toBeInTheDocument()
27
+
28
+ fireEvent.click(screen.getByText("Toggle hasEdit"))
29
+
30
+ expect(screen.queryByText("User Datasets")).not.toBeInTheDocument()
31
+ })
32
+
33
+ it("should render tabs when hasEdit is toggled back to true", () => {
34
+ render(<UserAccountTabsWrapper />)
35
+
36
+ expect(screen.getByText("User Datasets")).toBeInTheDocument()
37
+
38
+ fireEvent.click(screen.getByText("Toggle hasEdit"))
39
+ expect(screen.queryByText("User Datasets")).not.toBeInTheDocument()
40
+
41
+ fireEvent.click(screen.getByText("Toggle hasEdit"))
42
+ expect(screen.getByText("User Datasets")).toBeInTheDocument()
43
+ })
44
+
45
+ it("should update active class on the correct NavLink based on route", () => {
46
+ render(<UserAccountTabsWrapper />)
47
+
48
+ // Utility function to check if an element has 'active' class - used because of CSS module discrepancies between classNames
49
+ const hasActiveClass = (element) => element.className.includes("active")
50
+
51
+ const datasetsTab = screen.getByText("User Datasets")
52
+ expect(hasActiveClass(datasetsTab)).toBe(true)
53
+
54
+ const notificationsTab = screen.getByText("User Notifications")
55
+
56
+ fireEvent.click(notificationsTab)
57
+
58
+ expect(hasActiveClass(notificationsTab)).toBe(true)
59
+ expect(hasActiveClass(datasetsTab)).toBe(false)
60
+
61
+ const accountTab = screen.getByText("Account Info")
62
+
63
+ fireEvent.click(accountTab)
64
+
65
+ expect(hasActiveClass(accountTab)).toBe(true)
66
+ expect(hasActiveClass(datasetsTab)).toBe(false)
67
+ expect(hasActiveClass(notificationsTab)).toBe(false)
68
+ })
69
+
70
+ it("should trigger animation state when a tab is clicked", async () => {
71
+ render(<UserAccountTabsWrapper />)
72
+
73
+ const notificationsTab = screen.getByText("User Notifications")
74
+ // Utility function to check if an element has 'clicked' class - used because of CSS module discrepancies between classNames
75
+ const hasClickedClass = (element) => element.className.includes("clicked")
76
+ const tabsContainer = await screen.findByRole("list")
77
+
78
+ expect(hasClickedClass(tabsContainer)).toBe(false)
79
+
80
+ fireEvent.click(notificationsTab)
81
+
82
+ expect(hasClickedClass(tabsContainer)).toBe(true)
83
+ })
84
+ })
@@ -0,0 +1,20 @@
1
+ import React from "react"
2
+ import type { FC } from "react"
3
+ import { Button } from "@openneuro/components/button"
4
+
5
+ /**
6
+ * An edit button, calls action when clicked
7
+ */
8
+ interface CloseButtonProps {
9
+ action: () => void
10
+ }
11
+ export const CloseButton: FC<CloseButtonProps> = ({ action }) => {
12
+ return (
13
+ <Button
14
+ className="description-btn description-button-cancel"
15
+ label="Close"
16
+ icon="fas fa-times"
17
+ onClick={() => action()}
18
+ />
19
+ )
20
+ }
@@ -0,0 +1,20 @@
1
+ import React from "react"
2
+ import type { FC } from "react"
3
+ import { Button } from "@openneuro/components/button"
4
+
5
+ /**
6
+ * An edit button, calls action when clicked
7
+ */
8
+ interface EditButtonProps {
9
+ action: () => void
10
+ }
11
+ export const EditButton: FC<EditButtonProps> = ({ action }) => {
12
+ return (
13
+ <Button
14
+ className="description-btn description-button-edit"
15
+ label="Edit"
16
+ icon="fas fa-edit"
17
+ onClick={() => action()}
18
+ />
19
+ )
20
+ }
@@ -0,0 +1,103 @@
1
+ import React, { useState } from "react"
2
+ import { Button } from "@openneuro/components/button"
3
+ import "../scss/user-meta-blocks.scss"
4
+
5
+ interface EditListProps {
6
+ placeholder?: string
7
+ elements?: string[]
8
+ setElements: (elements: string[]) => void
9
+ validation?: RegExp
10
+ validationMessage?: string
11
+ }
12
+
13
+ /**
14
+ * EditList Component
15
+ * Allows adding and removing strings from a list.
16
+ */
17
+ export const EditList: React.FC<EditListProps> = (
18
+ {
19
+ placeholder = "Enter item",
20
+ elements = [],
21
+ setElements,
22
+ validation,
23
+ validationMessage,
24
+ },
25
+ ) => {
26
+ const [newElement, setNewElement] = useState<string>("")
27
+ const [warnEmpty, setWarnEmpty] = useState<boolean>(false)
28
+ const [warnValidation, setWarnValidation] = useState<string | null>(null)
29
+
30
+ const removeElement = (index: number): void => {
31
+ setElements(elements.filter((_, i) => i !== index))
32
+ }
33
+
34
+ // Add a new element to the list
35
+ const addElement = (): void => {
36
+ if (!newElement.trim()) {
37
+ setWarnEmpty(true)
38
+ setWarnValidation(null)
39
+ } else if (validation && !validation.test(newElement.trim())) {
40
+ setWarnValidation(validationMessage || "Invalid input format")
41
+ setWarnEmpty(false)
42
+ } else {
43
+ setElements([...elements, newElement.trim()])
44
+ setWarnEmpty(false)
45
+ setWarnValidation(null)
46
+ setNewElement("")
47
+ }
48
+ }
49
+
50
+ // Handle Enter/Return key press to add element
51
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
52
+ if (e.key === "Enter") {
53
+ addElement()
54
+ }
55
+ }
56
+
57
+ return (
58
+ <div className="edit-list-container">
59
+ <div className="el-group">
60
+ <input
61
+ type="text"
62
+ className="form-control"
63
+ placeholder={placeholder}
64
+ value={newElement}
65
+ onChange={(e) => setNewElement(e.target.value)}
66
+ onKeyDown={handleKeyDown}
67
+ />
68
+ <Button
69
+ className="edit-list-add"
70
+ primary={true}
71
+ size="small"
72
+ label="Add"
73
+ onClick={addElement}
74
+ />
75
+ </div>
76
+ {warnEmpty && (
77
+ <small className="warning-text">
78
+ Your input was empty
79
+ </small>
80
+ )}
81
+ {warnValidation && (
82
+ <small className="warning-text">{warnValidation}</small>
83
+ )}
84
+ <div className="edit-list-items">
85
+ {elements.map((element, index) => (
86
+ <div key={index} className="edit-list-group-item">
87
+ {element}
88
+ <Button
89
+ className="edit-list-remove"
90
+ nobg={true}
91
+ size="xsmall"
92
+ icon="fa fa-times"
93
+ label="Remove"
94
+ color="red"
95
+ onClick={() =>
96
+ removeElement(index)}
97
+ />
98
+ </div>
99
+ ))}
100
+ </div>
101
+ </div>
102
+ )
103
+ }
@@ -0,0 +1,90 @@
1
+ import React, { useEffect, useState } from "react"
2
+ import { Button } from "@openneuro/components/button"
3
+ import "../scss/user-meta-blocks.scss"
4
+
5
+ interface EditStringProps {
6
+ value?: string
7
+ setValue: (value: string) => void
8
+ placeholder?: string
9
+ closeEditing: () => void
10
+ validation?: RegExp
11
+ validationMessage?: string
12
+ }
13
+
14
+ export const EditString: React.FC<EditStringProps> = (
15
+ {
16
+ value = "",
17
+ setValue,
18
+ placeholder = "Enter text",
19
+ closeEditing,
20
+ validation,
21
+ validationMessage,
22
+ },
23
+ ) => {
24
+ const [currentValue, setCurrentValue] = useState<string>(value)
25
+ const [warnEmpty, setWarnEmpty] = useState<string | null>(null)
26
+ const [warnValidation, setWarnValidation] = useState<string | null>(null)
27
+
28
+ useEffect(() => {
29
+ // Show warning only if there was an initial value and it was deleted
30
+ if (value !== "" && currentValue === "") {
31
+ setWarnEmpty(
32
+ "Your input is empty. This will delete the previously saved value..",
33
+ )
34
+ } else {
35
+ setWarnEmpty(null)
36
+ }
37
+
38
+ // Validation logic
39
+ if (validation && currentValue && !validation.test(currentValue)) {
40
+ setWarnValidation(validationMessage || "Invalid input")
41
+ } else {
42
+ setWarnValidation(null)
43
+ }
44
+ }, [currentValue, value, validation, validationMessage])
45
+
46
+ const handleSave = (): void => {
47
+ if (!warnValidation) {
48
+ setValue(currentValue.trim())
49
+ closeEditing()
50
+ }
51
+ }
52
+
53
+ // Handle Enter key press for saving
54
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
55
+ if (event.key === "Enter") {
56
+ event.preventDefault()
57
+ handleSave()
58
+ }
59
+ }
60
+
61
+ return (
62
+ <div className="edit-string-container">
63
+ <div className="edit-string-group">
64
+ <input
65
+ type="text"
66
+ className="form-control"
67
+ placeholder={placeholder}
68
+ value={currentValue}
69
+ onChange={(e) => setCurrentValue(e.target.value)}
70
+ onKeyDown={handleKeyDown}
71
+ />
72
+ <Button
73
+ className="edit-string-save"
74
+ primary={true}
75
+ size="small"
76
+ label="Save"
77
+ onClick={handleSave}
78
+ />
79
+ </div>
80
+ {/* Show empty value warning only if content was deleted */}
81
+ {warnEmpty && currentValue === "" && (
82
+ <small className="warning-text">{warnEmpty}</small>
83
+ )}
84
+ {/* Show validation error */}
85
+ {warnValidation && (
86
+ <small className="warning-text">{warnValidation}</small>
87
+ )}
88
+ </div>
89
+ )
90
+ }
@@ -0,0 +1,98 @@
1
+ import React, { useState } from "react"
2
+ import { EditList } from "./edit-list"
3
+ import { EditString } from "./edit-string"
4
+ import { EditButton } from "./edit-button"
5
+ import { CloseButton } from "./close-button"
6
+ import { Markdown } from "../../utils/markdown"
7
+ import "../scss/editable-content.scss"
8
+
9
+ interface EditableContentProps {
10
+ editableContent: string[] | string
11
+ setRows: React.Dispatch<React.SetStateAction<string[] | string>>
12
+ className: string
13
+ heading: string
14
+ validation?: RegExp
15
+ validationMessage?: string
16
+ "data-testid"?: string
17
+ }
18
+
19
+ export const EditableContent: React.FC<EditableContentProps> = ({
20
+ editableContent,
21
+ setRows,
22
+ className,
23
+ heading,
24
+ validation,
25
+ validationMessage,
26
+ "data-testid": testId,
27
+ }) => {
28
+ const [editing, setEditing] = useState(false)
29
+
30
+ const closeEditing = () => {
31
+ setEditing(false)
32
+ }
33
+
34
+ // Function to handle validation of user input
35
+ const handleValidation = (value: string): boolean => {
36
+ if (validation && !validation.test(value)) {
37
+ return false
38
+ }
39
+ return true
40
+ }
41
+
42
+ return (
43
+ <div className={`user-meta-block ${className}`} data-testid={testId}>
44
+ <span className="umb-heading">
45
+ <h4>{heading}</h4>
46
+ {editing
47
+ ? <CloseButton action={closeEditing} />
48
+ : <EditButton action={() => setEditing(true)} />}
49
+ </span>
50
+ {editing
51
+ ? (
52
+ <>
53
+ {Array.isArray(editableContent)
54
+ ? (
55
+ <EditList
56
+ placeholder="Add new item"
57
+ elements={editableContent}
58
+ setElements={setRows as React.Dispatch<
59
+ React.SetStateAction<string[]>
60
+ >}
61
+ validation={validation}
62
+ validationMessage={validationMessage}
63
+ />
64
+ )
65
+ : (
66
+ <EditString
67
+ value={editableContent}
68
+ setValue={(newValue: string) => {
69
+ if (handleValidation(newValue)) {
70
+ setRows(newValue)
71
+ }
72
+ }}
73
+ placeholder="Edit content"
74
+ closeEditing={closeEditing}
75
+ validation={validation}
76
+ validationMessage={validationMessage}
77
+ />
78
+ )}
79
+ </>
80
+ )
81
+ : (
82
+ <>
83
+ {Array.isArray(editableContent)
84
+ ? (
85
+ <ul>
86
+ {editableContent.map((item, index) => (
87
+ <li key={index}>
88
+ <Markdown>{item}</Markdown>
89
+ </li>
90
+ ))}
91
+ </ul>
92
+ )
93
+ : <Markdown>{editableContent}</Markdown>}
94
+ </>
95
+ )}
96
+ </div>
97
+ )
98
+ }
@@ -0,0 +1,15 @@
1
+
2
+
3
+ .user-meta-block{
4
+ margin-top: 30px;
5
+ }
6
+ .umb-heading{
7
+ display: flex;
8
+ justify-content: space-between;
9
+ align-items: center;
10
+ border-bottom: 1px solid #e0e0e0;
11
+ h4{
12
+ margin: 0;
13
+ }
14
+ }
15
+
@@ -0,0 +1,14 @@
1
+ .user-meta-block {
2
+ .edit-string-container,
3
+ .edit-list-container{
4
+ margin-top: 20px;
5
+ }
6
+ ul{
7
+ list-style: none;
8
+ margin: 0;
9
+ padding: 0;
10
+ li {
11
+ p{ margin-bottom: 0;}
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,20 @@
1
+ .useraccountview{
2
+ h3{
3
+ margin-top: 0;
4
+ font-size: 22px;
5
+ }
6
+ .accountDetail{
7
+ list-style: none;
8
+ margin: 0;
9
+ padding: 0;
10
+ li{
11
+ margin: 0 0 10px;
12
+ font-size: 16px;
13
+ }
14
+ span{
15
+ font-weight: 700;
16
+ margin-right: 10px;
17
+ }
18
+ }
19
+ }
20
+
@@ -0,0 +1,24 @@
1
+ /* usercard.module.scss */
2
+ .userCard {
3
+ padding: 20px;
4
+ ul{
5
+ list-style: none;
6
+ margin: 0;
7
+ padding: 0;
8
+ li{
9
+ margin-bottom: 5px;
10
+ font-size: 14px;
11
+ i{
12
+ margin-right: 10px;
13
+ color: #666;
14
+
15
+ }
16
+ &.orcid i{
17
+ color: #a6cf39;
18
+ }
19
+ &:last-of-type{
20
+ margin-bottom: 0;
21
+ }
22
+ }
23
+ }
24
+ }