Post Create/Edit

This commit is contained in:
2024-07-27 16:42:03 +02:00
parent 74fd55084f
commit 08731f8559
29 changed files with 632 additions and 161 deletions
+11 -2
View File
@@ -1,4 +1,5 @@
import { PostAuth, PostListAuth, PostListNonAuth } from '../types/Post';
import { POST_LIMIT } from '../constanst';
import { PostAuth, PostListAuth, PostListNonAuth, PostUpdate } from '../types/Post';
import { User, UserUpdate } from '../types/User';
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
@@ -43,7 +44,7 @@ class ApiImpl {
};
public posts = async (page?: number): Promise<PostListNonAuth | PostListAuth> => {
const url = `posts?p=${page ?? 0}&l=9`;
const url = `posts?p=${page ?? 0}&l=${POST_LIMIT}`;
if (this.token) return await (await this.getAuth(url)).json();
@@ -62,6 +63,14 @@ class ApiImpl {
return await (await this.patch(`users/${id ?? 'self'}`, data as Record<string, unknown>)).json();
};
public newPost = async (data: PostUpdate): Promise<PostAuth> => {
return await (await this.postAuth(`posts`, data as Record<string, unknown>)).json();
};
public updatePost = async (data: PostUpdate, id: number): Promise<PostAuth> => {
return await (await this.patch(`posts/${id}`, data as Record<string, unknown>)).json();
};
/* Internal */
private post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
@@ -0,0 +1,175 @@
import {
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
LinearProgress,
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 { PostAuth, PostUpdate } from '../../../types/Post';
import ErrorComponent from '../../Error/ErrorComponent';
interface Props {
post: PostAuth;
open: boolean;
onClose: () => void;
}
const PostEditDialog: FC<Props> = ({ post, open, onClose }) => {
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const [error, setError] = useState<any>();
const [characterCount, setCharacterCount] = useState(post.content.length);
const updateMutation = useMutation({
mutationFn: ({ data, id }: { data: PostUpdate; id: number }) => {
return Api.updatePost(data, id);
},
});
const form = useForm<PostUpdate>({
defaultValues: {
content: post.content.replaceAll('<br />', ''),
},
onSubmit: async ({ value }) => {
try {
updateMutation.mutate(
{ data: value, id: post.id },
{
onSuccess: () => {
handleClose();
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
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();
setError(undefined);
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 post')}</DialogTitle>
<DialogContent>
<form.Field
name="content"
validators={{
onChange: ({ value }) => (!value ? t('Content required') : undefined),
onChangeAsyncDebounceMs: 250,
onChangeAsync: async ({ value }) => {
return !value && t('Content required');
},
}}
children={(field) => {
return (
<>
<TextField
variant="outlined"
multiline
minRows={3}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
if (e.target.value.length <= 250) {
setCharacterCount(e.target.value.length);
field.handleChange(e.target.value);
}
}}
size="small"
label={t('Comment')}
required
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
autoComplete="off"
margin="dense"
fullWidth
/>
<LinearProgress
variant="determinate"
value={(characterCount / 250) * 100}
color={characterCount === 250 ? 'error' : characterCount >= 200 ? 'warning' : 'primary'}
/>
</>
);
}}
/>
</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="postUpdate" />
</DialogContent>
)}
</Dialog>
);
};
export default PostEditDialog;
@@ -66,6 +66,7 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
const handleClose = () => {
form.reset();
setError(undefined);
onClose();
};
@@ -13,6 +13,8 @@ interface Props {
const ErrorComponent: FC<Props> = ({ error, context, color = 'error.main' }) => {
const { t } = useTranslation();
console.log(error, context);
if (!error) return null;
if (error.code) {
@@ -21,12 +23,18 @@ const ErrorComponent: FC<Props> = ({ error, context, color = 'error.main' }) =>
return <Typography color={color}>{t(error.code, { context: `${error.entity}:${context}` })}</Typography>;
case ERRORS.UNAUTHORIZED:
return <Typography color={color}>{t(error.code, { context })}</Typography>;
case ERRORS.FAILEDUPDATE:
case ERRORS.FAILED_UPDATE:
return error.fields.map((field: string, index: number) => (
<Typography key={`error_${field}`} color={color}>
{t(error.code, { context: `${error.reasons[index]}:${field}:${context}` })}
</Typography>
));
case ERRORS.MISSING_FIELD:
return error.fields.map((field: string) => (
<Typography key={`error_${field}`} color={color}>
{t(error.code, { context: `${field}:${context}` })}
</Typography>
));
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
export enum ERRORS {
NOT_FOUND = 'NotFound',
UNAUTHORIZED = 'Unauthorized',
FAILEDUPDATE = 'FailedUpdate',
FAILED_UPDATE = 'FailedUpdate',
MISSING_FIELD = 'MissingField',
}
@@ -4,6 +4,7 @@ import { useRouter } from '@tanstack/react-router';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api';
import { Login } from '../../../types/User';
import ErrorComponent from '../../Error/ErrorComponent';
interface Props {
@@ -16,7 +17,7 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
const { t } = useTranslation();
const router = useRouter();
const form = useForm({
const form = useForm<Login>({
defaultValues: {
email: '',
password: '',
@@ -74,6 +75,7 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
type="email"
autoComplete="username"
inputMode="email"
fullWidth
/>
</>
);
@@ -104,6 +106,7 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
type="password"
autoComplete="password"
fullWidth
/>
</>
);
@@ -0,0 +1,125 @@
import { Box, Button, CircularProgress, LinearProgress, TextField } from '@mui/material';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api';
import { PostUpdate } from '../../../types/Post';
import ErrorComponent from '../../Error/ErrorComponent';
const PostForm: FC = () => {
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const [error, setError] = useState<any>();
const [characterCount, setCharacterCount] = useState(0);
const { t } = useTranslation();
const queryClient = useQueryClient();
const newMutation = useMutation({
mutationFn: ({ data }: { data: PostUpdate }) => {
return Api.newPost(data);
},
});
const form = useForm<PostUpdate>({
defaultValues: {
content: '',
},
onSubmit: async ({ value }) => {
try {
newMutation.mutate(
{ data: value },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
onError: setError,
}
);
//eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
setError(error);
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
onKeyDown={(event) => {
if (event.key === 'Tab') {
event.stopPropagation();
}
}}
noValidate
>
<Box sx={{ display: 'grid', gap: 2, padding: 1 }}>
<form.Field
name="content"
validators={{
onChange: ({ value }) => (!value ? t('Content required') : undefined),
onChangeAsyncDebounceMs: 250,
onChangeAsync: async ({ value }) => {
return !value && t('Content required');
},
}}
children={(field) => {
return (
<>
<TextField
variant="outlined"
multiline
minRows={3}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
if (e.target.value.length <= 250) {
setCharacterCount(e.target.value.length);
field.handleChange(e.target.value);
}
}}
size="small"
label={t('Comment')}
required
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
autoComplete="off"
fullWidth
/>
<LinearProgress
variant="determinate"
value={(characterCount / 250) * 100}
color={characterCount === 250 ? 'error' : characterCount >= 200 ? 'warning' : 'primary'}
/>
</>
);
}}
/>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit]) => (
<>
<Button
type="submit"
disabled={!canSubmit || newMutation.isPending}
variant="contained"
endIcon={newMutation.isPending && <CircularProgress color="inherit" size="20px" />}
>
{t('Post comment')}
</Button>
</>
)}
/>
{error && <ErrorComponent error={error} context="newPost" />}
</Box>
</form>
);
};
export default PostForm;
+18 -6
View File
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next';
import Api from '../../api/Api';
import { PostAuth, PostNonAuth } from '../../types/Post';
import convertDate from '../../utils/date';
import PostEditDialog from '../Dialogs/PostEdit/PostEditDialog';
import ErrorComponent from '../Error/ErrorComponent';
interface Props {
@@ -30,7 +31,8 @@ interface Props {
}
const Post: FC<Props> = ({ post }) => {
const [open, setOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const [error, setError] = useState<any>();
@@ -86,22 +88,32 @@ const Post: FC<Props> = ({ post }) => {
subheader={convertDate(post.postedAt)}
/>
<CardContent>
<Typography>{post.content}</Typography>
<Typography>
<span dangerouslySetInnerHTML={{ __html: post.content }} />
</Typography>
</CardContent>
<CardActions>
{(Api.isAdmin() || ('id' in post.user && post.user.id === Api.getAuthenticatedUser()?.id)) && (
<>
<Button size="small" onClick={() => setEditOpen(true)}>
{t('Edit')}
</Button>
<PostEditDialog post={post as PostAuth} open={editOpen} onClose={() => setEditOpen(false)} />
</>
)}
{Api.isAdmin() && (
<>
<Button size="small" color="error" onClick={() => setOpen(true)}>
<Button size="small" color="error" onClick={() => setDeleteOpen(true)}>
{t('Delete')}
</Button>
<Dialog open={open} onClose={() => setOpen(false)}>
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
<DialogTitle>{t('Confirm post delete title')}</DialogTitle>
<DialogContent>
<DialogContentText>{t('Confirm post delete body', { name: post.user.username })}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} autoFocus variant="contained">
<Button onClick={() => setDeleteOpen(false)} autoFocus variant="contained">
{t('No')}
</Button>
<Button
@@ -116,7 +128,7 @@ const Post: FC<Props> = ({ post }) => {
},
onError: setError,
});
setOpen(false);
setDeleteOpen(false);
}}
>
{t('Yes')}
@@ -4,7 +4,7 @@ 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';
import UserEditDialog from '../Dialogs/UserEdit/UserEditDialog';
interface Props {
user: User;
+1
View File
@@ -0,0 +1 @@
export const POST_LIMIT = 15;
+11 -1
View File
@@ -1,7 +1,9 @@
import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
import { Divider, Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import Api from '../api/Api';
import PostForm from '../components/Forms/Post/PostForm';
import Post from '../components/Post/Post';
import { postsQueryOptions } from '../queries/postsQuery';
import { ROUTES } from '../types/Routes';
@@ -20,6 +22,14 @@ const Home = () => {
<Post post={post} />
</Grid>
))}
<Grid item xs={12}>
<Divider variant="middle" />
</Grid>
{Api.hasAuth() && (
<Grid item xs={12}>
<PostForm />
</Grid>
)}
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination
page={(page ?? 0) + 1}
+4
View File
@@ -27,3 +27,7 @@ export interface PostListAuth {
pages: number;
data: PostNonAuth[];
}
export interface PostUpdate {
content?: string;
}
+5
View File
@@ -16,3 +16,8 @@ export interface UserUpdate {
email?: string;
password?: string;
}
export interface Login {
email: string;
password: string;
}