diff --git a/packages/dashboard-v2/gatsby-browser.js b/packages/dashboard-v2/gatsby-browser.js index a71e49c3..a39bdb48 100644 --- a/packages/dashboard-v2/gatsby-browser.js +++ b/packages/dashboard-v2/gatsby-browser.js @@ -6,8 +6,14 @@ import "@fontsource/sora/600.css"; // semibold import "@fontsource/source-sans-pro/400.css"; // normal import "@fontsource/source-sans-pro/600.css"; // semibold import "./src/styles/global.css"; +import { MODAL_ROOT_ID } from "./src/components/Modal"; export function wrapPageElement({ element, props }) { const Layout = element.type.Layout ?? React.Fragment; - return {element}; + return ( + + {element} +
+ + ); } diff --git a/packages/dashboard-v2/gatsby-ssr.js b/packages/dashboard-v2/gatsby-ssr.js index a71e49c3..a39bdb48 100644 --- a/packages/dashboard-v2/gatsby-ssr.js +++ b/packages/dashboard-v2/gatsby-ssr.js @@ -6,8 +6,14 @@ import "@fontsource/sora/600.css"; // semibold import "@fontsource/source-sans-pro/400.css"; // normal import "@fontsource/source-sans-pro/600.css"; // semibold import "./src/styles/global.css"; +import { MODAL_ROOT_ID } from "./src/components/Modal"; export function wrapPageElement({ element, props }) { const Layout = element.type.Layout ?? React.Fragment; - return {element}; + return ( + + {element} +
+ + ); } diff --git a/packages/dashboard-v2/src/components/Alert/Alert.js b/packages/dashboard-v2/src/components/Alert/Alert.js new file mode 100644 index 00000000..0e6ab345 --- /dev/null +++ b/packages/dashboard-v2/src/components/Alert/Alert.js @@ -0,0 +1,9 @@ +import styled from "styled-components"; +import cn from "classnames"; + +export const Alert = styled.div.attrs(({ $variant }) => ({ + className: cn("px-3 py-2 sm:px-6 sm:py-4 rounded border", { + "bg-red-100 border-red-200 text-error": $variant === "error", + "bg-green-100 border-green-200 text-palette-400": $variant === "success", + }), +}))``; diff --git a/packages/dashboard-v2/src/components/Alert/index.js b/packages/dashboard-v2/src/components/Alert/index.js new file mode 100644 index 00000000..b8e17a03 --- /dev/null +++ b/packages/dashboard-v2/src/components/Alert/index.js @@ -0,0 +1 @@ +export * from "./Alert"; diff --git a/packages/dashboard-v2/src/components/Form/TextField.js b/packages/dashboard-v2/src/components/Form/TextField.js index 7eb811a8..6ae35021 100644 --- a/packages/dashboard-v2/src/components/Form/TextField.js +++ b/packages/dashboard-v2/src/components/Form/TextField.js @@ -2,7 +2,7 @@ import PropTypes from "prop-types"; import cn from "classnames"; import { Field } from "formik"; -export const TextField = ({ id, label, name, error, touched, ...props }) => { +export const TextField = ({ id, label, name, error, touched, className, ...props }) => { return (
{label && ( @@ -13,7 +13,7 @@ export const TextField = ({ id, label, name, error, touched, ...props }) => { ( + + +
+ + {children} +
+
+
+); + +Modal.propTypes = { + /** + * Modal's body. + */ + children: PropTypes.node.isRequired, + /** + * Handler function to be called when user clicks on the "X" icon, + * or outside of the modal. + */ + onClose: PropTypes.func.isRequired, + /** + * Additional CSS classes to be applied to modal's body. + */ + className: PropTypes.string, +}; diff --git a/packages/dashboard-v2/src/components/Modal/ModalPortal.js b/packages/dashboard-v2/src/components/Modal/ModalPortal.js new file mode 100644 index 00000000..ef18e612 --- /dev/null +++ b/packages/dashboard-v2/src/components/Modal/ModalPortal.js @@ -0,0 +1,16 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +export const MODAL_ROOT_ID = "__modal-root"; + +export const ModalPortal = ({ children }) => { + const ref = useRef(); + const [isClientSide, setIsClientSide] = useState(false); + + useEffect(() => { + ref.current = document.querySelector(MODAL_ROOT_ID) || document.body; + setIsClientSide(true); + }, []); + + return isClientSide ? createPortal(children, ref.current) : null; +}; diff --git a/packages/dashboard-v2/src/components/Modal/Overlay.js b/packages/dashboard-v2/src/components/Modal/Overlay.js new file mode 100644 index 00000000..7f1cbb35 --- /dev/null +++ b/packages/dashboard-v2/src/components/Modal/Overlay.js @@ -0,0 +1,42 @@ +import { useRef } from "react"; +import { useLockBodyScroll } from "react-use"; +import PropTypes from "prop-types"; + +export const Overlay = ({ children, onClick }) => { + const overlayRef = useRef(null); + + useLockBodyScroll(true); + + const handleClick = (event) => { + if (event.target !== overlayRef.current) { + return; + } + + event.nativeEvent.stopImmediatePropagation(); + + onClick?.(event); + }; + + return ( +
+ {children} +
+ ); +}; + +Overlay.propTypes = { + /** + * Overlay's body. + */ + children: PropTypes.node.isRequired, + /** + * Handler function to be called when user clicks on the overlay + * (but not the overlay's content). + */ + onClick: PropTypes.func, +}; diff --git a/packages/dashboard-v2/src/components/Modal/index.js b/packages/dashboard-v2/src/components/Modal/index.js new file mode 100644 index 00000000..28d34710 --- /dev/null +++ b/packages/dashboard-v2/src/components/Modal/index.js @@ -0,0 +1 @@ +export * from "./ModalPortal"; diff --git a/packages/dashboard-v2/src/components/forms/AccountRemovalForm.js b/packages/dashboard-v2/src/components/forms/AccountRemovalForm.js new file mode 100644 index 00000000..bdd7196e --- /dev/null +++ b/packages/dashboard-v2/src/components/forms/AccountRemovalForm.js @@ -0,0 +1,78 @@ +import * as Yup from "yup"; +import { useState } from "react"; +import PropTypes from "prop-types"; +import { Formik, Form } from "formik"; + +import { Button } from "../Button"; +import { TextField } from "../Form/TextField"; +import accountsService from "../../services/accountsService"; + +const accountRemovalSchema = Yup.object().shape({ + confirm: Yup.string().oneOf(["delete"], `Type "delete" to confirm`), +}); + +export const AccountRemovalForm = ({ abort, onSuccess }) => { + const [error, setError] = useState(false); + + return ( + { + try { + setError(false); + await accountsService.delete("user"); + onSuccess(); + } catch { + setError(true); + } + }} + > + {({ errors, touched, isValid, dirty }) => ( +
+
+

Delete account

+

+ This will completely delete your account. This process can't be undone. +

+
+ +
+ +

Type "delete" in the field below to remove your account.

+ + + +
+ + +
+ {error && ( +
+ There was an error processing your request. Please try again later. +
+ )} + + )} +
+ ); +}; + +AccountRemovalForm.propTypes = { + abort: PropTypes.func.isRequired, + onSuccess: PropTypes.func.isRequired, +}; diff --git a/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js b/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js new file mode 100644 index 00000000..2c382c1b --- /dev/null +++ b/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js @@ -0,0 +1,111 @@ +import * as Yup from "yup"; +import PropTypes from "prop-types"; +import { Formik, Form } from "formik"; + +import { Button } from "../Button"; +import { TextField } from "../Form/TextField"; +import accountsService from "../../services/accountsService"; + +const isPopulated = (value) => value?.length > 0; + +const emailUpdateSchema = Yup.object().shape({ + email: Yup.string().email("Please provide a valid email address"), + confirmEmail: Yup.string() + .oneOf([Yup.ref("email"), null], "Emails must match") + .when("email", { + is: isPopulated, + then: (schema) => schema.required("Please confirm new email address"), + }), + password: Yup.string().min(1, "Password can't be blank"), + confirmPassword: Yup.string() + .oneOf([Yup.ref("password"), null], "Passwords must match") + .when("password", { + is: isPopulated, + then: (schema) => schema.required("Please confirm new password"), + }), +}); + +export const AccountSettingsForm = ({ user, onSuccess, onFailure }) => { + return ( + { + try { + const user = await accountsService + .put("user", { + json: { + email: email || undefined, + password: password || undefined, + }, + }) + .json(); + + resetForm(); + await onSuccess(user); + } catch { + onFailure(); + } + }} + > + {({ errors, touched, isValid, dirty }) => ( +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ )} +
+ ); +}; + +AccountSettingsForm.propTypes = { + onSuccess: PropTypes.func.isRequired, + onFailure: PropTypes.func.isRequired, +}; diff --git a/packages/dashboard-v2/src/pages/settings/index.js b/packages/dashboard-v2/src/pages/settings/index.js index 459cf8c0..358a4b28 100644 --- a/packages/dashboard-v2/src/pages/settings/index.js +++ b/packages/dashboard-v2/src/pages/settings/index.js @@ -1,19 +1,42 @@ -import * as React from "react"; -import { useMedia } from "react-use"; -import styled from "styled-components"; +import { useCallback, useState } from "react"; +import { navigate } from "gatsby"; -import theme from "../../lib/theme"; +import { useUser } from "../../contexts/user"; import UserSettingsLayout from "../../layouts/UserSettingsLayout"; -import { TextInputBasic } from "../../components/TextInputBasic/TextInputBasic"; -import { Button } from "../../components/Button"; -import { AvatarUploader } from "../../components/AvatarUploader"; +import { AccountSettingsForm } from "../../components/forms/AccountSettingsForm"; +import { Modal } from "../../components/Modal/Modal"; +import { AccountRemovalForm } from "../../components/forms/AccountRemovalForm"; +import { Alert } from "../../components/Alert"; -const FormGroup = styled.div.attrs({ - className: "grid sm:grid-cols-[1fr_min-content] w-full gap-y-2 gap-x-4 items-end", -})``; +const State = { + Pure: "PURE", + Success: "SUCCESS", + Failure: "FAILURE", +}; const AccountPage = () => { - const isLargeScreen = useMedia(`(min-width: ${theme.screens.xl})`); + const { user, mutate: reloadUser } = useUser(); + const [state, setState] = useState(State.Pure); + const [removalInitiated, setRemovalInitiated] = useState(false); + + const prompt = () => setRemovalInitiated(true); + const abort = () => setRemovalInitiated(false); + + const onSettingsUpdated = useCallback( + async (updatedState) => { + try { + // Update state locally and request revalidation. + await reloadUser(updatedState); + } finally { + // If revalidation fails, we can't really do much. Request + // will be auto-retried by SWR, so we'll just show a message + // about the update request being successful. + setState(State.Success); + } + }, + [reloadUser] + ); + return ( <>
@@ -26,34 +49,12 @@ const AccountPage = () => {


- {!isLargeScreen && ( -
- -
- )}
- - -
- -
-
- - -
- -
-
- - -
- -
- - The password must be at least 6 characters long. Significantly different from the email and old - password. - -
+ {state === State.Failure && ( + There was an error processing your request. Please try again later. + )} + {state === State.Success && Changes saved successfully.} + setState(State.Failure)} />

@@ -61,16 +62,18 @@ const AccountPage = () => {

This will completely delete your account. This process can't be undone.

-
- {isLargeScreen && } -
+ {removalInitiated && ( + + navigate("/auth/login")} /> + + )}
);