From 3c2c5dcee6d9fc42b9b2c4e2528fda1beee8d8fb Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 18 May 2024 00:55:27 +0200 Subject: [PATCH] feat: add create/edit link form + controller methods --- app/controllers/collections_controller.ts | 24 ++-- app/controllers/links_controller.ts | 71 +++++++++++- app/validators/link.ts | 26 ++++- inertia/components/common/form/_button.tsx | 2 +- inertia/components/common/form/_form.tsx | 2 +- .../common/form/_form_field_error.tsx | 9 ++ inertia/components/common/form/checkbox.tsx | 55 +++++++++ inertia/components/common/form/textbox.tsx | 10 +- .../common/modal/_modal_container.tsx | 3 +- .../common/modal/_modal_wrapper.tsx | 1 + .../dashboard/link/link_controls.tsx | 24 ++-- .../components/dashboard/link/link_item.tsx | 2 +- inertia/components/form/form_collection.tsx | 54 +++++---- inertia/components/form/form_link.tsx | 105 ++++++++++++++++++ inertia/components/lang_selector.tsx | 4 +- inertia/i18n/locales/en/common.json | 2 +- inertia/i18n/locales/fr/common.json | 2 +- inertia/lib/navigation.ts | 6 + inertia/pages/collections/create.tsx | 10 +- inertia/pages/collections/edit.tsx | 10 +- inertia/pages/links/create.tsx | 81 +++----------- inertia/pages/links/edit.tsx | 57 ++++++++++ start/routes/link.ts | 8 +- 23 files changed, 421 insertions(+), 147 deletions(-) create mode 100644 inertia/components/common/form/_form_field_error.tsx create mode 100644 inertia/components/common/form/checkbox.tsx create mode 100644 inertia/components/form/form_link.tsx create mode 100644 inertia/pages/links/edit.tsx diff --git a/app/controllers/collections_controller.ts b/app/controllers/collections_controller.ts index 3db16a8..69ec78d 100644 --- a/app/controllers/collections_controller.ts +++ b/app/controllers/collections_controller.ts @@ -62,18 +62,6 @@ export default class CollectionsController { }); } - /** - * 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 @@ -94,6 +82,18 @@ export default class CollectionsController { return this.redirectToCollectionId(response, params.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 getCollectionsByAuthorId(authorId: User['id']) { return await Collection.query() .where('author_id', authorId) diff --git a/app/controllers/links_controller.ts b/app/controllers/links_controller.ts index 3e01614..b448521 100644 --- a/app/controllers/links_controller.ts +++ b/app/controllers/links_controller.ts @@ -1,6 +1,10 @@ import CollectionsController from '#controllers/collections_controller'; import Link from '#models/link'; -import { linkValidator } from '#validators/link'; +import { + createLinkValidator, + updateLinkFavoriteStatusValidator, + updateLinkValidator, +} from '#validators/link'; import { inject } from '@adonisjs/core'; import type { HttpContext } from '@adonisjs/core/http'; @@ -16,7 +20,7 @@ export default class LinksController { async store({ auth, request, response }: HttpContext) { const { collectionId, ...payload } = - await request.validateUsing(linkValidator); + await request.validateUsing(createLinkValidator); await this.collectionsController.getCollectionById( collectionId, @@ -32,4 +36,67 @@ export default class LinksController { collectionId ); } + + async showEditPage({ auth, inertia, request, response }: HttpContext) { + const linkId = request.qs()?.linkId; + if (!linkId) { + return response.redirectToNamedRoute('dashboard'); + } + + const userId = auth.user!.id; + const collections = + await this.collectionsController.getCollectionsByAuthorId(userId); + const link = await this.getLinkById(linkId, userId); + + return inertia.render('links/edit', { collections, link }); + } + + async update({ request, auth, response }: HttpContext) { + const { params, ...payload } = + await request.validateUsing(updateLinkValidator); + + // Throw if invalid link id provided + await this.getLinkById(params.id, auth.user!.id); + + await Link.updateOrCreate( + { + id: params.id, + }, + payload + ); + + return response.redirectToNamedRoute('dashboard', { + qs: { collectionId: payload.collectionId }, + }); + } + + async toggleFavorite({ request, auth, response }: HttpContext) { + const { params, favorite } = await request.validateUsing( + updateLinkFavoriteStatusValidator + ); + + // Throw if invalid link id provided + await this.getLinkById(params.id, auth.user!.id); + + await Link.updateOrCreate( + { + id: params.id, + }, + { favorite } + ); + + return response.json({ status: 'ok' }); + } + + /** + * Get link by id. + * + * /!\ Only return private link (create by the current user) + */ + private async getLinkById(id: Link['id'], userId: Link['id']) { + return await Link.query() + .where('id', id) + .andWhere('author_id', userId) + .firstOrFail(); + } } diff --git a/app/validators/link.ts b/app/validators/link.ts index d2558cb..4048602 100644 --- a/app/validators/link.ts +++ b/app/validators/link.ts @@ -1,6 +1,6 @@ import vine from '@vinejs/vine'; -export const linkValidator = vine.compile( +export const createLinkValidator = vine.compile( vine.object({ name: vine.string().trim().minLength(1).maxLength(254), description: vine.string().trim().maxLength(300).optional(), @@ -9,3 +9,27 @@ export const linkValidator = vine.compile( collectionId: vine.string().trim(), }) ); + +export const updateLinkValidator = vine.compile( + vine.object({ + name: vine.string().trim().minLength(1).maxLength(254), + description: vine.string().trim().maxLength(300).optional(), + url: vine.string().trim(), + favorite: vine.boolean(), + collectionId: vine.string().trim(), + + params: vine.object({ + id: vine.string().trim(), + }), + }) +); + +export const updateLinkFavoriteStatusValidator = vine.compile( + vine.object({ + favorite: vine.boolean(), + + params: vine.object({ + id: vine.string().trim(), + }), + }) +); diff --git a/inertia/components/common/form/_button.tsx b/inertia/components/common/form/_button.tsx index 1856016..566ab90 100644 --- a/inertia/components/common/form/_button.tsx +++ b/inertia/components/common/form/_button.tsx @@ -14,7 +14,7 @@ const Button = styled.button(({ theme }) => ({ '&:disabled': { cursor: 'not-allowed', - opacity: '0.75', + opacity: '0.5', }, '&:not(:disabled):hover': { diff --git a/inertia/components/common/form/_form.tsx b/inertia/components/common/form/_form.tsx index 33fc5c8..65e51f4 100644 --- a/inertia/components/common/form/_form.tsx +++ b/inertia/components/common/form/_form.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; const Form = styled.form({ width: '100%', display: 'flex', - gap: '0.5em', + gap: '1em', flexDirection: 'column', }); diff --git a/inertia/components/common/form/_form_field_error.tsx b/inertia/components/common/form/_form_field_error.tsx new file mode 100644 index 0000000..3f1d661 --- /dev/null +++ b/inertia/components/common/form/_form_field_error.tsx @@ -0,0 +1,9 @@ +import styled from '@emotion/styled'; + +// TODO: create a global style variable (fontSize) +const FormFieldError = styled.p(({ theme }) => ({ + fontSize: '12px', + color: theme.colors.lightRed, +})); + +export default FormFieldError; diff --git a/inertia/components/common/form/checkbox.tsx b/inertia/components/common/form/checkbox.tsx new file mode 100644 index 0000000..2d8c77b --- /dev/null +++ b/inertia/components/common/form/checkbox.tsx @@ -0,0 +1,55 @@ +import { ChangeEvent, Fragment, InputHTMLAttributes, useState } from 'react'; +import Toggle from 'react-toggle'; +import FormField from '~/components/common/form/_form_field'; +import FormFieldError from '~/components/common/form/_form_field_error'; + +interface InputProps + extends Omit, 'onChange'> { + label: string; + name: string; + checked: boolean; + errors?: string[]; + onChange?: (name: string, checked: boolean) => void; +} + +export default function Checkbox({ + name, + label, + checked = false, + errors = [], + onChange, + required = false, + ...props +}: InputProps): JSX.Element { + const [checkboxChecked, setCheckboxChecked] = useState(checked); + + if (typeof window === 'undefined') return ; + + function _onChange({ target }: ChangeEvent) { + setCheckboxChecked(target.checked); + if (onChange) { + onChange(target.name, target.checked); + } + } + + return ( + + + + {errors.length > 0 && + errors.map((error) => {error})} + + ); +} diff --git a/inertia/components/common/form/textbox.tsx b/inertia/components/common/form/textbox.tsx index 9f2cb6f..7918049 100644 --- a/inertia/components/common/form/textbox.tsx +++ b/inertia/components/common/form/textbox.tsx @@ -1,14 +1,8 @@ -import styled from '@emotion/styled'; import { ChangeEvent, InputHTMLAttributes, useState } from 'react'; import FormField from '~/components/common/form/_form_field'; +import FormFieldError from '~/components/common/form/_form_field_error'; 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; @@ -49,7 +43,7 @@ export default function TextBox({ placeholder={props.placeholder ?? 'Type something...'} /> {errors.length > 0 && - errors.map((error) => {error})} + errors.map((error) => {error})} ); } diff --git a/inertia/components/common/modal/_modal_container.tsx b/inertia/components/common/modal/_modal_container.tsx index 2cc9f6a..afd3148 100644 --- a/inertia/components/common/modal/_modal_container.tsx +++ b/inertia/components/common/modal/_modal_container.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; +import TransitionLayout from '~/components/layouts/_transition_layout'; -const ModalContainer = styled.div(({ theme }) => ({ +const ModalContainer = styled(TransitionLayout)(({ theme }) => ({ minWidth: '500px', background: theme.colors.secondary, padding: '1em', diff --git a/inertia/components/common/modal/_modal_wrapper.tsx b/inertia/components/common/modal/_modal_wrapper.tsx index d01e15a..5006b1c 100644 --- a/inertia/components/common/modal/_modal_wrapper.tsx +++ b/inertia/components/common/modal/_modal_wrapper.tsx @@ -13,6 +13,7 @@ const ModalWrapper = styled.div(({ theme }) => ({ display: 'flex', alignItems: 'center', flexDirection: 'column', + transition: theme.transition.delay, })); export default ModalWrapper; diff --git a/inertia/components/dashboard/link/link_controls.tsx b/inertia/components/dashboard/link/link_controls.tsx index 5b8fa3a..c097720 100644 --- a/inertia/components/dashboard/link/link_controls.tsx +++ b/inertia/components/dashboard/link/link_controls.tsx @@ -13,8 +13,9 @@ import { DropdownItemButton, DropdownItemLink, } from '~/components/common/dropdown/dropdown_item'; +import useActiveCollection from '~/hooks/use_active_collection'; import useCollections from '~/hooks/use_collections'; -import { appendCollectionId } from '~/lib/navigation'; +import { appendLinkId } from '~/lib/navigation'; import { makeRequest } from '~/lib/request'; const StartItem = styled(DropdownItemButton)(({ theme }) => ({ @@ -25,6 +26,7 @@ export default function LinkControls({ link }: { link: Link }) { const theme = useTheme(); const { t } = useTranslation('common'); const { collections, setCollections } = useCollections(); + const { setActiveCollection } = useActiveCollection(); const toggleFavorite = useCallback( (linkId: Link['id']) => { @@ -45,22 +47,20 @@ export default function LinkControls({ link }: { link: Link }) { }; setCollections(collectionsCopy); + setActiveCollection(collectionsCopy[collectionIndex]); }, [collections, setCollections] ); const onFavorite = () => { - const editRoute = route('link.edit', { + const { url, method } = route('link.toggle-favorite', { params: { id: link.id }, }); makeRequest({ - url: editRoute.url, - method: editRoute.method, + url, + method, body: { - name: link.name, - url: link.url, favorite: !link.favorite, - collectionId: link.collectionId, }, }) .then(() => toggleFavorite(link.id)) @@ -85,18 +85,12 @@ export default function LinkControls({ link }: { link: Link }) { )} {t('link.edit')} {t('link.delete')} diff --git a/inertia/components/dashboard/link/link_item.tsx b/inertia/components/dashboard/link/link_item.tsx index 3d67499..5b6eacc 100644 --- a/inertia/components/dashboard/link/link_item.tsx +++ b/inertia/components/dashboard/link/link_item.tsx @@ -2,8 +2,8 @@ import type Link from '#models/link'; import styled from '@emotion/styled'; import { AiFillStar } from 'react-icons/ai'; import ExternalLink from '~/components/common/external_link'; -import LinkFavicon from '~/components/dashboard/link/link_favicon'; import LinkControls from '~/components/dashboard/link/link_controls'; +import LinkFavicon from '~/components/dashboard/link/link_favicon'; const LinkWrapper = styled.li(({ theme }) => ({ userSelect: 'none', diff --git a/inertia/components/form/form_collection.tsx b/inertia/components/form/form_collection.tsx index 1239380..4bfc50a 100644 --- a/inertia/components/form/form_collection.tsx +++ b/inertia/components/form/form_collection.tsx @@ -1,10 +1,10 @@ -import { ChangeEvent, FormEvent } from 'react'; -import FormField from '~/components/common/form/_form_field'; +import { FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import Checkbox from '~/components/common/form/checkbox'; import TextBox from '~/components/common/form/textbox'; import BackToDashboard from '~/components/common/navigation/back_to_dashboard'; import FormLayout from '~/components/layouts/form_layout'; import { Visibility } from '../../../app/enums/visibility'; -import { useTranslation } from 'react-i18next'; export type FormCollectionData = { name: string; @@ -12,6 +12,17 @@ export type FormCollectionData = { visibility: Visibility; }; +interface FormCollectionProps { + title: string; + canSubmit: boolean; + disableHomeLink?: boolean; + data: FormCollectionData; + errors?: Record>; + + setData: (name: string, value: any) => void; + handleSubmit: () => void; +} + export default function FormCollection({ title, canSubmit, @@ -21,22 +32,10 @@ export default function FormCollection({ setData, handleSubmit, -}: { - title: string; - canSubmit: boolean; - disableHomeLink?: boolean; - data: FormCollectionData; - errors?: Record>; - - setData: (name: string, value: string) => void; - handleSubmit: () => void; -}) { +}: FormCollectionProps) { const { t } = useTranslation('common'); - const handleOnCheck = ({ target }: ChangeEvent) => - setData( - 'visibility', - target.checked ? Visibility.PUBLIC : Visibility.PRIVATE - ); + const handleOnCheck: FormCollectionProps['setData'] = (name, value) => + setData(name, value ? Visibility.PUBLIC : Visibility.PRIVATE); const onSubmit = (event: FormEvent) => { event.preventDefault(); @@ -62,22 +61,19 @@ export default function FormCollection({ autoFocus /> - - - - + ); diff --git a/inertia/components/form/form_link.tsx b/inertia/components/form/form_link.tsx new file mode 100644 index 0000000..78ddeee --- /dev/null +++ b/inertia/components/form/form_link.tsx @@ -0,0 +1,105 @@ +import type Collection from '#models/collection'; +import { FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import Checkbox from '~/components/common/form/checkbox'; +import TextBox from '~/components/common/form/textbox'; +import BackToDashboard from '~/components/common/navigation/back_to_dashboard'; +import FormLayout from '~/components/layouts/form_layout'; +import useSearchParam from '~/hooks/use_search_param'; + +export type FormLinkData = { + name: string; + description: string | null; + url: string; + favorite: boolean; + collectionId: Collection['id']; +}; + +interface FormLinkProps { + title: string; + canSubmit: boolean; + disableHomeLink?: boolean; + data: FormLinkData; + errors?: Record>; + collections: Collection[]; + + setData: (name: string, value: any) => void; + handleSubmit: () => void; +} + +export default function FormLink({ + title, + canSubmit, + disableHomeLink, + data, + errors, + collections, + + setData, + handleSubmit, +}: FormLinkProps) { + const { t } = useTranslation('common'); + const collectionId = useSearchParam('collectionId') ?? collections[0].id; + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + handleSubmit(); + }; + + return ( + + + + + + + + + + ); +} diff --git a/inertia/components/lang_selector.tsx b/inertia/components/lang_selector.tsx index fbdb1ca..9918a81 100644 --- a/inertia/components/lang_selector.tsx +++ b/inertia/components/lang_selector.tsx @@ -21,7 +21,9 @@ export default function LangSelector() { defaultValue={i18n.language} > {languages.map((lang) => ( - + ))} ); diff --git a/inertia/i18n/locales/en/common.json b/inertia/i18n/locales/en/common.json index aed1f13..ff4de33 100644 --- a/inertia/i18n/locales/en/common.json +++ b/inertia/i18n/locales/en/common.json @@ -7,7 +7,7 @@ "login": "Login", "link": { "links": "Links", - "link": "Link", + "link": "Link URL", "name": "Link name", "description": "Link description", "create": "Create a link", diff --git a/inertia/i18n/locales/fr/common.json b/inertia/i18n/locales/fr/common.json index cac13d4..2cfd0c5 100644 --- a/inertia/i18n/locales/fr/common.json +++ b/inertia/i18n/locales/fr/common.json @@ -7,7 +7,7 @@ "login": "Connexion", "link": { "links": "Liens", - "link": "Lien", + "link": "URL du lien", "name": "Nom du lien", "description": "Description du lien", "create": "Créer un lien", diff --git a/inertia/lib/navigation.ts b/inertia/lib/navigation.ts index f2ea33c..b56dbee 100644 --- a/inertia/lib/navigation.ts +++ b/inertia/lib/navigation.ts @@ -1,10 +1,16 @@ import type Collection from '#models/collection'; +import type Link from '#models/link'; export const appendCollectionId = ( url: string, collectionId?: Collection['id'] | null | undefined ) => `${url}${collectionId ? `?collectionId=${collectionId}` : ''}`; +export const appendLinkId = ( + url: string, + linkId?: Link['id'] | null | undefined +) => `${url}${linkId ? `?linkId=${linkId}` : ''}`; + export const appendResourceId = (url: string, resourceId?: string) => `${url}${resourceId ? `/${resourceId}` : ''}`; diff --git a/inertia/pages/collections/create.tsx b/inertia/pages/collections/create.tsx index e8a126c..96c3a78 100644 --- a/inertia/pages/collections/create.tsx +++ b/inertia/pages/collections/create.tsx @@ -1,10 +1,11 @@ import { useForm } from '@inertiajs/react'; +import { route } from '@izzyjs/route/client'; import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import FormCollection, { FormCollectionData, } from '~/components/form/form_collection'; import { Visibility } from '../../../app/enums/visibility'; -import { useTranslation } from 'react-i18next'; export default function CreateCollectionPage({ disableHomeLink, @@ -12,7 +13,7 @@ export default function CreateCollectionPage({ disableHomeLink: boolean; }) { const { t } = useTranslation('common'); - const { data, setData, post, processing } = useForm({ + const { data, setData, submit, processing } = useForm({ name: '', description: '', visibility: Visibility.PRIVATE, @@ -22,7 +23,10 @@ export default function CreateCollectionPage({ [processing, data] ); - const handleSubmit = () => post('/collections'); + const handleSubmit = () => { + const { method, url } = route('collection.create'); + submit(method, url); + }; return ( ({ name: collection.name, description: collection.description, @@ -25,7 +26,12 @@ export default function EditCollectionPage({ return isFormEdited && isFormValid && !processing; }, [data, collection]); - const handleSubmit = () => put(`/collections/${collection.id}`); + const handleSubmit = () => { + const { method, url } = route('collection.edit', { + params: { id: collection.id }, + }); + submit(method, url); + }; return ( ( () => data.name !== '' && @@ -34,67 +31,19 @@ export default function CreateLinkPage({ [data, processing] ); - const handleOnCheck = ({ target }: ChangeEvent) => { - setData('favorite', !!target.checked); - }; - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - post('/links'); + const handleSubmit = () => { + const { method, url } = route('link.create'); + submit(method, url); }; return ( - - - - - - - - - - - - + data={data} + setData={setData} + handleSubmit={handleSubmit} + collections={collections} + /> ); } diff --git a/inertia/pages/links/edit.tsx b/inertia/pages/links/edit.tsx new file mode 100644 index 0000000..1f96cf8 --- /dev/null +++ b/inertia/pages/links/edit.tsx @@ -0,0 +1,57 @@ +import type Collection from '#models/collection'; +import type Link from '#models/link'; +import { useForm } from '@inertiajs/react'; +import { route } from '@izzyjs/route/client'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import FormLink from '~/components/form/form_link'; +import { isValidHttpUrl } from '~/lib/navigation'; + +export default function EditLinkPage({ + collections, + link, +}: { + collections: Collection[]; + link: Link; +}) { + const { t } = useTranslation('common'); + const { data, setData, submit, processing } = useForm({ + name: link.name, + description: link.description, + url: link.url, + favorite: link.favorite, + collectionId: link.collectionId, + }); + const canSubmit = useMemo(() => { + const isFormEdited = + data.name !== link.name || + data.url !== link.url || + data.description !== link.description || + data.favorite !== link.favorite || + data.collectionId !== link.collectionId; + + const isFormValid = + data.name !== '' && + isValidHttpUrl(data.url) && + data.favorite !== null && + data.collectionId !== null; + + return isFormEdited && isFormValid && !processing; + }, [data, processing]); + + const handleSubmit = () => { + const { method, url } = route('link.edit', { params: { id: link.id } }); + submit(method, url); + }; + + return ( + + ); +} diff --git a/start/routes/link.ts b/start/routes/link.ts index 859bafa..7ce5cd8 100644 --- a/start/routes/link.ts +++ b/start/routes/link.ts @@ -12,8 +12,12 @@ router .as('link.create-form'); router.post('/', [LinksController, 'store']).as('link.create'); - router.get('/edit', () => 'edit form').as('link.edit-form'); - router.put('/:id', () => 'edit route api').as('link.edit'); + router.get('/edit', [LinksController, 'showEditPage']).as('link.edit-form'); + router.put('/:id', [LinksController, 'update']).as('link.edit'); + + router + .put('/:id/favorite', [LinksController, 'toggleFavorite']) + .as('link.toggle-favorite'); router.get('/delete', () => 'delete').as('link.delete-form'); })