Post delete, Profile edit

This commit is contained in:
2024-07-27 00:54:23 +02:00
parent a515c447e0
commit 581cacb636
33 changed files with 533 additions and 238 deletions
+17 -1
View File
@@ -1,5 +1,5 @@
import { PostAuth, PostListAuth, PostListNonAuth } from '../types/Post';
import { User } from '../types/User';
import { User, UserUpdate } from '../types/User';
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
@@ -20,6 +20,7 @@ class ApiImpl {
}
public hasAuth = () => this.token !== undefined;
//FIXME: TESTING
public isAdmin = () => this.hasAuth() && this.self?.isAdmin;
public getAuthenticatedUser = () => this.self;
public getCurrentSession = () => [this.token, this.refreshToken];
@@ -57,6 +58,10 @@ class ApiImpl {
return await (await this.getAuth(`users/${id ?? this.self?.id}`)).json();
};
public updateUser = async (data: UserUpdate, id?: number): Promise<User> => {
return await (await this.patch(`users/${id ?? 'self'}`, data as Record<string, unknown>)).json();
};
/* Internal */
private post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
@@ -110,6 +115,17 @@ class ApiImpl {
if (response.ok) return response;
throw await response.json();
};
private patch = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors',
method: 'patch',
headers: { token: this.token ?? '', ...headers },
body: JSON.stringify(body),
});
if (response.ok) return response;
throw await response.json();
};
}
const Api = new ApiImpl();
@@ -0,0 +1,35 @@
import { Typography } from '@mui/material';
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { ERRORS } from './Errors';
interface Props {
//eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any;
context?: string;
}
const ErrorComponent: FC<Props> = ({ error, context }) => {
const { t } = useTranslation();
if (!error) return null;
if (error.code) {
switch (error.code) {
case ERRORS.NOT_FOUND:
return <Typography color="error.main">{t(error.code, { context: `${error.entity}:${context}` })}</Typography>;
case ERRORS.UNAUTHORIZED:
return <Typography color="error.main">{t(error.code, { context })}</Typography>;
case ERRORS.FAILEDUPDATE:
return error.fields.map((field: string) => (
<Typography key={`error_${field}`} color="error.main">
{t(error.code, { context, name: t(field) })}
</Typography>
));
}
}
return <Typography color="error.main">{t(error?.message ?? 'Unknown', { context })}</Typography>;
};
export default ErrorComponent;
@@ -0,0 +1,5 @@
export enum ERRORS {
NOT_FOUND = 'NotFound',
UNAUTHORIZED = 'Unauthorized',
FAILEDUPDATE = 'FailedUpdate',
}
@@ -1,10 +1,10 @@
import { Box, Button, TextField, Typography } from '@mui/material';
import { Box, Button, TextField } from '@mui/material';
import { useForm } from '@tanstack/react-form';
import { useRouter } from '@tanstack/react-router';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api';
import handleError from '../../../utils/errors';
import ErrorComponent from '../../Error/ErrorComponent';
interface Props {
handleClose: () => void;
@@ -119,7 +119,7 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
</>
)}
/>
{error && <Typography color="error.main">{handleError(error, t, 'login')}</Typography>}
{error && <ErrorComponent error={error} context="login" />}
</Box>
</form>
);
@@ -0,0 +1,160 @@
import {
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
useMediaQuery,
useTheme,
} from '@mui/material';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { FC, FormEvent, useState } from 'react';
import Api from '../../../api/Api';
import { User, UserUpdate } from '../../../types/User';
import ErrorComponent from '../../Error/ErrorComponent';
interface Props {
user: User;
open: boolean;
onClose: () => void;
}
const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const [error, setError] = useState<any>();
const updateMutation = useMutation({
mutationFn: ({ data, id }: { data: UserUpdate; id?: number }) => {
return Api.updateUser(data, id);
},
});
const form = useForm<UserUpdate>({
defaultValues: {
username: user.username,
},
onSubmit: async ({ value }) => {
try {
updateMutation.mutate(
{ data: value, id: Api.getAuthenticatedUser()?.id === user.id ? undefined : user.id },
{
onSuccess: () => {
handleClose();
const queryKey = Api.getAuthenticatedUser()?.id === user.id ? ['profile'] : ['profile', { id: user.id }];
queryClient.invalidateQueries({ queryKey });
},
onError: setError,
}
);
//eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
setError(error);
}
},
});
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.only('xs'), { noSsr: true });
const queryClient = useQueryClient();
const handleClose = () => {
form.reset();
onClose();
};
return (
<Dialog
open={open}
onClose={handleClose}
fullWidth
fullScreen={fullScreen}
PaperProps={{
component: 'form',
onSubmit: (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
},
onKeyDown: (event: React.KeyboardEvent<HTMLFormElement>) => {
if (event.key === 'Tab') {
event.stopPropagation();
}
},
noValidate: true,
}}
>
<DialogTitle>{t('Edit data')}</DialogTitle>
<DialogContent>
<form.Field
name="username"
validators={{
onChange: ({ value }) => (!value ? t('Username required') : undefined),
onChangeAsyncDebounceMs: 250,
onChangeAsync: async ({ value }) => {
return !value && t('Username required');
},
}}
children={(field) => {
return (
<>
<TextField
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
size="small"
label={t('Username')}
required
margin="dense"
autoComplete="new-username"
fullWidth
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
/>
</>
);
}}
/>
</DialogContent>
<DialogActions>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit]) => (
<>
<Button
variant="outlined"
onClick={() => {
handleClose();
}}
>
{t('Cancel')}
</Button>
<Button
type="submit"
disabled={!canSubmit || updateMutation.isPending}
autoFocus
variant="contained"
endIcon={updateMutation.isPending && <CircularProgress color="inherit" size="20px" />}
>
{t('Save')}
</Button>
</>
)}
/>
</DialogActions>
{error && (
<DialogContent>
<ErrorComponent error={error} context="userUpdate" />
</DialogContent>
)}
</Dialog>
);
};
export default UserEditDialog;
+14 -5
View File
@@ -16,27 +16,29 @@ import {
Snackbar,
Typography,
} from '@mui/material';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../api/Api';
import { PostAuth, PostNonAuth } from '../../types/Post';
import convertDate from '../../utils/date';
import handleError from '../../utils/errors';
import ErrorComponent from '../Error/ErrorComponent';
interface Props {
post: PostNonAuth | PostAuth;
}
const Post: FC<Props> = ({ post }) => {
const [open, setOpen] = useState(false);
const deleteMutation = useMutation({
mutationFn: (id: number) => {
return Api.deletePost(id);
},
});
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -104,7 +106,13 @@ const Post: FC<Props> = ({ post }) => {
variant="outlined"
color="error"
onClick={() => {
deleteMutation.mutate(post.id);
deleteMutation.mutate(post.id, {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['posts'],
});
},
});
setOpen(false);
}}
>
@@ -117,9 +125,10 @@ const Post: FC<Props> = ({ post }) => {
</CardActions>
<Snackbar open={deleteMutation.isError} autoHideDuration={2000} onClose={() => deleteMutation.reset()}>
<Alert severity="error" variant="filled" sx={{ width: '100%' }}>
{deleteMutation.isError && handleError(deleteMutation.error, t, 'delete')}
{deleteMutation.isError && <ErrorComponent error={deleteMutation.error} context="delete" />}
</Alert>
</Snackbar>
<Snackbar open={deleteMutation.isPending} message={t('Deleting')} />
</Card>
);
};
+12 -2
View File
@@ -1,9 +1,10 @@
import { Person } from '@mui/icons-material';
import { Avatar, Box, Button, Card, CardActions, CardContent, Grid, Typography } from '@mui/material';
import { FC } from 'react';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { User } from '../../types/User';
import convertDate from '../../utils/date';
import UserEditDialog from '../Forms/UserEdit/UserEditDialog';
interface Props {
user: User;
@@ -11,6 +12,8 @@ interface Props {
}
const Profile: FC<Props> = ({ user, canEdit }) => {
const [editOpen, setEditOpen] = useState(false);
const { t } = useTranslation();
return (
@@ -36,7 +39,14 @@ const Profile: FC<Props> = ({ user, canEdit }) => {
</Grid>
</Grid>
</CardContent>
<CardActions>{canEdit && <Button size="small">{t('Edit')}</Button>}</CardActions>
<CardActions>
{canEdit && (
<Button size="small" onClick={() => setEditOpen(true)}>
{t('Edit')}
</Button>
)}
</CardActions>
<UserEditDialog user={user} open={editOpen} onClose={() => setEditOpen(false)} />
</Card>
);
};
+8
View File
@@ -0,0 +1,8 @@
import { queryOptions } from '@tanstack/react-query';
import Api from '../api/Api';
export const postsQueryOptions = (page?: number) =>
queryOptions({
queryKey: ['posts', { page: page ?? 0, hasAuth: Api.hasAuth() }],
queryFn: () => Api.posts(page),
});
+13
View File
@@ -0,0 +1,13 @@
import { queryOptions } from '@tanstack/react-query';
import Api from '../api/Api';
export const profileSelfQueryOptions = queryOptions({
queryKey: ['profile'],
queryFn: () => Api.user(),
});
export const profileQueryOptions = (id?: number) =>
queryOptions({
queryKey: ['profile', { id }],
queryFn: () => Api.user(id),
});
+1 -1
View File
@@ -4,9 +4,9 @@ import { createRootRouteWithContext, ErrorRouteComponent, Outlet, redirect, useR
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import { useEffect } from 'react';
import Api from '../api/Api';
import { ERRORS } from '../components/Error/Errors';
import Header from '../components/Header/Header';
import { ROUTES } from '../types/Routes';
import { ERRORS } from '../utils/errors';
const Root = () => {
//TODO: REAUTH HERE
+2 -8
View File
@@ -1,17 +1,11 @@
import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import Api from '../api/Api';
import Post from '../components/Post/Post';
import { postsQueryOptions } from '../queries/postsQuery';
import { ROUTES } from '../types/Routes';
const postsQueryOptions = (page?: number) =>
queryOptions({
queryKey: ['posts', { page: page ?? 0, hasAuth: Api.hasAuth() }],
queryFn: () => Api.posts(page),
});
const Home = () => {
const { page } = Route.useSearch();
const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page));
+2 -7
View File
@@ -1,17 +1,12 @@
import { Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { t } from 'i18next';
import Api from '../../api/Api';
import Profile from '../../components/Profile/Profile';
import { profileQueryOptions } from '../../queries/profileQuery';
import { ROUTES } from '../../types/Routes';
const profileQueryOptions = (id?: number) =>
queryOptions({
queryKey: ['profile', { id }],
queryFn: () => Api.user(id),
});
const ProfilePage = () => {
const { id } = Route.useParams();
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions(id));
+4 -8
View File
@@ -1,18 +1,14 @@
import { Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { t } from 'i18next';
import Api from '../../api/Api';
import Profile from '../../components/Profile/Profile';
import { profileSelfQueryOptions } from '../../queries/profileQuery';
import { ROUTES } from '../../types/Routes';
const profileQueryOptions = queryOptions({
queryKey: ['profile'],
queryFn: () => Api.user(),
});
const ProfilePage = () => {
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions);
const { data: profileQuery, isFetching } = useSuspenseQuery(profileSelfQueryOptions);
return (
<>
@@ -23,7 +19,7 @@ const ProfilePage = () => {
};
export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(profileQueryOptions),
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(profileSelfQueryOptions),
beforeLoad: () => {
if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX });
},
+6
View File
@@ -10,3 +10,9 @@ export interface User {
memberSince: Timestamp;
postCount: number;
}
export interface UserUpdate {
username?: string;
email?: string;
password?: string;
}
-39
View File
@@ -1,39 +0,0 @@
import { TFunction } from 'i18next';
export enum ERRORS {
NOT_FOUND = 'NotFound',
UNAUTHORIZED = 'Unauthorized',
}
/**
* Return translated error
* @param error Error object
* @param context Optional context
* @param t Optional translation function, defautls to pass through
* @returns Translated error or inputs if t as unspecified
*/
const handleError = (
//eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any,
//eslint-disable-next-line @typescript-eslint/no-explicit-any
t: TFunction<'translation', undefined> | ((..._in: any) => any) = (..._in: any) => _in,
context?: string
): string => {
console.log(context);
if (!error) return t('', {});
if (error.code) {
switch (error.code) {
case ERRORS.NOT_FOUND:
return t(error.code, { context: `${error.entity}:${context}` });
case ERRORS.UNAUTHORIZED:
return t(error.code, { context });
default:
return t('Unknown', { context });
}
}
return t(error?.message ?? 'Unknown', { context });
};
export default handleError;