Merge pull request #1915 from SkynetLabs/dashboard-v2-account-data-mutations
Dashboard V2 - changing account information
This commit is contained in:
commit
62816c6c84
|
@ -6,8 +6,14 @@ import "@fontsource/sora/600.css"; // semibold
|
||||||
import "@fontsource/source-sans-pro/400.css"; // normal
|
import "@fontsource/source-sans-pro/400.css"; // normal
|
||||||
import "@fontsource/source-sans-pro/600.css"; // semibold
|
import "@fontsource/source-sans-pro/600.css"; // semibold
|
||||||
import "./src/styles/global.css";
|
import "./src/styles/global.css";
|
||||||
|
import { MODAL_ROOT_ID } from "./src/components/Modal";
|
||||||
|
|
||||||
export function wrapPageElement({ element, props }) {
|
export function wrapPageElement({ element, props }) {
|
||||||
const Layout = element.type.Layout ?? React.Fragment;
|
const Layout = element.type.Layout ?? React.Fragment;
|
||||||
return <Layout {...props}>{element}</Layout>;
|
return (
|
||||||
|
<Layout {...props}>
|
||||||
|
{element}
|
||||||
|
<div id={MODAL_ROOT_ID} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,14 @@ import "@fontsource/sora/600.css"; // semibold
|
||||||
import "@fontsource/source-sans-pro/400.css"; // normal
|
import "@fontsource/source-sans-pro/400.css"; // normal
|
||||||
import "@fontsource/source-sans-pro/600.css"; // semibold
|
import "@fontsource/source-sans-pro/600.css"; // semibold
|
||||||
import "./src/styles/global.css";
|
import "./src/styles/global.css";
|
||||||
|
import { MODAL_ROOT_ID } from "./src/components/Modal";
|
||||||
|
|
||||||
export function wrapPageElement({ element, props }) {
|
export function wrapPageElement({ element, props }) {
|
||||||
const Layout = element.type.Layout ?? React.Fragment;
|
const Layout = element.type.Layout ?? React.Fragment;
|
||||||
return <Layout {...props}>{element}</Layout>;
|
return (
|
||||||
|
<Layout {...props}>
|
||||||
|
{element}
|
||||||
|
<div id={MODAL_ROOT_ID} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
}),
|
||||||
|
}))``;
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./Alert";
|
|
@ -2,7 +2,7 @@ import PropTypes from "prop-types";
|
||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import { Field } from "formik";
|
import { Field } from "formik";
|
||||||
|
|
||||||
export const TextField = ({ id, label, name, error, touched, ...props }) => {
|
export const TextField = ({ id, label, name, error, touched, className, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full gap-1">
|
<div className="flex flex-col w-full gap-1">
|
||||||
{label && (
|
{label && (
|
||||||
|
@ -13,7 +13,7 @@ export const TextField = ({ id, label, name, error, touched, ...props }) => {
|
||||||
<Field
|
<Field
|
||||||
id={id}
|
id={id}
|
||||||
name={name}
|
name={name}
|
||||||
className={cn("w-full py-2 px-4 bg-palette-100 rounded-sm placeholder:text-palette-200 outline-1", {
|
className={cn("w-full py-2 px-4 bg-palette-100 rounded-sm placeholder:text-palette-200 outline-1", className, {
|
||||||
"focus:outline outline-palette-200": !error || !touched,
|
"focus:outline outline-palette-200": !error || !touched,
|
||||||
"outline outline-error": touched && error,
|
"outline outline-error": touched && error,
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -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 }) => (
|
||||||
|
<ModalPortal>
|
||||||
|
<Overlay onClick={onClose}>
|
||||||
|
<div className="relative">
|
||||||
|
<button onClick={onClose} className="absolute top-[20px] right-[20px]">
|
||||||
|
<PlusIcon size={14} className="rotate-45" />
|
||||||
|
</button>
|
||||||
|
<Panel className={cn("px-8 py-6 sm:px-12 sm:py-10", className)}>{children}</Panel>
|
||||||
|
</div>
|
||||||
|
</Overlay>
|
||||||
|
</ModalPortal>
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
role="presentation"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="fixed inset-0 z-50 bg-palette-100/80 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./ModalPortal";
|
|
@ -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 (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
confirm: "",
|
||||||
|
}}
|
||||||
|
validationSchema={accountRemovalSchema}
|
||||||
|
onSubmit={async () => {
|
||||||
|
try {
|
||||||
|
setError(false);
|
||||||
|
await accountsService.delete("user");
|
||||||
|
onSuccess();
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ errors, touched, isValid, dirty }) => (
|
||||||
|
<Form className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h4>Delete account</h4>
|
||||||
|
<p>
|
||||||
|
This will completely delete your account. <strong>This process can't be undone.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-palette-200/50" />
|
||||||
|
|
||||||
|
<p>Type "delete" in the field below to remove your account.</p>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
name="confirm"
|
||||||
|
placeholder="delete"
|
||||||
|
error={errors.confirm}
|
||||||
|
touched={touched.confirm}
|
||||||
|
className="text-center"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-center mt-4">
|
||||||
|
<Button $primary onClick={abort}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!isValid || !dirty}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="px-3 py-2 sm:px-6 sm:py-4 rounded border bg-red-100 border-red-200 text-error">
|
||||||
|
There was an error processing your request. Please try again later.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AccountRemovalForm.propTypes = {
|
||||||
|
abort: PropTypes.func.isRequired,
|
||||||
|
onSuccess: PropTypes.func.isRequired,
|
||||||
|
};
|
|
@ -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 (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
email: "",
|
||||||
|
confirmEmail: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
}}
|
||||||
|
validationSchema={emailUpdateSchema}
|
||||||
|
onSubmit={async ({ email, password }, { resetForm }) => {
|
||||||
|
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 }) => (
|
||||||
|
<Form>
|
||||||
|
<div className="flex flex-col w-full gap-8">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
label="Email address"
|
||||||
|
placeholder={user?.email}
|
||||||
|
error={errors.email}
|
||||||
|
touched={touched.email}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
id="confirmEmail"
|
||||||
|
name="confirmEmail"
|
||||||
|
label="Confirm new email address"
|
||||||
|
error={errors.confirmEmail}
|
||||||
|
touched={touched.confirmEmail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
label="New password"
|
||||||
|
error={errors.password}
|
||||||
|
touched={touched.password}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
label="Confirm new password"
|
||||||
|
error={errors.confirmPassword}
|
||||||
|
touched={touched.confirmPassword}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex mt-2 sm:mt-0 pt-5 justify-center">
|
||||||
|
<Button type="submit" className="px-24" disabled={!isValid || !dirty}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AccountSettingsForm.propTypes = {
|
||||||
|
onSuccess: PropTypes.func.isRequired,
|
||||||
|
onFailure: PropTypes.func.isRequired,
|
||||||
|
};
|
|
@ -1,19 +1,42 @@
|
||||||
import * as React from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useMedia } from "react-use";
|
import { navigate } from "gatsby";
|
||||||
import styled from "styled-components";
|
|
||||||
|
|
||||||
import theme from "../../lib/theme";
|
import { useUser } from "../../contexts/user";
|
||||||
import UserSettingsLayout from "../../layouts/UserSettingsLayout";
|
import UserSettingsLayout from "../../layouts/UserSettingsLayout";
|
||||||
import { TextInputBasic } from "../../components/TextInputBasic/TextInputBasic";
|
import { AccountSettingsForm } from "../../components/forms/AccountSettingsForm";
|
||||||
import { Button } from "../../components/Button";
|
import { Modal } from "../../components/Modal/Modal";
|
||||||
import { AvatarUploader } from "../../components/AvatarUploader";
|
import { AccountRemovalForm } from "../../components/forms/AccountRemovalForm";
|
||||||
|
import { Alert } from "../../components/Alert";
|
||||||
|
|
||||||
const FormGroup = styled.div.attrs({
|
const State = {
|
||||||
className: "grid sm:grid-cols-[1fr_min-content] w-full gap-y-2 gap-x-4 items-end",
|
Pure: "PURE",
|
||||||
})``;
|
Success: "SUCCESS",
|
||||||
|
Failure: "FAILURE",
|
||||||
|
};
|
||||||
|
|
||||||
const AccountPage = () => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col xl:flex-row">
|
<div className="flex flex-col xl:flex-row">
|
||||||
|
@ -26,34 +49,12 @@ const AccountPage = () => {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<hr />
|
<hr />
|
||||||
{!isLargeScreen && (
|
|
||||||
<section>
|
|
||||||
<AvatarUploader className="flex flex-col sm:flex-row gap-8 items-center" />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
<section className="flex flex-col gap-8">
|
<section className="flex flex-col gap-8">
|
||||||
<FormGroup>
|
{state === State.Failure && (
|
||||||
<TextInputBasic label="Display name" placeholder="John Doe" />
|
<Alert $variant="error">There was an error processing your request. Please try again later.</Alert>
|
||||||
<div className="flex mt-2 sm:mt-0 justify-center">
|
)}
|
||||||
<Button>Update</Button>
|
{state === State.Success && <Alert $variant="success">Changes saved successfully.</Alert>}
|
||||||
</div>
|
<AccountSettingsForm user={user} onSuccess={onSettingsUpdated} onFailure={() => setState(State.Failure)} />
|
||||||
</FormGroup>
|
|
||||||
<FormGroup>
|
|
||||||
<TextInputBasic label="Email" placeholder="john.doe@example.com" />
|
|
||||||
<div className="flex mt-2 sm:mt-0 justify-center">
|
|
||||||
<Button>Update</Button>
|
|
||||||
</div>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup>
|
|
||||||
<TextInputBasic type="password" label="Password" placeholder="dbf3htf*efh4pcy@PXB" />
|
|
||||||
<div className="flex mt-2 sm:mt-0 justify-center order-last sm:order-none">
|
|
||||||
<Button>Update</Button>
|
|
||||||
</div>
|
|
||||||
<small className="text-palette-400">
|
|
||||||
The password must be at least 6 characters long. Significantly different from the email and old
|
|
||||||
password.
|
|
||||||
</small>
|
|
||||||
</FormGroup>
|
|
||||||
</section>
|
</section>
|
||||||
<hr />
|
<hr />
|
||||||
<section>
|
<section>
|
||||||
|
@ -61,16 +62,18 @@ const AccountPage = () => {
|
||||||
<p>This will completely delete your account. This process can't be undone.</p>
|
<p>This will completely delete your account. This process can't be undone.</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.confirm("TODO: confirmation modal")}
|
onClick={prompt}
|
||||||
className="text-error underline decoration-1 hover:decoration-dashed"
|
className="text-error underline decoration-1 hover:decoration-dashed"
|
||||||
>
|
>
|
||||||
Delete account
|
Delete account
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-start xl:justify-end">
|
{removalInitiated && (
|
||||||
{isLargeScreen && <AvatarUploader className="flex flex-col gap-4" />}
|
<Modal onClose={abort} className="text-center">
|
||||||
</div>
|
<AccountRemovalForm abort={abort} onSuccess={() => navigate("/auth/login")} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
Reference in New Issue