feat: create edit collection page

This commit is contained in:
Sonny
2024-05-14 23:57:54 +02:00
committed by Sonny
parent 6b327a5b1e
commit 6b4cfd9926
13 changed files with 180 additions and 38 deletions

View File

@@ -1,13 +1,16 @@
import PATHS from '#constants/paths';
import Collection from '#models/collection';
import User from '#models/user';
import { collectionValidator } from '#validators/collection';
import {
createCollectionValidator,
updateCollectionValidator,
} from '#validators/collection';
import type { HttpContext } from '@adonisjs/core/http';
export default class CollectionsController {
// Dashboard
async index({ auth, inertia, request, response }: HttpContext) {
const collections = await this.getCollectionByAuthorId(auth.user!.id);
const collections = await this.getCollectionsByAuthorId(auth.user!.id);
if (collections.length === 0) {
return response.redirect('/collections/create');
}
@@ -29,7 +32,7 @@ export default class CollectionsController {
// Create collection form
async showCreatePage({ inertia, auth }: HttpContext) {
const collections = await this.getCollectionByAuthorId(auth.user!.id);
const collections = await this.getCollectionsByAuthorId(auth.user!.id);
return inertia.render('collections/create', {
disableHomeLink: collections.length === 0,
});
@@ -37,7 +40,7 @@ export default class CollectionsController {
// Method called when creating a collection
async store({ request, response, auth }: HttpContext) {
const payload = await request.validateUsing(collectionValidator);
const payload = await request.validateUsing(createCollectionValidator);
const collection = await Collection.create({
...payload,
authorId: auth.user?.id!,
@@ -45,11 +48,54 @@ export default class CollectionsController {
return this.redirectToCollectionId(response, collection.id);
}
async getCollectionById(id: Collection['id']) {
return await Collection.find(id);
async showEditPage({ auth, request, inertia, response }: HttpContext) {
const collectionId = request.qs()?.collectionId;
if (!collectionId) {
return response.redirect('/dashboard');
}
const collection = await this.getCollectionById(
collectionId,
auth.user!.id
);
return inertia.render('collections/edit', {
collection,
});
}
async getCollectionByAuthorId(authorId: User['id']) {
/**
* Get collection by id.
*
* /!\ Only return private collection (create by the current user)
*/
async getCollectionById(id: Collection['id'], userId: User['id']) {
return await Collection.query()
.where('id', id)
.andWhere('author_id', userId)
.firstOrFail();
}
async update({ request, auth, response }: HttpContext) {
const { params, ...payload } = await request.validateUsing(
updateCollectionValidator
);
// Cant use validator (vinejs) custom rule 'cause its too generic,
// because we have to find a collection by identifier and
// check whether the current user is the author.
// https://vinejs.dev/docs/extend/custom_rules
await this.getCollectionById(params.id, auth.user!.id);
await Collection.updateOrCreate(
{
id: params.id,
},
payload
);
return this.redirectToCollectionId(response, params.id);
}
async getCollectionsByAuthorId(authorId: User['id']) {
return await Collection.query()
.where('author_id', authorId)
.preload('links');

View File

@@ -10,7 +10,7 @@ export default class LinksController {
async showCreatePage({ auth, inertia }: HttpContext) {
const collections =
await this.collectionsController.getCollectionByAuthorId(auth.user!.id);
await this.collectionsController.getCollectionsByAuthorId(auth.user!.id);
return inertia.render('links/create', { collections });
}
@@ -18,7 +18,10 @@ export default class LinksController {
const { collectionId, ...payload } =
await request.validateUsing(linkValidator);
await this.collectionsController.getCollectionById(collectionId);
await this.collectionsController.getCollectionById(
collectionId,
auth.user!.id
);
await Link.create({
...payload,
collectionId,

View File

@@ -1,6 +1,11 @@
import PATHS from '#constants/paths';
import { ExceptionHandler, HttpContext } from '@adonisjs/core/http';
import app from '@adonisjs/core/services/app';
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http';
import type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http';
import type {
StatusPageRange,
StatusPageRenderer,
} from '@adonisjs/core/types/http';
import { errors } from '@adonisjs/lucid';
export default class HttpExceptionHandler extends ExceptionHandler {
/**
@@ -21,8 +26,10 @@ export default class HttpExceptionHandler extends ExceptionHandler {
* to return the HTML contents to send as a response.
*/
protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {
'404': (error, { inertia }) => inertia.render('errors/not_found', { error }),
'500..599': (error, { inertia }) => inertia.render('errors/server_error', { error }),
'404': (error, { inertia }) =>
inertia.render('errors/not_found', { error }),
'500..599': (error, { inertia }) =>
inertia.render('errors/server_error', { error }),
};
/**
@@ -30,6 +37,9 @@ export default class HttpExceptionHandler extends ExceptionHandler {
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_ROW_NOT_FOUND) {
return ctx.response.redirect(PATHS.DASHBOARD);
}
return super.handle(error, ctx);
}

View File

@@ -1,16 +1,29 @@
import vine, { SimpleMessagesProvider } from '@vinejs/vine';
import { Visibility } from '../enums/visibility.js';
export const collectionValidator = vine.compile(
export const createCollectionValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(300).optional(),
description: vine.string().trim().maxLength(254).nullable(),
visibility: vine.enum(Visibility),
nextId: vine.string().optional(),
})
);
collectionValidator.messagesProvider = new SimpleMessagesProvider({
export const updateCollectionValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(254).nullable(),
visibility: vine.enum(Visibility),
nextId: vine.string().optional(),
params: vine.object({
id: vine.string().trim(),
}),
})
);
createCollectionValidator.messagesProvider = new SimpleMessagesProvider({
name: 'Collection name is required',
'visibility.required': 'Collection visibiliy is required',
});

View File

@@ -1,19 +1,19 @@
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
const DropdownItemBase = styled.div<{ danger?: boolean }>(
({ theme, danger }) => ({
fontSize: '14px',
whiteSpace: 'nowrap',
color: danger ? theme.colors.lightRed : theme.colors.primary,
padding: '8px 12px',
borderRadius: theme.border.radius,
const DropdownItemBase = styled('div', {
shouldForwardProp: (propName) => propName !== 'danger',
})<{ danger?: boolean }>(({ theme, danger }) => ({
fontSize: '14px',
whiteSpace: 'nowrap',
color: danger ? theme.colors.lightRed : theme.colors.primary,
padding: '8px 12px',
borderRadius: theme.border.radius,
'&:hover': {
backgroundColor: theme.colors.background,
},
})
);
'&:hover': {
backgroundColor: theme.colors.background,
},
}));
const DropdownItemButton = styled(DropdownItemBase)({
display: 'flex',

View File

@@ -1,12 +1,20 @@
import styled from '@emotion/styled';
import { ChangeEvent, InputHTMLAttributes, useState } from 'react';
import FormField from '~/components/common/form/_form_field';
import Input from '~/components/common/form/_input';
// TODO: create a global style variable (fontSize)
const InputLegend = styled.p(({ theme }) => ({
fontSize: '12px',
color: theme.colors.lightRed,
}));
interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
value?: string;
errors?: string[];
onChange?: (name: string, value: string) => void;
}
@@ -14,6 +22,7 @@ export default function TextBox({
name,
label,
value = '',
errors = [],
onChange,
required = false,
...props
@@ -39,6 +48,8 @@ export default function TextBox({
value={inputValue}
placeholder={props.placeholder ?? 'Type something...'}
/>
{errors.length > 0 &&
errors.map((error) => <InputLegend>{error}</InputLegend>)}
</FormField>
);
}

View File

@@ -63,7 +63,7 @@ export default function CollectionContainer({
</Link>
)}
<CollectionHeader />
<CollectionControls />
<CollectionControls collectionId={activeCollection.id} />
</CollectionHeaderWrapper>
<CollectionDescription />
<LinkList links={activeCollection.links} />

View File

@@ -1,20 +1,31 @@
import PATHS from '#constants/paths';
import type Collection from '#models/collection';
import { BsThreeDotsVertical } from 'react-icons/bs';
import { HiOutlinePencil } from 'react-icons/hi2';
import { GoPencil } from 'react-icons/go';
import { IoIosAddCircleOutline } from 'react-icons/io';
import { IoTrashOutline } from 'react-icons/io5';
import Dropdown from '~/components/common/dropdown/dropdown';
import { DropdownItemLink } from '~/components/common/dropdown/dropdown_item';
import { appendCollectionId } from '~/lib/navigation';
const CollectionControls = () => (
const CollectionControls = ({
collectionId,
}: {
collectionId: Collection['id'];
}) => (
<Dropdown label={<BsThreeDotsVertical />} svgSize={18}>
<DropdownItemLink href={PATHS.LINK.CREATE}>
<IoIosAddCircleOutline /> Add
</DropdownItemLink>
<DropdownItemLink href={PATHS.COLLECTION.EDIT}>
<HiOutlinePencil /> Edit
<DropdownItemLink
href={appendCollectionId(PATHS.COLLECTION.EDIT, collectionId)}
>
<GoPencil /> Edit
</DropdownItemLink>
<DropdownItemLink href={PATHS.COLLECTION.REMOVE} danger>
<DropdownItemLink
href={appendCollectionId(PATHS.COLLECTION.REMOVE, collectionId)}
danger
>
<IoTrashOutline /> Delete
</DropdownItemLink>
</Dropdown>

View File

@@ -5,7 +5,7 @@ import styled from '@emotion/styled';
import { useCallback } from 'react';
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
import { BsThreeDotsVertical } from 'react-icons/bs';
import { HiOutlinePencil } from 'react-icons/hi2';
import { GoPencil } from 'react-icons/go';
import { IoTrashOutline } from 'react-icons/io5';
import Dropdown from '~/components/common/dropdown/dropdown';
import {
@@ -82,7 +82,7 @@ export default function LinkControls({ link }: { link: Link }) {
<DropdownItemLink
href={appendCollectionId(PATHS.LINK.EDIT, link.collectionId)}
>
<HiOutlinePencil /> Edit
<GoPencil /> Edit
</DropdownItemLink>
<DropdownItemLink
href={appendCollectionId(PATHS.LINK.REMOVE, link.collectionId)}

View File

@@ -16,6 +16,7 @@ export default function FormCollection({
canSubmit,
disableHomeLink,
data,
errors,
setData,
handleSubmit,
@@ -24,6 +25,7 @@ export default function FormCollection({
canSubmit: boolean;
disableHomeLink?: boolean;
data: FormCollectionData;
errors?: Record<string, Array<string>>;
setData: (name: string, value: string) => void;
handleSubmit: () => void;
@@ -53,6 +55,7 @@ export default function FormCollection({
name="name"
onChange={setData}
value={data.name}
errors={errors?.name}
required
autoFocus
/>
@@ -62,13 +65,14 @@ export default function FormCollection({
name="description"
onChange={setData}
value={data.description ?? undefined}
errors={errors?.description}
/>
<FormField>
<label htmlFor="visibility">Public</label>
<input
type="checkbox"
onChange={handleOnCheck}
value={data.visibility}
checked={data.visibility === Visibility.PUBLIC}
id="visibility"
/>
</FormField>

View File

@@ -4,7 +4,7 @@ import BaseLayout from './_base_layout';
const DashboardLayoutStyle = styled.div(({ theme }) => ({
height: '100%',
width: theme.media.small_desktop,
width: theme.media.medium_desktop,
maxWidth: '100%',
padding: '0.75em 1em',
}));

View File

@@ -0,0 +1,41 @@
import type Collection from '#models/collection';
import { useForm } from '@inertiajs/react';
import { useMemo } from 'react';
import FormCollection, {
FormCollectionData,
} from '~/components/form/form_collection';
export default function EditCollectionPage({
collection,
}: {
collection: Collection;
}) {
const { data, setData, put, processing, errors } =
useForm<FormCollectionData>({
name: collection.name,
description: collection.description,
visibility: collection.visibility,
});
const canSubmit = useMemo<boolean>(() => {
const isFormEdited =
data.name !== collection.name ||
data.description !== collection.description ||
data.visibility !== collection.visibility;
const isFormValid = data.name !== '';
return isFormEdited && isFormValid && !processing;
}, [data, collection]);
const handleSubmit = () => put(`/collections/${collection.id}`);
return (
<FormCollection
title="Edit a collection"
canSubmit={canSubmit}
data={data}
setData={setData}
handleSubmit={handleSubmit}
// TODO: fix this, type mistmatch (Record<string, string[]> sent by the backend, but useForm expects a Record<string, string>)
errors={errors as any}
/>
);
}

View File

@@ -27,6 +27,9 @@ router
]);
router.post('/collections', [CollectionsController, 'store']);
router.get(PATHS.COLLECTION.EDIT, [CollectionsController, 'showEditPage']);
router.put('/collections/:id', [CollectionsController, 'update']);
router.get(PATHS.LINK.CREATE, [LinksController, 'showCreatePage']);
router.post('/links', [LinksController, 'store']);
})