From 60c2d35eb9b81ce2e314a8879d84a718a245bea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Thu, 24 Mar 2022 10:32:29 +0100 Subject: [PATCH 1/8] feat(dashboard-v2): add Alert component --- packages/dashboard-v2/src/components/Alert/Alert.js | 9 +++++++++ packages/dashboard-v2/src/components/Alert/index.js | 1 + 2 files changed, 10 insertions(+) create mode 100644 packages/dashboard-v2/src/components/Alert/Alert.js create mode 100644 packages/dashboard-v2/src/components/Alert/index.js 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"; From 340fe5f2038fe9a50bf668d7edc3506e8d43c2ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Thu, 24 Mar 2022 10:33:09 +0100 Subject: [PATCH 2/8] fix(dashboard-v2): fix TextField customization via className --- packages/dashboard-v2/src/components/Form/TextField.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 }) => { Date: Thu, 24 Mar 2022 10:39:18 +0100 Subject: [PATCH 3/8] feat(dashboard-v2): add Modal component --- packages/dashboard-v2/gatsby-browser.js | 8 +++- packages/dashboard-v2/gatsby-ssr.js | 8 +++- .../src/components/Modal/Modal.js | 37 ++++++++++++++++ .../src/components/Modal/ModalPortal.js | 16 +++++++ .../src/components/Modal/Overlay.js | 42 +++++++++++++++++++ .../src/components/Modal/index.js | 1 + 6 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 packages/dashboard-v2/src/components/Modal/Modal.js create mode 100644 packages/dashboard-v2/src/components/Modal/ModalPortal.js create mode 100644 packages/dashboard-v2/src/components/Modal/Overlay.js create mode 100644 packages/dashboard-v2/src/components/Modal/index.js 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/Modal/Modal.js b/packages/dashboard-v2/src/components/Modal/Modal.js new file mode 100644 index 00000000..6fd8e00a --- /dev/null +++ b/packages/dashboard-v2/src/components/Modal/Modal.js @@ -0,0 +1,37 @@ +import cn from "classnames"; +import PropTypes from "prop-types"; + +import { PlusIcon } from "../Icons"; +import { Panel } from "../Panel"; + +import { ModalPortal } from "./ModalPortal"; +import { Overlay } from "./Overlay"; + +export const Modal = ({ children, className, onClose }) => ( + + +
+ + {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"; From 93809d5428917a0c943e79ed9308acd45ad5eb57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Thu, 24 Mar 2022 10:44:48 +0100 Subject: [PATCH 4/8] feat(dashboard-v2): implement data mutations for user accounts --- .../components/forms/AccountRemovalForm.js | 78 +++++++++++++ .../components/forms/AccountSettingsForm.js | 106 ++++++++++++++++++ .../dashboard-v2/src/pages/settings/index.js | 67 ++++++----- 3 files changed, 221 insertions(+), 30 deletions(-) create mode 100644 packages/dashboard-v2/src/components/forms/AccountRemovalForm.js create mode 100644 packages/dashboard-v2/src/components/forms/AccountSettingsForm.js 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..f979dd31 --- /dev/null +++ b/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js @@ -0,0 +1,106 @@ +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(6, "Password has to be at least 6 characters long"), + 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 { + await accountsService.put("user", { + json: { email, password }, + }); + + resetForm(); + onSuccess(); + } 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..41ccf6b4 100644 --- a/packages/dashboard-v2/src/pages/settings/index.js +++ b/packages/dashboard-v2/src/pages/settings/index.js @@ -1,19 +1,31 @@ -import * as React from "react"; +import { useState } from "react"; import { useMedia } from "react-use"; -import styled from "styled-components"; +import { navigate } from "gatsby"; +import { useUser } from "../../contexts/user"; import theme from "../../lib/theme"; 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); + return ( <>
@@ -32,28 +44,18 @@ const AccountPage = () => { )}
- - -
- -
-
- - -
- -
-
- - -
- -
- - 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.} + { + reloadUser(); + setState(State.Success); + }} + onFailure={() => setState(State.Failure)} + />

@@ -61,7 +63,7 @@ const AccountPage = () => {

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

); From 07112383d3c8192691a756e733d4bce5c49879fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Thu, 24 Mar 2022 10:46:02 +0100 Subject: [PATCH 5/8] chore(dashboard-v2): cleanup AvatarUploader (user avatars are not available yet) --- packages/dashboard-v2/src/pages/settings/index.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/dashboard-v2/src/pages/settings/index.js b/packages/dashboard-v2/src/pages/settings/index.js index 41ccf6b4..32ec95ac 100644 --- a/packages/dashboard-v2/src/pages/settings/index.js +++ b/packages/dashboard-v2/src/pages/settings/index.js @@ -1,11 +1,8 @@ import { useState } from "react"; -import { useMedia } from "react-use"; import { navigate } from "gatsby"; import { useUser } from "../../contexts/user"; -import theme from "../../lib/theme"; import UserSettingsLayout from "../../layouts/UserSettingsLayout"; -import { AvatarUploader } from "../../components/AvatarUploader"; import { AccountSettingsForm } from "../../components/forms/AccountSettingsForm"; import { Modal } from "../../components/Modal/Modal"; import { AccountRemovalForm } from "../../components/forms/AccountRemovalForm"; @@ -18,7 +15,6 @@ const State = { }; 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); @@ -38,11 +34,6 @@ const AccountPage = () => {


- {!isLargeScreen && ( -
- -
- )}
{state === State.Failure && ( There was an error processing your request. Please try again later. @@ -70,9 +61,6 @@ const AccountPage = () => {
-
- {isLargeScreen && } -
{removalInitiated && ( navigate("/auth/login")} /> From 52314130742c05ec88cb9158f7636b08e35f1e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Fri, 25 Mar 2022 12:54:59 +0100 Subject: [PATCH 6/8] fix(dashboard-v2): bulletproof emptying one's email address --- .../src/components/forms/AccountSettingsForm.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js b/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js index f979dd31..ad29b85c 100644 --- a/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js +++ b/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js @@ -16,7 +16,7 @@ const emailUpdateSchema = Yup.object().shape({ is: isPopulated, then: (schema) => schema.required("Please confirm new email address"), }), - password: Yup.string().min(6, "Password has to be at least 6 characters long"), + password: Yup.string().min(1, "Password can't be blank"), confirmPassword: Yup.string() .oneOf([Yup.ref("password"), null], "Passwords must match") .when("password", { @@ -38,7 +38,10 @@ export const AccountSettingsForm = ({ user, onSuccess, onFailure }) => { onSubmit={async ({ email, password }, { resetForm }) => { try { await accountsService.put("user", { - json: { email, password }, + json: { + email: email || undefined, + password: password || undefined, + }, }); resetForm(); From 2aa3437ab67c70dad5aebf0511a01c242c730f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Fri, 25 Mar 2022 12:57:57 +0100 Subject: [PATCH 7/8] chore(dashboard-v2): await reloadUser --- packages/dashboard-v2/src/pages/settings/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dashboard-v2/src/pages/settings/index.js b/packages/dashboard-v2/src/pages/settings/index.js index 32ec95ac..ad7a34d8 100644 --- a/packages/dashboard-v2/src/pages/settings/index.js +++ b/packages/dashboard-v2/src/pages/settings/index.js @@ -41,8 +41,8 @@ const AccountPage = () => { {state === State.Success && Changes saved successfully.} { - reloadUser(); + onSuccess={async () => { + await reloadUser(); setState(State.Success); }} onFailure={() => setState(State.Failure)} From 5bb1ed18c439e923e910c9702830a0ebd957025d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Fri, 25 Mar 2022 13:39:57 +0100 Subject: [PATCH 8/8] chore(dashboard-v2): handle reloadUser error --- .../components/forms/AccountSettingsForm.js | 16 +++++++----- .../dashboard-v2/src/pages/settings/index.js | 26 ++++++++++++------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js b/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js index ad29b85c..2c382c1b 100644 --- a/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js +++ b/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js @@ -37,15 +37,17 @@ export const AccountSettingsForm = ({ user, onSuccess, onFailure }) => { validationSchema={emailUpdateSchema} onSubmit={async ({ email, password }, { resetForm }) => { try { - await accountsService.put("user", { - json: { - email: email || undefined, - password: password || undefined, - }, - }); + const user = await accountsService + .put("user", { + json: { + email: email || undefined, + password: password || undefined, + }, + }) + .json(); resetForm(); - onSuccess(); + await onSuccess(user); } catch { onFailure(); } diff --git a/packages/dashboard-v2/src/pages/settings/index.js b/packages/dashboard-v2/src/pages/settings/index.js index ad7a34d8..358a4b28 100644 --- a/packages/dashboard-v2/src/pages/settings/index.js +++ b/packages/dashboard-v2/src/pages/settings/index.js @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { navigate } from "gatsby"; import { useUser } from "../../contexts/user"; @@ -22,6 +22,21 @@ const AccountPage = () => { 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 ( <>
@@ -39,14 +54,7 @@ const AccountPage = () => { There was an error processing your request. Please try again later. )} {state === State.Success && Changes saved successfully.} - { - await reloadUser(); - setState(State.Success); - }} - onFailure={() => setState(State.Failure)} - /> + setState(State.Failure)} />