Skip to content

Commit

Permalink
feat: Dodanie formularza do aktualizacji danych bloga (#101)
Browse files Browse the repository at this point in the history
* Add endpoint to update blog data

* Code review

* Create form for updating blogs data

* Code review

* Code review

* Fix generic types

* Fix

* Code review

Co-authored-by: Michał Miszczyszyn <mmiszy@users.noreply.github.com>
  • Loading branch information
wisnie and Michał Miszczyszyn committed Jan 5, 2021
1 parent 76aa5f6 commit e17bdf0
Show file tree
Hide file tree
Showing 19 changed files with 532 additions and 106 deletions.
35 changes: 29 additions & 6 deletions api-helpers/api-hofs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ import { logger } from './logger';
type SomeSchema = Record<string, AnySchema<any, any, any>>;
type AllAllowedFields = 'body' | 'query';

export type HTTPMethod =
| 'GET'
| 'HEAD'
| 'POST'
| 'PUT'
| 'DELETE'
| 'CONNECT'
| 'OPTIONS'
| 'TRACE'
| 'PATCH';

export const withValidation = <
Body extends SomeSchema,
Query extends SomeSchema,
Expand All @@ -28,22 +39,19 @@ export const withValidation = <
) => {
const schemaObj = object(schema).required();

return (
return <R extends NextApiRequest>(
handler: (
req: Omit<NextApiRequest, AllAllowedFields> & InferType<typeof schemaObj>,
req: Omit<R, AllAllowedFields> & InferType<typeof schemaObj>,
res: NextApiResponse,
) => unknown,
) => async (req: NextApiRequest, res: NextApiResponse) => {
) => async (req: R, res: NextApiResponse) => {
try {
// eslint-disable-next-line no-var
var validatedValues = await schemaObj.validate(req, { abortEarly: false });
} catch (err) {
throw Boom.badRequest((err as Error | undefined)?.message, err);
}

Object.keys(validatedValues).forEach((key) => {
req[key as AllAllowedFields] = validatedValues[key as keyof typeof schema];
});
return handler(validatedValues as any, res);
};
};
Expand Down Expand Up @@ -105,3 +113,18 @@ export function withAuth<R extends NextApiRequest>(role?: UserRole) {
return handler({ session, ...req }, res);
};
}

export function withMethods(
methods: {
readonly [key in HTTPMethod]?: (req: NextApiRequest, res: NextApiResponse) => Promise<unknown>;
},
) {
return <R extends NextApiRequest>(req: R, res: NextApiResponse) => {
const reqMethod = req.method as HTTPMethod;
const handler = methods[reqMethod];
if (handler) {
return handler(req, res);
}
throw Boom.notFound();
};
}
6 changes: 2 additions & 4 deletions api-helpers/contentCreatorFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Cheerio from 'cheerio';
import Slugify from 'slugify';

import { closeConnection, openConnection } from './db';
import { isPrismaError } from './prisma-helper';
import { handlePrismaError } from './prisma-helper';

const NEVER = new Date(0);
const YOUTUBE_REGEX = /^(http(s)?:\/\/)?((w){3}.)?youtu(be|.be)?(\.com)?\/.+/;
Expand Down Expand Up @@ -38,9 +38,7 @@ export const addContentCreator = async (url: string, email: string) => {
},
});
} catch (err) {
if (isPrismaError(err) && err.code === 'P2002') {
throw Boom.conflict();
}
handlePrismaError(err);
throw Boom.isBoom(err) ? err : Boom.badRequest();
} finally {
await closeConnection();
Expand Down
19 changes: 19 additions & 0 deletions api-helpers/prisma-helper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import Boom from '@hapi/boom';

import { logger } from './logger';
import type { PrismaError } from './prisma-errors';

export function isPrismaError(err: any): err is PrismaError {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return Boolean(err && err.code) && /^P\d{4}$/.test(err.code);
}

export function handlePrismaError(err: unknown) {
if (!isPrismaError(err)) {
throw err;
}

switch (err.code) {
case 'P2001':
throw Boom.notFound();
case 'P2002':
throw Boom.conflict();
default:
logger.error(`Unhandled Prisma error: ${err.code}`);
break;
}
}
33 changes: 1 addition & 32 deletions components/AdminPanel/AdminPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,3 @@
import { signIn, useSession } from 'next-auth/client';
import { useEffect } from 'react';

import { LoadingScreen } from '../LoadingScreen/LoadingScreen';

import styles from './AdminPanel.module.css';

export const AdminPanel = () => {
const [session, isLoading] = useSession();

useEffect(() => {
if (!isLoading && !session) {
void signIn();
}
}, [session, isLoading]);

if (isLoading) {
return <LoadingScreen />;
}

if (session?.user.role === 'ADMIN') {
return <section>admin panel</section>; // @to-do admin-panel
}

return (
<section className={styles.section}>
<h2 className={styles.heading}>Brak uprawnień</h2>
<p className={styles.p}>
Nie masz odpowiednich uprawnień, żeby korzystać z tej podstrony. W celu weryfikacji
skontaktuj się z administracją serwisu.
</p>
</section>
);
return <section>admin panel</section>;
};
43 changes: 43 additions & 0 deletions components/AuthGuard/AuthGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { signIn, useSession } from 'next-auth/client';
import { useEffect } from 'react';

import { LoadingScreen } from '../LoadingScreen/LoadingScreen';

import styles from './authGuard.module.css';

type Props = {
readonly role?: 'admin';
};

export const AuthGuard: React.FC<Props> = ({ children, role }) => {
const [session, isLoading] = useSession();

useEffect(() => {
if (!isLoading && !session) {
void signIn();
}
}, [session, isLoading]);

if (isLoading) {
return <LoadingScreen />;
}

// Without role allow all authorized users
if (!role && session) {
return <>{children}</>;
}

if (role === 'admin' && session?.user.role === 'ADMIN') {
return <>{children}</>;
}

return (
<section className={styles.section}>
<h2 className={styles.heading}>Brak uprawnień</h2>
<p className={styles.p}>
Nie masz odpowiednich uprawnień, żeby korzystać z tej podstrony. W celu weryfikacji
skontaktuj się z administracją serwisu.
</p>
</section>
);
};
File renamed without changes.
131 changes: 131 additions & 0 deletions components/UpdateBlogSection/UpdateBlogForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import clsx from 'clsx';
import type { ChangeEventHandler, FormEvent } from 'react';
import { useRef, memo, useCallback, useEffect, useState } from 'react';

import { useMutation } from '../../hooks/useMutation';
import { useQuery } from '../../hooks/useQuery';
import type { BlogIdRequestBody } from '../../pages/api/blogs/[blogId]';
import { getBlog } from '../../utils/api/getBlog';
import { updateBlog } from '../../utils/api/updateBlog';
import { Button } from '../Button/Button';

import styles from './updateBlogForm.module.scss';

type Props = {
readonly blogId: string;
};

export const UpdateBlogForm = memo<Props>(({ blogId }) => {
const { mutate, status } = useMutation((body: BlogIdRequestBody) => updateBlog(blogId, body));
const { value: blog, status: queryStatus } = useQuery(
useCallback(() => getBlog(blogId), [blogId]),
);
const [fields, setFields] = useState<BlogIdRequestBody>({
href: '',
creatorEmail: null,
isPublic: false,
});
const [isValid, setIsValid] = useState(false);
const formRef = useRef<HTMLFormElement | null>(null);

useEffect(() => {
if (queryStatus === 'success' && blog) {
setFields(blog);
}
}, [queryStatus, blog]);

const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(({ currentTarget }) => {
setFields((fields) => ({ ...fields, [currentTarget.name]: currentTarget.value }));
setIsValid(formRef.current?.checkValidity() ?? false);
}, []);

const handleInputCheckboxChange: ChangeEventHandler<HTMLInputElement> = useCallback(
({ currentTarget }) => {
setFields((fields) => ({ ...fields, [currentTarget.name]: currentTarget.checked }));
},
[],
);

const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
void mutate(fields);
},
[mutate, fields],
);

if (!blog) {
return null;
}

const isLoading = status === 'loading';

return (
<form
onSubmit={handleSubmit}
className={clsx(styles.form, isLoading && styles.formLoading)}
ref={formRef}
>
<label className={styles.label}>
Nazwa bloga
<input className={styles.input} value={blog.name} type="text" name="name" readOnly />
</label>
<label className={styles.label}>
Href bloga
<input
className={styles.input}
value={fields.href}
name="href"
onChange={handleChange}
placeholder="Podaj href bloga"
required
type="url"
/>
</label>
<label className={styles.label}>
Rss bloga
<input className={styles.input} value={blog.rss} name="rss" type="url" readOnly />
</label>
<label className={styles.label}>
Slug bloga
<input className={styles.input} value={blog.slug ?? ''} readOnly name="slug" type="text" />
</label>
<label className={styles.label}>
Favicon bloga
<input
className={styles.input}
value={blog.favicon ?? ''}
name="favicon"
type="url"
readOnly
/>
</label>
<label className={styles.label}>
Email twórcy
<input
className={styles.input}
value={fields.creatorEmail ?? ''}
name="creatorEmail"
onChange={handleChange}
placeholder="Podaj email twórcy"
type="email"
/>
</label>
<label className={styles.labelCheckbox}>
<input
className={styles.inputCheckbox}
checked={fields.isPublic}
name="isPublic"
onChange={handleInputCheckboxChange}
type="checkbox"
required
/>
Czy blog ma być pokazany na stronie?
</label>
<Button type="submit" disabled={!isValid} className={styles.submitButton}>
Zapisz
</Button>
</form>
);
});
UpdateBlogForm.displayName = 'UpdateBlogForm';
18 changes: 18 additions & 0 deletions components/UpdateBlogSection/UpdateBlogSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { memo } from 'react';

import { UpdateBlogForm } from './UpdateBlogForm';
import styles from './updateBlogSection.module.css';

type Props = {
readonly blogId: string;
};

export const UpdateBlogSection = memo<Props>(({ blogId }) => {
return (
<section className={styles.section}>
<h2 className={styles.heading}>Formularz aktualizacji danych bloga</h2>
<UpdateBlogForm blogId={blogId} />
</section>
);
});
UpdateBlogSection.displayName = 'UpdateBlogSection';
48 changes: 48 additions & 0 deletions components/UpdateBlogSection/updateBlogForm.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
.form {
border-left: 3px solid var(--gray-dark-border);
margin-top: 1rem;
padding: 1.5rem;
text-align: left;
background: var(--white);
box-shadow: 1px 1px 6px 1px var(--gray-shadow);
border-radius: 3px;

@media screen and (min-width: 45em) {
padding: 2rem;
margin-top: 2rem;
}
}

.formLoading {
cursor: wait;
}

.label {
display: block;
margin: 1rem 0;
color: var(--black-lighter);

&Checkbox {
display: flex;
align-items: center;
}
}

.input {
width: 100%;
padding: 0.75rem 1rem;
margin: 0.25rem 0;
border-radius: 5px;
border: 1px solid var(--gray-dark-border);

&Checkbox {
width: 1.25rem;
height: 1.25rem;
display: flex;
margin-right: 0.75rem;
}
}

.submitButton {
margin-top: 1.75rem;
}
Loading

0 comments on commit e17bdf0

Please sign in to comment.