added ReservedAliasDetailRoute.tsx

This commit is contained in:
Myzel394 2023-02-04 16:43:38 +01:00
parent dac14af539
commit 90dd7ca180
11 changed files with 486 additions and 1 deletions

View File

@ -296,6 +296,20 @@
"userAmount_one": "Forwards to one user",
"userAmount_other": "Forwards to {{count}} users"
},
"ReservedAliasDetailRoute": {
"title": "Reserved Alias Details",
"sections": {
"users": {
"title": "Users",
"fields": {
"users": {
"label": "Users",
"me": "{{email}} (Me)"
}
}
}
}
},
"AdminRoute": {
"title": "Site configuration",
"forms": {

View File

@ -21,6 +21,7 @@ import LogoutRoute from "~/routes/LogoutRoute"
import OverviewRoute from "~/routes/OverviewRoute"
import ReportDetailRoute from "~/routes/ReportDetailRoute"
import ReportsRoute from "~/routes/ReportsRoute"
import ReservedAliasDetailRoute from "~/routes/ReservedAliasDetailRoute"
import ReservedAliasesRoute from "~/routes/ReservedAliasesRoute"
import RootRoute from "~/routes/Root"
import SettingsRoute from "~/routes/SettingsRoute"
@ -104,6 +105,10 @@ const router = createBrowserRouter([
path: "/admin/reserved-aliases",
element: <ReservedAliasesRoute />,
},
{
path: "/admin/reserved-aliases/:id",
element: <ReservedAliasDetailRoute />,
},
{
path: "/admin/reserved-aliases/create",
loader: getServerSettings,

View File

@ -0,0 +1,13 @@
import {ReservedAlias} from "~/server-types"
import {client} from "~/constants/axios-client"
export default async function getReservedAlias(id: string): Promise<ReservedAlias> {
const {data} = await client.get(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/reserved-alias/${id}`,
{
withCredentials: true,
},
)
return data
}

View File

@ -44,3 +44,7 @@ export * from "./get-reserved-aliases"
export {default as getReservedAliases} from "./get-reserved-aliases"
export * from "./create-reserved-alias"
export {default as createReservedAlias} from "./create-reserved-alias"
export * from "./get-reserved-alias"
export {default as getReservedAlias} from "./get-reserved-alias"
export * from "./update-reserved-alias"
export {default as updateReservedAlias} from "./update-reserved-alias"

View File

@ -0,0 +1,27 @@
import {ReservedAlias} from "~/server-types"
import {client} from "~/constants/axios-client"
export interface UpdateReservedAliasData {
isActive?: boolean
users?: Array<{
id: string
}>
}
export default async function updateReservedAlias(
id: string,
{isActive, users}: UpdateReservedAliasData,
): Promise<ReservedAlias> {
const {data} = await client.patch(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/reserved-alias/${id}`,
{
isActive,
users,
},
{
withCredentials: true,
},
)
return data
}

View File

@ -0,0 +1,65 @@
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {ReactElement} from "react"
import {useQuery} from "@tanstack/react-query"
import {MenuItem, TextField} from "@mui/material"
import {GetAdminUsersResponse, getAdminUsers} from "~/apis"
import {useUser} from "~/hooks"
export interface AdminUserPickerProps {
onPick: (user: GetAdminUsersResponse["users"][0]) => void
alreadyPicked: GetAdminUsersResponse["users"]
}
export default function AdminUserPicker({
onPick,
alreadyPicked,
}: AdminUserPickerProps): ReactElement {
const {t} = useTranslation()
const meUser = useUser()
const {data: {users: availableUsers} = {}} = useQuery<GetAdminUsersResponse, AxiosError>(
["getAdminUsers"],
getAdminUsers,
)
if (!availableUsers) {
return <></>
}
const users = availableUsers.filter(
user => !alreadyPicked.find(picked => picked.id === user.id),
)
if (users.length === 0) {
return <></>
}
return (
<TextField
fullWidth
select
value={null}
label="Admin User"
onChange={event => {
const user = users.find(user => user.id === event.target.value)
if (user) {
onPick(user)
}
event.preventDefault()
}}
>
{users.map(user => (
<MenuItem key={user.id} value={user.id}>
{user.id === meUser?.id
? t("routes.AdminRoute.forms.reservedAliases.fields.users.me", {
email: user.email.address,
})
: user.email.address}
</MenuItem>
))}
</TextField>
)
}

View File

@ -0,0 +1,86 @@
import {ReactElement} from "react"
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import update from "immutability-helper"
import {useMutation} from "@tanstack/react-query"
import {Switch} from "@mui/material"
import {useErrorSuccessSnacks} from "~/hooks"
import {ReservedAlias} from "~/server-types"
import {updateReservedAlias} from "~/apis"
import {queryClient} from "~/constants/react-query"
export interface AliasActivationSwitch {
id: string
isActive: boolean
queryKey: readonly string[]
}
export default function AliasActivationSwitch({
id,
isActive,
queryKey,
}: AliasActivationSwitch): ReactElement {
const {t} = useTranslation()
const {showError, showSuccess} = useErrorSuccessSnacks()
const {isLoading, mutateAsync} = useMutation<
ReservedAlias,
AxiosError,
boolean,
{previousAlias: ReservedAlias | undefined}
>(
activeNow =>
updateReservedAlias(id, {
isActive: activeNow,
}),
{
onMutate: async activeNow => {
await queryClient.cancelQueries(queryKey)
const previousAlias = queryClient.getQueryData<ReservedAlias>(queryKey)
queryClient.setQueryData<ReservedAlias>(queryKey, old =>
update(old, {
isActive: {
$set: activeNow!,
},
}),
)
return {previousAlias}
},
onSuccess: newAlias => {
queryClient.setQueryData<ReservedAlias>(queryKey, newAlias)
},
onError: (error, values, context) => {
showError(error)
if (context?.previousAlias) {
queryClient.setQueryData<ReservedAlias>(queryKey, context.previousAlias)
}
},
},
)
return (
<Switch
checked={isActive}
onChange={async () => {
if (isLoading) {
return
}
try {
await mutateAsync(!isActive)
showSuccess(
isActive
? t("relations.alias.mutations.success.aliasChangedToDisabled")
: t("relations.alias.mutations.success.aliasChangedToEnabled"),
)
} catch {}
}}
/>
)
}

View File

@ -0,0 +1,205 @@
import * as yup from "yup"
import {ReactElement, useState} from "react"
import {AxiosError} from "axios"
import {MdCheckCircle} from "react-icons/md"
import {FaPen} from "react-icons/fa"
import {useTranslation} from "react-i18next"
import {FieldArray, FormikProvider, useFormik} from "formik"
import {TiDelete} from "react-icons/ti"
import deepEqual from "deep-equal"
import update from "immutability-helper"
import {useMutation} from "@tanstack/react-query"
import {
Divider,
FormHelperText,
Grid,
IconButton,
List,
ListItem,
ListItemSecondaryAction,
ListItemText,
Typography,
} from "@mui/material"
import {ReservedAlias} from "~/server-types"
import {updateReservedAlias} from "~/apis"
import {parseFastAPIError} from "~/utils"
import {queryClient} from "~/constants/react-query"
import {useErrorSuccessSnacks} from "~/hooks"
import AdminUserPicker from "~/route-widgets/ReservedAliasDetailRoute/AdminUserPicker"
export interface AliasUsersListProps {
users: ReservedAlias["users"]
id: string
queryKey: readonly string[]
}
interface Form {
users: ReservedAlias["users"]
}
export default function AliasUsersList({users, queryKey, id}: AliasUsersListProps): ReactElement {
const {t} = useTranslation()
const {showError, showSuccess} = useErrorSuccessSnacks()
const {mutateAsync} = useMutation<
ReservedAlias,
AxiosError,
ReservedAlias["users"],
{previousAlias?: ReservedAlias}
>(
users =>
updateReservedAlias(id, {
users: users.map(user => ({
id: user.id,
})),
}),
{
onMutate: async users => {
await queryClient.cancelQueries(queryKey)
const previousAlias = queryClient.getQueryData<ReservedAlias>(queryKey)
queryClient.setQueryData<ReservedAlias>(queryKey, old =>
update(old, {
users: {
$set: users,
},
}),
)
return {
previousAlias,
}
},
onSuccess: async newAlias => {
showSuccess(t("relations.alias.mutations.success.aliasUpdated"))
await queryClient.cancelQueries(queryKey)
queryClient.setQueryData<ReservedAlias>(queryKey, newAlias as any as ReservedAlias)
},
onError: (error, _, context) => {
showError(error)
setIsInEditMode(true)
if (context?.previousAlias) {
queryClient.setQueryData<ReservedAlias>(queryKey, context.previousAlias)
}
},
},
)
const schema = yup.object().shape({
users: yup
.array()
.of(
yup.object().shape({
id: yup.string().required(),
email: yup.object().shape({
address: yup.string().required(),
id: yup.string().required(),
}),
}),
)
.label(t("routes.AliasDetailRoute.sections.users.fields.users.label")),
})
const initialValues: Form = {
users: users,
}
const formik = useFormik<Form>({
initialValues,
validationSchema: schema,
onSubmit: async (values, {setErrors}) => {
try {
await mutateAsync(values.users)
} catch (error) {
setErrors(parseFastAPIError(error as AxiosError))
}
},
})
const [isInEditMode, setIsInEditMode] = useState<boolean>(false)
return (
<Grid container direction="column" spacing={1}>
<Grid item>
<Grid container spacing={1} direction="row">
<Grid item>
<Typography variant="h6" component="h3">
{t("routes.ReservedAliasDetailRoute.sections.users.title")}
</Typography>
</Grid>
<Grid item>
<IconButton
size="small"
disabled={formik.isSubmitting}
onClick={async () => {
setIsInEditMode(!isInEditMode)
if (
isInEditMode &&
!deepEqual(initialValues, formik.values, {
strict: true,
})
) {
await formik.submitForm()
}
}}
>
{isInEditMode ? <MdCheckCircle /> : <FaPen />}
</IconButton>
</Grid>
</Grid>
</Grid>
<Grid item>
{isInEditMode ? (
<FormikProvider value={formik}>
<FieldArray
name="users"
render={arrayHelpers => (
<List>
{formik.values.users.map((user, index) => (
<ListItem key={user.id}>
<ListItemText primary={user.email.address} />
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="delete"
onClick={async () => {
arrayHelpers.remove(index)
}}
>
<TiDelete />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
<Divider />
<ListItem>
<AdminUserPicker
alreadyPicked={formik.values.users}
onPick={user => arrayHelpers.push(user)}
/>
</ListItem>
</List>
)}
/>
<FormHelperText
error={Boolean(formik.touched.users && formik.errors.users)}
>
{formik.touched.users && (formik.errors.users as string)}
</FormHelperText>
</FormikProvider>
) : (
<List>
{users.map(user => (
<ListItem key={user.id}>
<ListItemText primary={user.email.address} />
</ListItem>
))}
</List>
)}
</Grid>
</Grid>
)
}

View File

@ -0,0 +1,59 @@
import {ReactElement} from "react"
import {useTranslation} from "react-i18next"
import {useParams} from "react-router-dom"
import {AxiosError} from "axios"
import {useQuery} from "@tanstack/react-query"
import {Grid} from "@mui/material"
import {QueryResult, SimplePage, SimplePageBuilder} from "~/components"
import {ReservedAlias} from "~/server-types"
import {getReservedAlias} from "~/apis"
import AliasActivationSwitch from "~/route-widgets/ReservedAliasDetailRoute/AliasActivationSwitch"
import AliasAddress from "~/route-widgets/AliasDetailRoute/AliasAddress"
import AliasUsersList from "~/route-widgets/ReservedAliasDetailRoute/AliasUsersList"
export default function ReservedAliasDetailRoute(): ReactElement {
const {t} = useTranslation()
const params = useParams()
const queryKey = ["get_reserved_alias", params.id!]
const query = useQuery<ReservedAlias, AxiosError>(queryKey, () => getReservedAlias(params.id!))
return (
<SimplePage title={t("routes.ReservedAliasDetailRoute.title")}>
<QueryResult<ReservedAlias, AxiosError> query={query}>
{alias => (
<SimplePageBuilder.MultipleSections>
{[
<Grid
key="basic"
container
spacing={1}
direction="row"
alignItems="center"
>
<Grid item>
<AliasAddress address={`${alias.local}@${alias.domain}`} />
</Grid>
<Grid item>
<AliasActivationSwitch
id={alias.id}
isActive={alias.isActive}
queryKey={queryKey}
/>
</Grid>
</Grid>,
<AliasUsersList
key="users"
users={alias.users}
id={alias.id}
queryKey={queryKey}
/>,
]}
</SimplePageBuilder.MultipleSections>
)}
</QueryResult>
</SimplePage>
)
}

View File

@ -108,7 +108,10 @@ export interface ReservedAlias {
local: string
users: Array<{
id: string
email: string
email: {
address: string
id: string
}
}>
}

View File

@ -29,6 +29,10 @@ export default function parseFastAPIError(
return {detail: error.detail}
}
if (error.detail[0].loc[0] === "body" && error.detail[0].loc[1] === "__root__") {
return {detail: error.detail[0].msg}
}
return error.detail.reduce((acc, error) => {
const [location, field] = error.loc