2022-10-30 18:31:15 +01:00

282 lines
6.8 KiB
TypeScript

import * as yup from "yup"
import {TiDelete} from "react-icons/ti"
import {AxiosError} from "axios"
import {ReactElement, useContext} from "react"
import {RiLinkM, RiStickyNoteFill} from "react-icons/ri"
import {FieldArray, FormikProvider, useFormik} from "formik"
import update from "immutability-helper"
import {useMutation} from "@tanstack/react-query"
import {
Button,
FormGroup,
FormHelperText,
Grid,
IconButton,
InputAdornment,
List,
ListItem,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
TextField,
} from "@mui/material"
import {URL_REGEX} from "~/constants/values"
import {decryptAliasNotes, parseFastAPIError, whenEnterPressed} from "~/utils"
import {BackupImage, ErrorSnack, SuccessSnack} from "~/components"
import {Alias, AliasNote, DecryptedAlias} from "~/server-types"
import {UpdateAliasData, updateAlias} from "~/apis"
import AuthContext from "~/AuthContext/AuthContext"
export interface AliasNotesFormProps {
id: string
notes: AliasNote
onChanged: (alias: DecryptedAlias) => void
}
interface Form {
personalNotes: string
websites: AliasNote["data"]["websites"]
detail?: string
}
interface WebsiteForm {
url: string
}
const SCHEMA = yup.object().shape({
personalNotes: yup.string(),
websites: yup.array().of(
yup.object().shape({
url: yup.string().url(),
createdAt: yup.date(),
}),
),
})
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({
id,
notes,
onChanged,
}: AliasNotesFormProps): ReactElement {
const {_encryptUsingMasterPassword, _decryptUsingMasterPassword} =
useContext(AuthContext)
const {mutateAsync, isSuccess} = useMutation<
Alias,
AxiosError,
UpdateAliasData
>(values => updateAlias(id, values), {
onSuccess: newAlias => {
onChanged(decryptAliasNotes(newAlias, _decryptUsingMasterPassword))
},
})
const formik = useFormik<Form>({
validationSchema: SCHEMA,
initialValues: {
personalNotes: notes.data.personalNotes,
websites: notes.data.websites,
},
onSubmit: async (values, {setErrors}) => {
try {
const newNotes = update(notes, {
data: {
personalNotes: {
$set: values.personalNotes,
},
websites: {
$set: values.websites,
},
},
})
const data = _encryptUsingMasterPassword(
JSON.stringify(newNotes),
)
await mutateAsync({
encryptedNotes: data,
})
} catch (error) {
setErrors(parseFastAPIError(error as AxiosError))
}
},
})
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}` : ""}`
websiteFormik.resetForm()
await formik.setFieldValue(
"websites",
[
...formik.values.websites,
{
url: baseUrl,
createdAt: new Date(),
},
],
true,
)
await formik.submitForm()
},
validateOnChange: true,
validateOnBlur: true,
})
return (
<>
<form onSubmit={formik.handleSubmit}>
<Grid container spacing={4} direction="column">
<Grid item>
<TextField
label="Personal Notes"
multiline
fullWidth
key="personalNotes"
id="personalNotes"
name="personalNotes"
value={formik.values.personalNotes}
onChange={formik.handleChange}
onBlur={() => formik.submitForm()}
disabled={formik.isSubmitting}
error={
formik.touched.personalNotes &&
Boolean(formik.errors.personalNotes)
}
helperText={
(formik.touched.personalNotes &&
formik.errors.personalNotes) ||
"You can enter personal notes for this alias here. Notes are encrypted."
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<RiStickyNoteFill />
</InputAdornment>
),
}}
/>
</Grid>
<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}
error={
websiteFormik.touched.url &&
Boolean(websiteFormik.errors.url)
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<RiLinkM />
</InputAdornment>
),
}}
/>
<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}>
<FieldArray
name="websites"
render={arrayHelpers => (
<List>
{formik.values.websites.map(
(website, index) => (
<ListItem key={website.url}>
<ListItemIcon>
<BackupImage
width={20}
fallbackSrc={`https://external-content.duckduckgo.com/ip3/${getDomain(
website.url,
)}.ico`}
src={`${website.url}/favicon.ico`}
/>
</ListItemIcon>
<ListItemText>
{website.url}
</ListItemText>
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="delete"
onClick={async () => {
arrayHelpers.remove(
index,
)
await formik.submitForm()
}}
>
<TiDelete />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
),
)}
</List>
)}
/>
</FormikProvider>
</Grid>
</Grid>
</form>
<ErrorSnack message={formik.errors.detail} />
<SuccessSnack
message={isSuccess && "Updated notes successfully!"}
/>
</>
)
}