improved AliasNotesForm.tsx

This commit is contained in:
Myzel394 2022-10-31 08:59:11 +01:00
parent b376177dc2
commit f4f8eceabe
8 changed files with 426 additions and 254 deletions

View File

@ -21,6 +21,7 @@
"camelcase-keys": "^8.0.2", "camelcase-keys": "^8.0.2",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"deep-equal": "^2.0.5",
"formik": "^2.2.9", "formik": "^2.2.9",
"group-array": "^1.0.0", "group-array": "^1.0.0",
"immutability-helper": "^3.1.1", "immutability-helper": "^3.1.1",
@ -41,6 +42,7 @@
"devDependencies": { "devDependencies": {
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/date-fns": "^2.6.0", "@types/date-fns": "^2.6.0",
"@types/deep-equal": "^1.0.1",
"@types/group-array": "^1.0.1", "@types/group-array": "^1.0.1",
"@types/openpgp": "^4.4.18", "@types/openpgp": "^4.4.18",
"@types/react": "^18.0.17", "@types/react": "^18.0.17",

View File

@ -1,25 +0,0 @@
import React, {ReactElement, useState} from "react"
export interface BackupImageProps
extends Omit<
React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>,
"src"
> {
fallbackSrc: string
src: string
}
export default function BackupImage({
fallbackSrc,
src,
...props
}: BackupImageProps): ReactElement {
const [source, setSource] = useState<string>(src)
return (
<img {...props} src={source} onError={() => setSource(fallbackSrc)} />
)
}

View File

@ -0,0 +1,38 @@
import React, {ReactElement, useState} from "react"
export interface FaviconImageProps
extends Omit<
React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>,
"src"
> {
url: string
}
const getDomain = (url: string): string => {
const {hostname, port} = new URL(url)
return `${hostname}${port ? `:${port}` : ""}`
}
export default function FaviconImage({
url,
...props
}: FaviconImageProps): ReactElement {
const [source, setSource] = useState<string>(`${url}/favicon.ico`)
return (
<img
{...props}
src={source}
onError={() =>
setSource(
`https://external-content.duckduckgo.com/ip3/${getDomain(
url,
)}.ico`,
)
}
/>
)
}

View File

@ -28,5 +28,5 @@ export * from "./AliasTypeIndicator"
export {default as AliasTypeIndicator} from "./AliasTypeIndicator" export {default as AliasTypeIndicator} from "./AliasTypeIndicator"
export * from "./DecryptionPasswordMissingAlert" export * from "./DecryptionPasswordMissingAlert"
export {default as DecryptionPasswordMissingAlert} from "./DecryptionPasswordMissingAlert" export {default as DecryptionPasswordMissingAlert} from "./DecryptionPasswordMissingAlert"
export * from "./BackupImage" export * from "./FaviconImage"
export {default as BackupImage} from "./BackupImage" export {default as FaviconImage} from "./FaviconImage"

View File

@ -0,0 +1,114 @@
import * as yup from "yup"
import {useFormik} from "formik"
import {ReactElement} from "react"
import {RiLinkM} from "react-icons/ri"
import {
Button,
FormGroup,
FormHelperText,
Grid,
InputAdornment,
TextField,
} from "@mui/material"
import {URL_REGEX} from "~/constants/values"
import {whenEnterPressed} from "~/utils"
export interface AddWebsiteFieldProps {
onAdd: (website: string) => Promise<void>
isLoading: boolean
}
interface WebsiteForm {
url: string
}
const WEBSITE_SCHEMA = yup.object().shape({
url: yup.string().matches(URL_REGEX, "This URL is invalid."),
})
export default function AddWebsiteField({
onAdd,
isLoading,
}: AddWebsiteFieldProps): ReactElement {
const websiteFormik = useFormik<WebsiteForm>({
validationSchema: WEBSITE_SCHEMA,
initialValues: {
url: "",
},
onSubmit: async values => {
const url = (() => {
// Make sure url starts with `http://` or `https://`
if (values.url.startsWith("http://")) {
return values.url
}
if (values.url.startsWith("https://")) {
return values.url
}
return `https://${values.url}`
})()
const {hostname, protocol, port} = new URL(url)
const baseUrl = `${protocol}//${hostname}${port ? `:${port}` : ""}`
await onAdd(baseUrl)
websiteFormik.resetForm()
},
validateOnChange: true,
validateOnBlur: true,
})
return (
<Grid container spacing={2} direction="column">
<Grid item>
<FormGroup row>
<TextField
name="url"
id="url"
label="Website"
variant="outlined"
value={websiteFormik.values.url}
onChange={websiteFormik.handleChange}
onBlur={websiteFormik.handleBlur}
onKeyDown={whenEnterPressed(() =>
websiteFormik.handleSubmit(),
)}
disabled={websiteFormik.isSubmitting || isLoading}
error={
websiteFormik.touched.url &&
Boolean(websiteFormik.errors.url)
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<RiLinkM />
</InputAdornment>
),
}}
/>
<Button
size="small"
variant="contained"
disableElevation
disabled={websiteFormik.isSubmitting || isLoading}
onClick={() => websiteFormik.handleSubmit()}
>
Add
</Button>
</FormGroup>
</Grid>
<Grid item>
<FormHelperText
error={
websiteFormik.touched.url &&
Boolean(websiteFormik.errors.url)
}
>
{(websiteFormik.touched.url && websiteFormik.errors.url) ||
"Add a website to this alias. Used to autofill."}
</FormHelperText>
</Grid>
</Grid>
)
}

View File

@ -15,6 +15,11 @@ export interface AliasDetailsProps {
alias: Alias | DecryptedAlias alias: Alias | DecryptedAlias
} }
const getDomain = (url: string): string => {
const {hostname, port} = new URL(url)
return `${hostname}${port ? `:${port}` : ""}`
}
export default function AliasDetails({ export default function AliasDetails({
alias: aliasValue, alias: aliasValue,
}: AliasDetailsProps): ReactElement { }: AliasDetailsProps): ReactElement {
@ -46,13 +51,6 @@ export default function AliasDetails({
</Grid> </Grid>
</Grid> </Grid>
<Grid item width="100%"> <Grid item width="100%">
<Grid container direction="column" spacing={4}>
<Grid item>
<Typography variant="h6" component="h3">
Notes
</Typography>
</Grid>
<Grid item>
{encryptionStatus === EncryptionStatus.Available ? ( {encryptionStatus === EncryptionStatus.Available ? (
<AliasNotesForm <AliasNotesForm
id={aliasUIState.id} id={aliasUIState.id}
@ -63,8 +61,6 @@ export default function AliasDetails({
<DecryptionPasswordMissingAlert /> <DecryptionPasswordMissingAlert />
)} )}
</Grid> </Grid>
</Grid>
</Grid>
<Grid item> <Grid item>
<Grid container spacing={4}> <Grid container spacing={4}>
<Grid item> <Grid item>
@ -72,24 +68,23 @@ export default function AliasDetails({
Settings Settings
</Typography> </Typography>
</Grid> </Grid>
<Grid item>
<Typography variant="body1">
These settings apply to this alias only. You can
either set a value manually or refer to your
defaults settings. Note that this does change in
behavior. When you set a value to refer to your
default setting, the alias will always use the
latest value. So when you change your default
setting, the alias will automatically use the new
value.
</Typography>
</Grid>
<Grid item> <Grid item>
<AliasPreferencesForm <AliasPreferencesForm
alias={aliasUIState} alias={aliasUIState}
onChanged={setAliasUIState} onChanged={setAliasUIState}
/> />
</Grid> </Grid>
<Grid item>
<Typography variant="body2">
These settings apply to this alias only. You can
either set a value manually or refer to your default
settings. Note that this does change in behavior.
When you set a value to refer to your default
setting, the alias will always use the latest value.
So when you change your default setting, the alias
will automatically use the new value.
</Typography>
</Grid>
</Grid> </Grid>
</Grid> </Grid>
</Grid> </Grid>

View File

@ -1,18 +1,17 @@
import * as yup from "yup" import * as yup from "yup"
import {TiDelete} from "react-icons/ti" import {TiDelete} from "react-icons/ti"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {ReactElement, useContext} from "react" import {ReactElement, useContext, useMemo, useState} from "react"
import {MdEditCalendar} from "react-icons/md" import {MdCheckCircle, MdEditCalendar} from "react-icons/md"
import {RiLinkM, RiStickyNoteFill} from "react-icons/ri" import {RiStickyNoteFill} from "react-icons/ri"
import {FieldArray, FormikProvider, useFormik} from "formik" import {FieldArray, FormikProvider, useFormik} from "formik"
import {FaPen} from "react-icons/fa"
import deepEqual from "deep-equal"
import format from "date-fns/format" import format from "date-fns/format"
import update from "immutability-helper" import update from "immutability-helper"
import {useMutation} from "@tanstack/react-query" import {useMutation} from "@tanstack/react-query"
import { import {
Button,
FormGroup,
FormHelperText,
Grid, Grid,
IconButton, IconButton,
InputAdornment, InputAdornment,
@ -26,11 +25,11 @@ import {
Typography, Typography,
} from "@mui/material" } from "@mui/material"
import {URL_REGEX} from "~/constants/values" import {parseFastAPIError} from "~/utils"
import {parseFastAPIError, whenEnterPressed} from "~/utils" import {ErrorSnack, FaviconImage, SuccessSnack} from "~/components"
import {BackupImage, ErrorSnack, SuccessSnack} from "~/components"
import {Alias, AliasNote, DecryptedAlias} from "~/server-types" import {Alias, AliasNote, DecryptedAlias} from "~/server-types"
import {UpdateAliasData, updateAlias} from "~/apis" import {UpdateAliasData, updateAlias} from "~/apis"
import AddWebsiteField from "~/route-widgets/AliasDetailRoute/AddWebsiteField"
import AuthContext from "~/AuthContext/AuthContext" import AuthContext from "~/AuthContext/AuthContext"
import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes" import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes"
@ -48,10 +47,6 @@ interface Form {
detail?: string detail?: string
} }
interface WebsiteForm {
url: string
}
const SCHEMA = yup.object().shape({ const SCHEMA = yup.object().shape({
personalNotes: yup.string(), personalNotes: yup.string(),
websites: yup.array().of( websites: yup.array().of(
@ -61,14 +56,6 @@ const SCHEMA = yup.object().shape({
}), }),
), ),
}) })
const WEBSITE_SCHEMA = yup.object().shape({
url: yup.string().matches(URL_REGEX, "This URL is invalid."),
})
const getDomain = (url: string): string => {
const {hostname, port} = new URL(url)
return `${hostname}${port ? `:${port}` : ""}`
}
export default function AliasNotesForm({ export default function AliasNotesForm({
id, id,
@ -91,12 +78,16 @@ export default function AliasNotesForm({
onChanged(newAlias as any as DecryptedAlias) onChanged(newAlias as any as DecryptedAlias)
}, },
}) })
const formik = useFormik<Form>({ const initialValues = useMemo(
validationSchema: SCHEMA, () => ({
initialValues: {
personalNotes: notes.data.personalNotes, personalNotes: notes.data.personalNotes,
websites: notes.data.websites, websites: notes.data.websites,
}, }),
[notes.data.personalNotes, notes.data.websites],
)
const formik = useFormik<Form>({
validationSchema: SCHEMA,
initialValues,
onSubmit: async (values, {setErrors}) => { onSubmit: async (values, {setErrors}) => {
try { try {
const newNotes = update(notes, { const newNotes = update(notes, {
@ -121,47 +112,51 @@ export default function AliasNotesForm({
} }
}, },
}) })
const websiteFormik = useFormik<WebsiteForm>({
validationSchema: WEBSITE_SCHEMA,
initialValues: {
url: "",
},
onSubmit: async values => {
const url = (() => {
// Make sure url starts with `http://` or `https://`
if (values.url.startsWith("http://")) {
return values.url
}
if (values.url.startsWith("https://")) {
return values.url
}
return `https://${values.url}`
})()
const {hostname, protocol, port} = new URL(url) const [isInEditMode, setIsInEditMode] = useState<boolean>(false)
const baseUrl = `${protocol}//${hostname}${port ? `:${port}` : ""}`
websiteFormik.resetForm()
await formik.setFieldValue(
"websites",
[
...formik.values.websites,
{
url: baseUrl,
createdAt: new Date(),
},
],
true,
)
await formik.submitForm()
},
validateOnChange: true,
validateOnBlur: true,
})
return ( return (
<> <>
<form onSubmit={formik.handleSubmit}> <form onSubmit={formik.handleSubmit}>
<Grid container direction="column" spacing={4}>
<Grid item>
<Grid container spacing={1} direction="row">
<Grid item>
<Typography variant="h6" component="h3">
Notes
</Typography>
</Grid>
<Grid item>
<IconButton
size="small"
disabled={formik.isSubmitting}
onClick={async () => {
if (
isInEditMode &&
!deepEqual(
initialValues,
formik.values,
{
strict: true,
},
)
) {
await formik.submitForm()
}
setIsInEditMode(!isInEditMode)
}}
>
{isInEditMode ? (
<MdCheckCircle />
) : (
<FaPen />
)}
</IconButton>
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container spacing={4} direction="column"> <Grid container spacing={4} direction="column">
{notes.data.createdAt && ( {notes.data.createdAt && (
<Grid item> <Grid item>
@ -179,7 +174,10 @@ export default function AliasNotesForm({
title={notes.data.createdAt.toISOString()} title={notes.data.createdAt.toISOString()}
> >
<Typography variant="body1"> <Typography variant="body1">
{format(notes.data.createdAt, "Pp")} {format(
notes.data.createdAt,
"Pp",
)}
</Typography> </Typography>
</Tooltip> </Tooltip>
</Grid> </Grid>
@ -187,6 +185,14 @@ export default function AliasNotesForm({
</Grid> </Grid>
)} )}
<Grid item> <Grid item>
<Grid container spacing={1} direction="column">
<Grid item>
<Typography variant="overline">
Personal Notes
</Typography>
</Grid>
<Grid item>
{isInEditMode ? (
<TextField <TextField
label="Personal Notes" label="Personal Notes"
multiline multiline
@ -194,17 +200,25 @@ export default function AliasNotesForm({
key="personalNotes" key="personalNotes"
id="personalNotes" id="personalNotes"
name="personalNotes" name="personalNotes"
value={formik.values.personalNotes} value={
formik.values.personalNotes
}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={() => formik.submitForm()} onBlur={formik.handleBlur}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
error={ error={
formik.touched.personalNotes && formik.touched
Boolean(formik.errors.personalNotes) .personalNotes &&
Boolean(
formik.errors
.personalNotes,
)
} }
helperText={ helperText={
(formik.touched.personalNotes && (formik.touched
formik.errors.personalNotes) || .personalNotes &&
formik.errors
.personalNotes) ||
"You can enter personal notes for this alias here. Notes are encrypted." "You can enter personal notes for this alias here. Notes are encrypted."
} }
InputProps={{ InputProps={{
@ -215,71 +229,64 @@ export default function AliasNotesForm({
), ),
}} }}
/> />
) : (
<Typography>
{notes.data.personalNotes}
</Typography>
)}
</Grid>
</Grid>
</Grid> </Grid>
<Grid item> <Grid item>
<FormGroup row> <Grid container spacing={1} direction="column">
<TextField <Grid item>
name="url" <Typography variant="overline">
id="url" Websites
label="Website" </Typography>
variant="outlined" </Grid>
value={websiteFormik.values.url} {isInEditMode ? (
onChange={websiteFormik.handleChange} <Grid item>
onBlur={websiteFormik.handleBlur} <AddWebsiteField
onKeyDown={whenEnterPressed(() => onAdd={async website => {
websiteFormik.handleSubmit(), await formik.setFieldValue(
)} "websites",
disabled={websiteFormik.isSubmitting} [
error={ ...formik.values
websiteFormik.touched.url && .websites,
Boolean(websiteFormik.errors.url) {
} url: website,
InputProps={{ },
startAdornment: ( ],
<InputAdornment position="start"> )
<RiLinkM />
</InputAdornment>
),
}} }}
isLoading={formik.isSubmitting}
/> />
<Button
size="small"
variant="contained"
disableElevation
onClick={() => websiteFormik.handleSubmit()}
>
Add
</Button>
<FormHelperText
error={
websiteFormik.touched.url &&
Boolean(websiteFormik.errors.url)
}
>
{(websiteFormik.touched.url &&
websiteFormik.errors.url) ||
"Add a website to this alias. Used to autofill."}
</FormHelperText>
</FormGroup>
<FormikProvider value={formik}> <FormikProvider value={formik}>
<FieldArray <FieldArray
name="websites" name="websites"
render={arrayHelpers => ( render={arrayHelpers => (
<List> <List>
{formik.values.websites.map( {formik.values.websites.map(
(website, index) => ( (
<ListItem key={website.url}> website,
index,
) => (
<ListItem
key={
website.url
}
>
<ListItemIcon> <ListItemIcon>
<BackupImage <FaviconImage
width={20} url={
fallbackSrc={`https://external-content.duckduckgo.com/ip3/${getDomain( website.url
website.url, }
)}.ico`}
src={`${website.url}/favicon.ico`}
/> />
</ListItemIcon> </ListItemIcon>
<ListItemText> <ListItemText>
{website.url} {
website.url
}
</ListItemText> </ListItemText>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<IconButton <IconButton
@ -289,7 +296,6 @@ export default function AliasNotesForm({
arrayHelpers.remove( arrayHelpers.remove(
index, index,
) )
await formik.submitForm()
}} }}
> >
<TiDelete /> <TiDelete />
@ -303,6 +309,48 @@ export default function AliasNotesForm({
/> />
</FormikProvider> </FormikProvider>
</Grid> </Grid>
) : (
<Grid item>
{notes.data.websites.length ? (
<List>
{notes.data.websites.map(
website => (
<ListItem
key={
website.url
}
>
<ListItemIcon>
<FaviconImage
width={
20
}
url={
website.url
}
/>
</ListItemIcon>
<ListItemText>
{
website.url
}
</ListItemText>
</ListItem>
),
)}
</List>
) : (
<Typography variant="body2">
You haven&apos;t used this
alias on any website yet.
</Typography>
)}
</Grid>
)}
</Grid>
</Grid>
</Grid>
</Grid>
</Grid> </Grid>
</form> </form>
<ErrorSnack message={formik.errors.detail} /> <ErrorSnack message={formik.errors.detail} />

View File

@ -40,7 +40,7 @@ export default function AuthenticatedRoute(): ReactElement {
justifyContent="space-between" justifyContent="space-between"
alignItems="center" alignItems="center"
> >
<Grid item xs={12} sm={4} md={2}> <Grid item xs={12} sm={3} md={2}>
<Box <Box
bgcolor={theme.palette.background.paper} bgcolor={theme.palette.background.paper}
component="nav" component="nav"
@ -56,7 +56,7 @@ export default function AuthenticatedRoute(): ReactElement {
</List> </List>
</Box> </Box>
</Grid> </Grid>
<Grid item xs={12} sm={8} md={10}> <Grid item xs={12} sm={9} md={10}>
<Paper> <Paper>
<Box <Box
maxHeight="80vh" maxHeight="80vh"