Skip to content

Commit

Permalink
[wallet-ext] build new "Protect Account" UI screen and add some form …
Browse files Browse the repository at this point in the history
…primitives (MystenLabs#13160)

## Description 

This PR adds the new "Protect Account" screen used when clicking "Create
a new account" button and as the second step of the "Import Passphrase"
and "Import Private Key" flows. As part of this, I attempted to create
some new form primitives to make building forms more manageable (all of
the existing components aren't quite up to spec, use Formik, and aren't
fully accessible). I ended up following an approach similar to
https://www.brendonovich.dev/blog/the-ultimate-form-abstraction which
gave some creds to @Jordan-Mysten 😆

As a rough outline, we have generic, non-form-library-specific input
controls like `TextArea`, `Input`, `PasswordInput`, `Checkbox` which are
used to create `react-hook-form` specific controls such as `TextField`,
`TextAreaField`, `CheckboxField`, and so forth. We also have some helper
components like `Form` and `FormField` to help abstract away some
specific form details such as error states when using react-hook-form. I
considered using the Radix form primitives, but I didn't really see the
immediate value 🤷🏼

Additional note #1: Some of these pages are used in different flows and
have different submission logic depending on the usage context. I think
I might need to brainstorm on the best way to handle that and tackle it
in a follow-up PR

Additional note #2: the auto-lock input is still a WIP on the design
side, so I have a TODO to add that once it's ready.

Checkbox in Figma -
https://www.figma.com/file/T06obgYVOUD2JDGXM8QEDX?node-id=341%3A378&main-component=1&fuid=1209977329759347633

Input in Figma -
https://www.figma.com/file/T06obgYVOUD2JDGXM8QEDX/01-Components-%3A-Shared?node-id=19%3A312&mode=dev

<img width="631" alt="image"
src="https://github.com/MystenLabs/sui/assets/7453188/4c851808-b751-412a-b25e-06d4660b5fa3">

## Test Plan 
- Manual testing (error states, successful submission, accessibility,
focus/disabled/hover states, etc.)
- CI

---
If your changes are not user-facing and not a breaking change, you can
skip the following section. Otherwise, please indicate what changed, and
then add to the Release Notes section as highlighted during the release
process.

### Type of Change (Check all that apply)

- [ ] protocol change
- [ ] user-visible impact
- [ ] breaking change for a client SDKs
- [ ] breaking change for FNs (FN binary must upgrade)
- [ ] breaking change for validators or node operators (must upgrade
binaries)
- [ ] breaking change for on-chain data layout
- [ ] necessitate either a data wipe or data migration

### Release notes
  • Loading branch information
williamrobertson13 authored Jul 27, 2023
1 parent 57b9a8e commit ffb4258
Show file tree
Hide file tree
Showing 18 changed files with 427 additions and 129 deletions.
1 change: 1 addition & 0 deletions apps/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
"@mysten/sui.js": "workspace:*",
"@mysten/wallet-standard": "workspace:*",
"@noble/hashes": "^1.3.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@reduxjs/toolkit": "^1.9.5",
"@scure/bip32": "^1.3.1",
"@scure/bip39": "^1.2.1",
Expand Down
34 changes: 10 additions & 24 deletions apps/wallet/src/ui/app/components/accounts/ImportPrivateKeyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { useForm, type SubmitHandler } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import * as Yup from 'yup';
import { privateKeyValidation } from '../../helpers/validation/privateKeyValidation';
import { Form } from '../../shared/forms/Form';
import { TextAreaField } from '../../shared/forms/TextAreaField';
import { Button } from '_app/shared/ButtonUI';
import Alert from '_src/ui/app/components/alert';
import { Text } from '_src/ui/app/shared/text';

const formSchema = Yup.object({
privateKey: privateKeyValidation,
Expand All @@ -21,33 +21,19 @@ type ImportPrivateKeyFormProps = {
};

export function ImportPrivateKeyForm({ onSubmit }: ImportPrivateKeyFormProps) {
const {
register,
handleSubmit,
formState: { isSubmitting, isValid, touchedFields, errors },
} = useForm({
const form = useForm({
mode: 'onTouched',
resolver: yupResolver(formSchema),
});
const {
register,
formState: { isSubmitting, isValid },
} = form;
const navigate = useNavigate();

return (
<form className="flex flex-col h-full" onSubmit={handleSubmit(onSubmit)}>
<label className="flex flex-col gap-2.5">
<div className="pl-2.5">
<Text variant="pBody" color="steel-darker" weight="semibold">
Enter Private Key
</Text>
</div>
<textarea
className="resize-none w-full text-body text-steel-dark font-medium p-3 border border-solid border-gray-45 rounded-2lg shadow-button focus:border-steel focus:shadow-none"
rows={4}
{...register('privateKey')}
/>
{touchedFields.privateKey && errors.privateKey && (
<Alert>{errors.privateKey.message}</Alert>
)}
</label>
<Form className="flex flex-col h-full" form={form} onSubmit={onSubmit}>
<TextAreaField label="Enter Private Key" rows={4} {...register('privateKey')} />
<div className="flex gap-2.5 mt-auto">
<Button variant="outline" size="tall" text="Cancel" onClick={() => navigate(-1)} />
<Button
Expand All @@ -59,6 +45,6 @@ export function ImportPrivateKeyForm({ onSubmit }: ImportPrivateKeyFormProps) {
text="Add Account"
/>
</div>
</form>
</Form>
);
}
94 changes: 94 additions & 0 deletions apps/wallet/src/ui/app/components/accounts/ProtectAccountForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { yupResolver } from '@hookform/resolvers/yup';
import { useForm, type SubmitHandler } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import * as Yup from 'yup';
import { CheckboxField } from '../../shared/forms/CheckboxField';
import { Form } from '../../shared/forms/Form';
import { TextField } from '../../shared/forms/TextField';
import ExternalLink from '../external-link';
import { Button } from '_app/shared/ButtonUI';
import { ToS_LINK } from '_src/shared/constants';

const formSchema = Yup.object({
password: Yup.string().required('Required'),
confirmedPassword: Yup.string().required('Required'),
acceptedTos: Yup.boolean().required().oneOf([true]),
enabledAutolock: Yup.boolean(),
});

type FormValues = Yup.InferType<typeof formSchema>;

type ProtectAccountFormProps = {
submitButtonText: string;
cancelButtonText: string;
onSubmit: SubmitHandler<FormValues>;
};

export function ProtectAccountForm({
submitButtonText,
cancelButtonText,
onSubmit,
}: ProtectAccountFormProps) {
const form = useForm({
mode: 'all',
defaultValues: {
password: '',
confirmedPassword: '',
acceptedTos: false,
enabledAutolock: true,
},
resolver: yupResolver(formSchema),
});
const {
register,
formState: { isSubmitting, isValid },
} = form;
const navigate = useNavigate();

return (
<Form className="flex flex-col gap-6 h-full" form={form} onSubmit={onSubmit}>
<TextField type="password" label="Create Account Password" {...register('password')} />
<TextField
type="password"
label="Confirm Account Password"
{...register('confirmedPassword')}
/>
<div className="flex flex-col gap-4">
<CheckboxField name="enabledAutolock" label="Auto-lock after I am inactive for" />
{/* TODO: Abhi is working on designs for the auto-lock input, we'll add this when it's ready */}
</div>
<div className="flex flex-col gap-5 mt-auto">
<CheckboxField
name="acceptedTos"
label={
<>
I read and agreed to the{' '}
<ExternalLink href={ToS_LINK} className="text-[#1F6493] no-underline">
Terms of Services
</ExternalLink>
</>
}
/>
<div className="flex gap-2.5">
<Button
variant="outline"
size="tall"
text={cancelButtonText}
onClick={() => navigate(-1)}
/>
<Button
type="submit"
disabled={isSubmitting || !isValid}
variant="primary"
size="tall"
loading={isSubmitting}
text={submitButtonText}
/>
</div>
</div>
</Form>
);
}
6 changes: 3 additions & 3 deletions apps/wallet/src/ui/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import { useEffect } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';

import { useInitialPageView } from './hooks/useInitialPageView';
import { CreateNewAccountPage } from './pages/accounts/CreateNewAccountPage';
import { ProtectAccountPage } from './pages/accounts/ProtectAccountPage';
import AssetsPage from './pages/home/assets';
import { QredoConnectInfoPage } from './pages/qredo-connect/QredoConnectInfoPage';
import { SelectQredoAccountsPage } from './pages/qredo-connect/SelectQredoAccountsPage';
import { RestrictedPage } from './pages/restricted';
import WelcomePage from './pages/welcome';
import { AppType } from './redux/slices/app/AppType';
import { Staking } from './staking/home';
import ForgotPasswordPage from '_app/wallet/forgot-password-page';
Expand Down Expand Up @@ -41,7 +42,6 @@ import CreatePage from '_pages/initialize/create';
import { ImportPage } from '_pages/initialize/import';
import SelectPage from '_pages/initialize/select';
import SiteConnectPage from '_pages/site-connect';
import WelcomePage from '_pages/welcome';
import { setNavVisibility } from '_redux/slices/app';

const HIDDEN_MENU_PATHS = [
Expand Down Expand Up @@ -97,7 +97,7 @@ const App = () => {
{useNewOnboardingFlow && (
<Route path="/accounts" element={<AccountsPage />}>
<Route path="add-account" element={<AddAccountPage />} />
<Route path="create-new-account" element={<CreateNewAccountPage />} />
<Route path="protect-account" element={<ProtectAccountPage />} />
<Route path="import-ledger-accounts" element={<ImportLedgerAccountsPage />} />
<Route path="import-passphrase" element={<ImportPassphrasePage />} />
<Route path="import-private-key" element={<ImportPrivateKeyPage />} />
Expand Down
2 changes: 1 addition & 1 deletion apps/wallet/src/ui/app/pages/accounts/AddAccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function AddAccountPage({ showSocialSignInOptions = false }: AddAccountPa
variant="outline"
size="tall"
text="Create a new Passphrase Account"
to="/accounts/create-new-account"
to="/accounts/protect-account"
onClick={() => {
ampli.clickedCreateNewAccount({ sourceFlow });
}}
Expand Down

This file was deleted.

34 changes: 34 additions & 0 deletions apps/wallet/src/ui/app/pages/accounts/ProtectAccountPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { ProtectAccountForm } from '../../components/accounts/ProtectAccountForm';
import { Heading } from '../../shared/heading';
import { Text } from '_app/shared/text';

export function ProtectAccountPage() {
return (
<div className="rounded-20 bg-sui-lightest shadow-wallet-content flex flex-col items-center px-6 py-10 h-full">
<Text variant="caption" color="steel-dark" weight="semibold">
Wallet Setup
</Text>
<div className="text-center mt-2.5">
<Heading variant="heading1" color="gray-90" as="h1" weight="bold">
Protect Account with a Password Lock
</Heading>
</div>
<div className="mt-6 w-full grow">
<ProtectAccountForm
cancelButtonText="Back"
submitButtonText="Create Wallet"
onSubmit={(formValues) => {
// eslint-disable-next-line no-console
console.log(
'TODO: Do something when the user submits the form successfully',
formValues,
);
}}
/>
</div>
</div>
);
}
33 changes: 33 additions & 0 deletions apps/wallet/src/ui/app/shared/forms/CheckboxField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { type ComponentProps, type ReactNode, forwardRef } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { Checkbox } from './controls/Checkbox';

type CheckboxFieldProps = {
name: string;
label: ReactNode;
} & Omit<ComponentProps<'button'>, 'ref'>;

export const CheckboxField = forwardRef<HTMLButtonElement, CheckboxFieldProps>(
({ label, name, ...props }, forwardedRef) => {
const { control } = useFormContext();
return (
<Controller
control={control}
name={name}
render={({ field: { onChange, name, value } }) => (
<Checkbox
label={label}
onCheckedChange={onChange}
name={name}
checked={value}
ref={forwardedRef}
{...props}
/>
)}
/>
);
},
);
25 changes: 25 additions & 0 deletions apps/wallet/src/ui/app/shared/forms/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { type ComponentProps } from 'react';
import {
type FieldValues,
FormProvider,
type SubmitHandler,
type UseFormReturn,
} from 'react-hook-form';

type FormProps<T extends FieldValues> = Omit<ComponentProps<'form'>, 'onSubmit'> & {
form: UseFormReturn<T>;
onSubmit: SubmitHandler<T>;
};

export function Form<T extends FieldValues>({ form, onSubmit, children, ...props }: FormProps<T>) {
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} {...props}>
{children}
</form>
</FormProvider>
);
}
26 changes: 26 additions & 0 deletions apps/wallet/src/ui/app/shared/forms/FormField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0
import { type ReactNode } from 'react';
import { useFormContext } from 'react-hook-form';
import { FormLabel } from './FormLabel';
import Alert from '../../components/alert';

type FormFieldProps = {
name: string;
label: ReactNode;
children: ReactNode;
};

export function FormField({ children, name, label }: FormFieldProps) {
const { getFieldState, formState } = useFormContext();
const state = getFieldState(name, formState);

return (
<div className="flex flex-col gap-2.5">
<FormLabel label={label}>{children}</FormLabel>
{state.error && <Alert>{state.error.message}</Alert>}
</div>
);
}

export default FormField;
24 changes: 24 additions & 0 deletions apps/wallet/src/ui/app/shared/forms/FormLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { Text } from '_app/shared/text';

import type { ReactNode } from 'react';

export type FormLabelProps = {
label: ReactNode;
children: ReactNode;
};

export function FormLabel({ label, children }: FormLabelProps) {
return (
<label className="flex flex-col flex-nowrap gap-2.5">
<div className="pl-2.5">
<Text variant="body" color="steel-darker" weight="semibold">
{label}
</Text>
</div>
{children}
</label>
);
}
19 changes: 19 additions & 0 deletions apps/wallet/src/ui/app/shared/forms/TextAreaField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { type ComponentProps, type ReactNode, forwardRef } from 'react';
import FormField from './FormField';
import { TextArea } from './controls/TextArea';

type TextAreaFieldProps = {
name: string;
label: ReactNode;
} & ComponentProps<'textarea'>;

export const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>(
({ label, ...props }, forwardedRef) => (
<FormField name={props.name} label={label}>
<TextArea {...props} ref={forwardedRef} />
</FormField>
),
);
Loading

0 comments on commit ffb4258

Please sign in to comment.