diff --git a/app/controllers/collections_controller.ts b/app/controllers/collections_controller.ts index 7b1bddc..39d915c 100644 --- a/app/controllers/collections_controller.ts +++ b/app/controllers/collections_controller.ts @@ -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'); diff --git a/app/controllers/links_controller.ts b/app/controllers/links_controller.ts index 7a95aba..3e01614 100644 --- a/app/controllers/links_controller.ts +++ b/app/controllers/links_controller.ts @@ -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, diff --git a/app/exceptions/handler.ts b/app/exceptions/handler.ts index 1dff385..bd0082c 100644 --- a/app/exceptions/handler.ts +++ b/app/exceptions/handler.ts @@ -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 = { - '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); } diff --git a/app/validators/collection.ts b/app/validators/collection.ts index 0658cf4..2ce8389 100644 --- a/app/validators/collection.ts +++ b/app/validators/collection.ts @@ -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', }); diff --git a/inertia/components/common/dropdown/dropdown_item.tsx b/inertia/components/common/dropdown/dropdown_item.tsx index 0c1d64f..0a8f47f 100644 --- a/inertia/components/common/dropdown/dropdown_item.tsx +++ b/inertia/components/common/dropdown/dropdown_item.tsx @@ -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', diff --git a/inertia/components/common/form/textbox.tsx b/inertia/components/common/form/textbox.tsx index dd217c3..9f2cb6f 100644 --- a/inertia/components/common/form/textbox.tsx +++ b/inertia/components/common/form/textbox.tsx @@ -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, '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) => {error})} ); } diff --git a/inertia/components/dashboard/collection/collection_container.tsx b/inertia/components/dashboard/collection/collection_container.tsx index 60f2b0c..020eb07 100644 --- a/inertia/components/dashboard/collection/collection_container.tsx +++ b/inertia/components/dashboard/collection/collection_container.tsx @@ -63,7 +63,7 @@ export default function CollectionContainer({ )} - + diff --git a/inertia/components/dashboard/collection/header/collection_controls.tsx b/inertia/components/dashboard/collection/header/collection_controls.tsx index 9274e51..e78dbb5 100644 --- a/inertia/components/dashboard/collection/header/collection_controls.tsx +++ b/inertia/components/dashboard/collection/header/collection_controls.tsx @@ -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']; +}) => ( } svgSize={18}> Add - - Edit + + Edit - + Delete diff --git a/inertia/components/dashboard/link/link_controls.tsx b/inertia/components/dashboard/link/link_controls.tsx index 9ca5d7e..bf4db2c 100644 --- a/inertia/components/dashboard/link/link_controls.tsx +++ b/inertia/components/dashboard/link/link_controls.tsx @@ -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 }) { - Edit + Edit >; 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} /> diff --git a/inertia/components/layouts/dashboard_layout.tsx b/inertia/components/layouts/dashboard_layout.tsx index 784293b..98ca87b 100644 --- a/inertia/components/layouts/dashboard_layout.tsx +++ b/inertia/components/layouts/dashboard_layout.tsx @@ -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', })); diff --git a/inertia/pages/collections/edit.tsx b/inertia/pages/collections/edit.tsx new file mode 100644 index 0000000..4a23c4d --- /dev/null +++ b/inertia/pages/collections/edit.tsx @@ -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({ + name: collection.name, + description: collection.description, + visibility: collection.visibility, + }); + const canSubmit = useMemo(() => { + 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 ( + sent by the backend, but useForm expects a Record) + errors={errors as any} + /> + ); +} diff --git a/start/routes.ts b/start/routes.ts index 28d1433..4ed8908 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -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']); })