Oorspronkelijk gepubliceerd hier
Remix is een relatief nieuw, full-stack JS-framework, ondersteund door enkele van de reuzen in de JS-gemeenschap, zoals Kent C. Dodds, Ryan T. Florence en Michael Jackson. Totdat Next.js kwam, was het samenstellen van verschillende tools om je SPA te bouwen de feitelijke manier om JS-apps te bouwen. Next.js heeft dat tot op zekere hoogte revolutionair veranderd en was een tijdje ongeëvenaard. Het landschap is het afgelopen jaar echter snel aan het verschuiven met gezonde concurrentie van RedwoodJs, BlitzJs en nu Remix. Al deze tools proberen een aantal van de eeuwenoude problemen in webontwikkeling op te lossen op een creatievere, betrouwbaardere en Het belangrijkste, ontwikkelaarvriendelijke manier zodat het bouwen van een performante web-app de standaard wordt voor JS-ontwikkelaars.
Het is absoluut heel vroeg om een duidelijke winnaar te identificeren tussen al deze tools in deze ruimte, maar Remix ziet er zeker uit als een waardige kanshebber. Dus, als je je voeten nog niet nat hebt gemaakt in de geweldigheid die Remix is, hoop ik dat deze tutorial je op weg zal helpen en je zal overtuigen om het te kiezen voor wat je hierna gaat bouwen!
Vogelperspectief
In dit bericht zal ik je helpen bij het bouwen van een AMA-app (Ask Me Anything) met Remix. Hieronder vindt u een lijst met de belangrijkste tools die we zullen gebruiken om deze app te bouwen. Het zal zeker gemakkelijker zijn voor de lezer om te volgen als ze bekend zijn met de basis van sommige tools (behalve Remix natuurlijk), maar maak je niet al te veel zorgen als dat niet het geval is.
- Remix – Primair kader
- Reageren – UI-framework
- Prisma – Database-ORM
- PostgreSQL – Database
- TailwindCSS – CSS-framework
Dit wordt een lang bericht, dus ik raad aan om het in meerdere sessies te volgen en om het voor u gemakkelijker te maken om te beslissen of het lezen van het hele artikel een waardevolle investering is of niet, hier is een overzicht van wat we zullen doen/leren over de hele geheel, in chronologische volgorde:
- App-specificatie - Geef een overzicht van de functies van de app die we vanaf een hoger niveau gaan bouwen.
- Ga aan de slag met Remix - Volg meestal hun officiële documenten en installeer een paar dingen.
- Databaseschema - Stel het databaseschema in dat alle dynamische inhoud kan ondersteunen die onze app nodig heeft.
- CRUD - Basis CRUD-bewerkingen op de standaard Remix-manier.
- UI/UX – Strooi een beetje Tailwind om dingen er mooi en mooi uit te laten zien.
Zoals je kunt zien, hebben we veel te behandelen, dus laten we er meteen in duiken. Oh, eerst echter, als je ongeduldig bent zoals ik en gewoon de code wilt zien, hier is de hele app op github: https://github.com/foysalit/remix-ama
App-specificatie
Als je in elk project precies weet wat je gaat bouwen, wordt het vanaf het begin een stuk eenvoudiger om door het landschap te navigeren. Je hebt misschien niet altijd die vrijheid, maar gelukkig kennen we in ons geval alle functies die we nodig hebben voor onze app. Voordat we alle functies methodisch op een rij zetten vanuit een technisch perspectief, bekijken we ze eerst vanuit een algemeen productoogpunt.
AMA-sessie
Een gebruiker van onze app moet meerdere AMA-sessies kunnen hosten. Het heeft echter geen zin om meerdere sessies op dezelfde dag te hosten, dus laten we de duur van een sessie beperken tot een volledige dag en slechts 1 sessie per gebruiker per dag toestaan.
Q & A
Een gebruiker van onze app moet een vraag kunnen stellen aan een host tijdens een lopende AMA-sessie. Laten we, om exclusiviteit op te bouwen, voorkomen dat gebruikers vragen stellen nadat de sessie is afgelopen. Natuurlijk moet de gastheer van de sessie in staat zijn om de vragen die in hun sessies worden gesteld te beantwoorden.
Heb je vragen? Stel ze hier.
Om meer betrokkenheid op te bouwen en dingen een beetje leuker te maken dan traditionele Q&A, laten we een functie voor opmerkingenthreads toevoegen waarmee elke gebruiker een opmerking aan een vraag kan toevoegen. Dit kan worden gebruikt om meer context toe te voegen aan een reeds gestelde vraag of om een discussie te voeren over het door de gastheer gegeven antwoord enz.
Laten we nu eens kijken hoe we ze zullen implementeren:
authenticatie – Gebruikers moeten zich kunnen registreren om een AMA-sessie te hosten, een vraag te stellen aan een host of commentaar te geven in een thread. Laten we echter niet voorkomen dat een niet-geverifieerde gebruiker een reeds actieve sessie bekijkt. Laten we voor authenticatie het e-mailadres en wachtwoord gebruiken. Laten we de gebruiker bovendien vragen om bij het aanmelden zijn volledige naam in te voeren, zodat deze overal in de app kan worden gebruikt. Er wordt een gebruikersentiteit gebruikt voor het opslaan van auth-gerelateerde gegevens.
Sessions – Toon een lijst van alle huidige en eerdere sessies in een indexpagina aan alle (geverifieerde/niet-geverifieerde) gebruikers, zodat ze in elke sessie kunnen klikken en vragen/antwoorden/opmerkingen enz. kunnen zien. Geauthenticeerde gebruikers kunnen een nieuwe sessie starten als die er al is is niet een voor die dag. Laten we de host vragen om wat context/details aan elke sessie te geven bij het starten ervan. Elke sessie is een entiteit die bij een gebruiker hoort.
Contact – Elke individuele sessie kan meerdere vragen hebben van elke geregistreerde gebruiker, behalve de host. De vragentiteit zal ook het antwoord van de host in de database bevatten en elke ingevoerde antwoord zal worden gevalideerd om ervoor te zorgen dat de auteur de host van de sessie is. De entiteit hoort bij een sessie en een gebruiker. Laten we ervoor zorgen dat een gebruiker slechts één vraag per sessie kan stellen, dus laten we een tekstinvoer aan elke gebruiker laten zien totdat ze een vraag stellen. Laten we onder elke beantwoorde vraag een tekstinvoer tonen aan de host om hun antwoord toe te voegen.
Heb je vragen? Stel ze hier. – Elke vraag (al dan niet beantwoord) kan meerdere opmerkingen hebben. Laten we, om de complexiteit te verminderen, voorlopig geen threading toevoegen aan opmerkingen. Elke gebruiker kan meerdere opmerkingen onder een vraag plaatsen, dus laten we altijd de invoer van de commentaartekst aan alle gebruikers onder elke vraag tonen. Om de gebruikersinterface te vereenvoudigen, laten we standaard de vraag- (en antwoordlijst) op de sessiepagina weergeven en een link toevoegen om de opmerkingenreeks in een zijbalk te openen.
Aan de slag met Remix
Remix heeft veel geweldige kwaliteiten, maar documentatie neemt waarschijnlijk de eerste plaats in. Een framework dat volop in ontwikkeling is, zal ongetwijfeld veel bewegende delen hebben die constant worden ontwikkeld door de beheerders, dus documentatie zal zeker achterlopen als functies prioriteit krijgen. Het Remix-team besteedt echter veel zorg aan het up-to-date houden van de documentatie en synchroon met de constante stroom van verbazingwekkende veranderingen die naar buiten worden geduwd. Dus om te beginnen natuurlijk de officiële documenten zal ons eerste punt van binnenkomst zijn.
Als je te lui bent om naar een andere website te gaan en nog een muur van tekst te lezen, maak je dan geen zorgen. Hier is alles wat je hoeft te doen om Remix te installeren:
- Zorg ervoor dat u Node.js development env hebt ingesteld.
- Open uw Terminal-venster en voer de volgende opdracht uit:
npx create-remix@latest
. - Gereed.
Remix geeft je niet alleen een heleboel tools en vraagt je om je ding te gaan bouwen, ze geven het goede voorbeeld en daarom hebben ze het concept van Stacks. Stacks zijn in wezen sjablonen/starterskits die u de basis geven voor een compleet project, direct uit de doos. Voor ons project gebruiken we de Bluesstapel wat ons een volledig geconfigureerd Remix-project geeft met Prisma, Tailwind en een volledige module die laat zien hoe we die tools kunnen gebruiken om een CRUD-functie te bouwen. Ik bedoel eerlijk gezegd, ik heb het gevoel dat ik dit bericht niet eens zou moeten schrijven, omdat de sjabloon al het werk al heeft gedaan. Ach... ik zit er nu te diep in, dus kan het net zo goed afmaken.
Het enige wat u hoeft te doen is de opdracht uitvoeren npx create-remix --template remix-run/blues-stack ama
in uw terminal en Remix zal het hele project in een nieuwe map plaatsen met de naam ama
nadat je een paar vragen hebt beantwoord.
Laten we nu de . openen ama
map en maak ons een beetje vertrouwd met de inhoud erin. Er zijn een heleboel configuratiebestanden in de root en we zullen niet op de meeste daarvan ingaan. We zijn vooral geïnteresseerd in de prisma, publiek en gebruiken mappen. De prisma-directory bevat ons databaseschema en onze migratie. De openbare map bevat alle activa die de app nodig heeft, zoals pictogrammen, afbeeldingen enz. Ten slotte bevat de app-map al onze code, zowel de client als de server. Ja, u leest het goed, zowel client als server. Als dit je belangrijke historische codebase-flashbacks geeft, weet dan dat je niet de enige bent.
Voordat we de code van onze eigen app gaan schrijven, laten we eerst alles in git controleren, zodat we onze wijzigingen kunnen traceren van wat al voor ons is gedaan door de remix blues-stack.
cd ama
git init
git add .
git commit -am ":tada: Remix blues stack app"
En tot slot, laten we de app starten en kijken hoe hij eruitziet voordat we iets aanraken. Het README.md-bestand bevat al alle gedetailleerde stappen die u hierbij zouden moeten helpen en aangezien deze vaak worden gewijzigd, ga ik naar de stappen linken in plaats van ze hier op te schrijven https://github.com/remix-run/blues-stack#development
Als je de stappen precies volgt, zou de app toegankelijk moeten zijn op http://localhost:3000
De stapel wordt geleverd met een standaard notitiemodule waarmee u kunt spelen na registratie met uw e-mailadres en wachtwoord.
Database schema
Gewoonlijk begin ik graag aan een functie/entiteit te denken vanuit het databaseschema en werk ik omhoog naar de gebruikersinterface waar de gegevens op verschillende manieren worden geïnterpreteerd, weergegeven en gemanipuleerd. Als je het schema eenmaal hebt uitgewerkt, wordt het veel gemakkelijker om snel door die implementatie te gaan.
Zoals hierboven besproken in de app-specificatie, hebben we 3 entiteiten in onze database nodig: Sessie, Vraag en Opmerking. We hebben ook een gebruikersentiteit nodig om elke geregistreerde gebruiker op te slaan, maar de blues-stack van Remix bevat deze al. We hoeven het alleen een beetje aan te passen om een . toe te voegen name
kolom. Laten we het bestand openen prisma/schema.prisma
en voeg de onderstaande regels toe aan het einde van het bestand:
model Session { id String @id @default(cuid()) content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String questions Question[]
} model Question { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt content String answer String? user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade, onUpdate: Cascade) sessionId String comments Comment[]
} model Comment { id String @id @default(cuid()) content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String question Question @relation(fields: [questionId], references: [id], onDelete: Cascade, onUpdate: Cascade) questionId String
}
En voeg dan deze regel toe aan de definitie van de User
model:
model User { … name String sessions Session[] questions Question[] comments Comment[] …
}
Er valt hier veel uit te pakken, maar het meeste valt buiten het bestek van dit bericht. Deze schemadefinitie is alles wat prisma nodig heeft om alle tabellen met de juiste kolommen te bouwen voor de 3 nieuwe entiteiten die we nodig hebben. Hoe de definities en de syntaxis werken, ga naar deze link https://www.prisma.io/docs/concepts/components/prisma-schema en lees je een beetje in. Een samenvatting op hoog niveau is:
- Een entiteit/tabeldefinitie begint met
model <EntityName> {}
en binnen de accolades gaan alle kolommen/eigenschappen van de entiteit en relaties met de andere entiteiten. Dus een tabel voor commentaar zou er als volgt uitzien:model Comment {}
- Kolomdefinities zien er meestal als volgt uit:
<columnName> <columnType> <default/relationship/other specifiers>
. Dus als onze commentaarentiteit een kolom nodig heeft om de inhoud van de commentaarinvoer door de gebruiker op te slaan, zou het er als volgt uitzien:
model Comment { content String
}
- Relaties tussen 2 tabellen/entiteiten worden meestal gedefinieerd via een refererende sleutelkolom, dus deze worden ook gedefinieerd naast andere kolommen. De definitie vereist meestal 2 regels. Een kolom die de id van de refererende sleutel bevat en de andere om de naam op te geven die wordt gebruikt om toegang te krijgen tot een gerelateerde entiteit, die er meestal als volgt uitziet:
<entity> <entityName> @relation(fields: [<foreignKeyColumnName>], references: [id], onDelete: Cascade, onUpdate: Cascade)
. Dus om de commentaarentiteit te relateren aan de vragentiteit met een een-op-veel-relatie, moeten we deze definiëren als:
model Comment { content String question Question @relation(fields: [questionId], references: [id], onDelete: Cascade, onUpdate: Cascade) questionId String
}
Het bovenstaande dekt niet eens het topje van de ijsberg die prisma is, dus alsjeblieft, alsjeblieft, lees erover in hun officiële documenten en je zult de ware kracht ervan zien. Omwille van deze blogpost zou het bovenstaande je een idee moeten geven van waarom we het bovenstaande prismaschema nodig hebben.
We moeten nog een laatste aanpassing maken met betrekking tot de database. Naast het volledige authenticatiesysteem bevat de blues-stack ook een initiële dataseeder die uw database vult met een dummy-gebruiker voor testdoeleinden. Sinds we een nieuwe kolom hebben geïntroduceerd name
in de gebruikerstabel moeten we ook de seeder aanpassen om een dummynaam aan de gebruiker toe te voegen. Open het bestand prisma/seed.js
en wijzig de gebruikersinvoegcode zoals hieronder:
const user = await prisma.user.create({ data: { Email, name: 'Rachel Remix', password: { create: { hash: hashedPassword, }, }, }, });
Daarmee zijn we eindelijk klaar om al deze wijzigingen te synchroniseren met onze database. Omdat onze database echter al is opgebouwd met eerder gemaakt schema en enkele gezaaide gegevens en sindsdien onze db is veranderd, kunnen we niet echt al onze wijzigingen meteen synchroniseren. In plaats daarvan zullen we de migratie een beetje moeten aanpassen. Prisma biedt opdrachten voor dit soort aanpassingen maar gelukkig zijn onze bestaande gegevens en schema niet in productie of zo, dus op dit moment is het gewoon gemakkelijker om de db te vernietigen en opnieuw te beginnen met ons huidige schema. Dus laten we de gemakkelijkere route nemen en deze opdrachten uitvoeren:
./node_modules/.bin/prisma migrate reset
./node_modules/.bin/prisma migrate dev
De eerste opdracht stelt onze db opnieuw in en de tweede gebruikt de huidige schemadefinitie om de db opnieuw te maken met alle tabellen en deze te vullen met gezaaide gegevens.
Laten we nu de actieve app-server stoppen, de app opnieuw instellen en weer draaien
npm run setup
npm run dev
Gebruikersregistratie bijwerken
Aangezien we een nieuwe naamkolom hebben toegevoegd aan de gebruikerstabel, laten we beginnen met gebruikers te verplichten hun naam in te vullen bij het aanmelden. Dit geeft ons een mooie instap in de remix-manier om dingen te doen zonder het een grote schok te maken als je vooral bekend bent met de gebruikelijke manier van reageren om apps te bouwen.
De code voor gebruikersaanmelding is te vinden in ./app/routes/join.tsx
het dossier. Open het en recht onder de <Form>
component de volgende code om het invoerveld voor naam toe te voegen:
<Form method="post" className="space-y-6" noValidate> <div> <label htmlFor="name" className="block text-sm font-medium text-gray-700" > Full Name </label> <div className="mt-1"> <input ref={nameRef} id="name" required autoFocus={true} name="name" type="text" aria-invalid={actionData?.errors?.name ? true : undefined} aria-describedby="name-error" className="w-full rounded border border-gray-500 px-2 py-1 text-lg" /> {actionData?.errors?.name && ( <div className="pt-1 text-red-700" id="name-error"> {actionData.errors.name} </div> )} </div> </div>
Het bootst in feite het reeds bestaande e-mailveld na. Nu moeten we hier nog een paar dingen aanpassen om ervoor te zorgen dat de naaminvoer correct wordt afgehandeld. Laten we eerst een verwijzing naar het naamveld maken en als er een fout is opgetreden bij het verwerken van de naaminvoer, willen we dat veld automatisch scherpstellen, net als de andere velden in het formulier.
const emailRef = React.useRef<HTMLInputElement>(null); const nameRef = React.useRef<HTMLInputElement>(null); const passwordRef = React.useRef<HTMLInputElement>(null); React.useEffect(() => { if (actionData?.errors?.email) { emailRef.current?.focus(); } else if (actionData?.errors?.password) { passwordRef.current?.focus(); } else if (actionData?.errors?.name) { nameRef.current?.focus(); } }, [actionData]);
Wat is nu? actionData
? Het is gewoon het geretourneerde antwoord van de server van het verzendverzoek. Elke actie voor het indienen van een formulier stuurt het postverzoek van de browser naar de server en remix zal het afhandelen via de action
functie die direct boven de component is gedefinieerd. Deze functie ontvangt een object met een request-eigenschap die u een aantal zeer handige methoden geeft om toegang te krijgen tot de gegevens die door de browser zijn verzonden en u kunt een reactie van deze functie retourneren die de browsercode dienovereenkomstig kan verwerken. In ons geval willen we de ingediende gegevens valideren en ervoor zorgen dat het naamveld daadwerkelijk is ingevuld. Dus hier zijn de wijzigingen die we nodig hebben in de action
functie:
const email = formData.get("email"); const name = formData.get("name"); const password = formData.get("password"); if (typeof name !== "string" || name.length === 0) { return json<ActionData>( { errors: { name: "Name is required" } }, { status: 400 } ); }
Dat komt erop neer dat de ingevoerde naam wordt opgehaald uit het formulier indieningsverzoek en vervolgens een foutmelding wordt geretourneerd als de naam niet is ingevuld. Aangezien de retourgegevens worden getypt via de ActionData
type, moeten we de definitie aanpassen en de eigenschap name toevoegen:
interface ActionData { errors: { email?: string; name?: string; password?: string; };
}
We hebben alleen het onjuiste invoergeval afgehandeld, dus laten we doorgaan en ervoor zorgen dat in het geval van een juiste invoer de gebruikersnaam wordt ingevoegd in de kolomeigenschap door de regel bij te werken const user = await createUser(email, password);
naar const user = await createUser(email, password, name);
en bijgevolg moeten we de definitie van . aanpassen createUser
in de app/models/user.server.ts
file:
export async function createUser(email: User["email"], password: string, name: string) { const hashedPassword = await bcrypt.hash(password, 10); return prisma.user.create({ data: { email, name, password: { create: { hash: hashedPassword, }, }, }, });
}
Een paar dingen om hier op te merken:
- Om serverspecifieke code geïsoleerd en weg van de client te houden, kunnen we bestanden als suffix gebruiken met:
.server.ts
. - We gebruiken een zeer expressieve en intuïtieve prisma-API om eenvoudig een nieuwe rij in de db in te voegen. Dit neemt meestal de vorm aan van
prisma.<entityName>.<actionName>({})
WAARentityName
is de tabelnaam in kleine letters enactionName
is de db-bewerking zoals create, update, findOne enz. We zullen hier binnenkort meer gebruik van zien.
Daarmee hebben we zojuist een nieuwe naaminvoer toegevoegd die wordt gevalideerd wanneer de gebruiker raakt Create Account
.
Dit is waarschijnlijk een goed stoppunt om onze wijzigingen op git in te checken, dus laten we onze code vastleggen: git add . && git commit -am “:sparkles: Add name field to the sign up form”
Sessions
Tot nu toe hebben we voornamelijk bestaande code hier en daar aangepast om enig inzicht te krijgen in hoe Remix dingen doet. Nu kunnen we onze eigen module helemaal opnieuw bouwen. Het eerste dat we zullen bouwen, is een manier waarop gebruikers een AMA-sessie kunnen hosten volgens de initiële app-specificatiedefinitie.
In remix zijn URL-routes op bestanden gebaseerd. Ik bedoel, het verzint zo'n beetje een heel nieuw paradigma, dus het vereenvoudigen tot: file based routing
is waarschijnlijk niet erg nauwkeurig of eerlijk, maar we zullen er langzaamaan ingaan. Om te beginnen met sessies willen we
- Een lijstpagina waar alle huidige en historische sessies worden vermeld
- Een speciale pagina per sessie waar alle vragen, antwoorden en commentaarthreads worden getoond
- Een pagina om een nieuwe sessie te starten voor elke ingelogde gebruiker
Laten we beginnen met de lijstpagina. Maak een nieuw bestand in app/routes/sessions/index.tsx
en zet de volgende code erin:
import { Link, useLoaderData } from "@remix-run/react";
import { getSessions } from "~/models/session.server";
import type { LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Header } from "~/components/shared/header";
import { Button } from "~/components/shared/button"; type LoaderData = { sessions: Awaited<ReturnType<typeof getSessions>>;
}; export const loader: LoaderFunction = async () => { const sessions = await getSessions(); if (!sessions?.length) { throw new Response("No sessions found", { status: 404 }); } return json<LoaderData>({ sessions });
} export function CatchBoundary() { return ( <> <Header /> <div className="mx-auto px-6 md:w-5/6 lg:w-4/5 xl:w-2/3"> <div className="rounded bg-red-100 p-5"> <h4 className="text-lg font-bold">No sessions found</h4> <p className="mb-4">Why don't you start one... could be fun!</p> <Button isLink to="new" className="bg-blue-600 text-white"> Start AMA session! </Button> </div> </div> </> );
} export default function SessionIndexPage() { const data = useLoaderData<LoaderData>(); const dateFormatter = new Intl.DateTimeFormat("en-GB"); return ( <> <Header /> <div className="mx-auto px-6 md:w-5/6 lg:w-4/5 xl:w-2/3"> <div> {data.sessions?.map((session) => ( <div key={`session_list_item_${session.id}`} className="mt-4 p-4 shadow-sm" > <div className="flex flex-row"> <Link className="underline" to={session.id}> {session.user.name} -{" "} {dateFormatter.format(new Date(session.createdAt))} </Link> <span className="px-2">|</span> <div className="flex flex-row"> <img width={18} height={18} alt="Question count icon" src="/icons/question.svg" /> <span className="ml-1">{session._count.questions}</span> </div> </div> <p className="pt-2 text-sm text-gray-700">{session.content}</p> </div> ))} </div> </div> </> );
}
Als je bekend bent met reageren, zou dit je voor het grootste deel bekend moeten voorkomen. Laten we het echter stuk voor stuk opsplitsen. Remix zal de standaard geëxporteerde component weergeven. Boven de componentdefinitie hebben we a loader
functie. Dit is een speciale functie die u slechts 1 per route/bestand kunt hebben en bij het laden van de pagina zal Remix deze functie aanroepen om de gegevens op te halen die uw pagina nodig heeft. Het zal dan je component hydrateren met de gegevens en de gerenderde HTML over de draad sturen als een reactie, wat een van de magische gedragingen of Remix is. Dit zorgt ervoor dat gebruikers geen laadstatus hoeven te zien omdat de JS-code van uw browser gegevens laadt van API-verzoeken. De hoofdtekst van de actiefunctie roept op tot a getSessions()
functie die is geïmporteerd uit ~/models/session.server
. Hier volgen we de reeds besproken strategie om db-bewerkingen in server-only-bestanden te plaatsen. Laten we het nieuwe bestand maken in app/models/session.server.ts
en zet er de volgende code in:
import { prisma } from "~/db.server"; export type { Session, Question, Comment } from "@prisma/client"; export const getSessions = () => { return prisma.session.findMany({ include: { user: true, _count: { select: { questions: true }, }, }, });
};
Het haalt gewoon alle items uit de sessietabel en alle gebruikersitems die ermee verband houden, omdat we de informatie van de host op de gebruikersinterface zullen gebruiken en het bevat ook het totale aantal vragen dat elke sessie heeft. Dit is niet super schaalbaar, want naarmate onze app groeit, kunnen er honderdduizenden AMA-sessies zijn en het ophalen van al deze sessies zal niet goed schalen. Voor het doel van dit bericht slaan we de paginering echter voorlopig over.
Laten we terug springen in onze sessions/index.tsx
route bestand. Als er geen sessies in de database zijn, retourneren we een 404-foutreactie met de Response
helper van Remix. Anders retourneren we een JSON-antwoord met de reeks sessies met behulp van de json
helper van Remix.
De const data = useLoaderData<LoaderData>();
roept een speciale Remix-hook aan die ons toegang geeft tot de gegevens in het antwoord dat is teruggestuurd van action
. U vraagt zich misschien af, hoe gaan we om met de foutreactie? Het wordt zeker niet behandeld in het lichaam van de SessionIndexPage
functie. Remix gebruikt de lang beschikbare ErrorBoundary
functie voor het afhandelen van foutweergaven. Het enige wat we hoeven te doen is een reactiecomponent met de naam . exporteren CatchBoundary
van een routebestand en elke fout die wordt gegenereerd bij het weergeven van de route (client of server) de CatchBoundary
onderdeel zal worden weergegeven. Laten we dit heel snel definiëren boven de SessionIndexPage
component:
export function CatchBoundary() { return ( <> <Header /> <div className="mx-auto px-6 md:w-5/6 lg:w-4/5 xl:w-2/3"> <div className="rounded bg-red-100 p-5"> <h4 className="text-lg font-bold">No sessions found</h4> <p className="mb-4">Why don't you start one... could be fun!</p> <Button isLink to="new" className="bg-blue-600 text-white"> Start AMA session! </Button> </div> </div> </> );
} export default function SessionIndexPage() {
…
Dit is simpelweg het renderen van een gedeelde header-component en een link naar het starten van een nieuwe sessie. Het gebruikt ook een gedeelde Button
onderdeel. Laten we deze gedeelde componenten uitbouwen. We gaan ze in de app/components/shared/
map. Laten we beginnen met de app/components/shared/header.tsx
file:
import { Link } from "@remix-run/react"; export const HeaderText = () => { return ( <h1 className="text-center text-3xl font-cursive tracking-tight sm:text-5xl lg:text-7xl"> <Link to="/sessions" className="block uppercase drop-shadow-md"> AMA </Link> </h1> );
}; export const Header = () => { return ( <div className="flex flex-row justify-between items-center px-6 md:w-5/6 lg:w-4/5 xl:w-2/3 mx-auto py-4"> <HeaderText /> </div> );
};
Dit is een basisreactiecomponent met wat staartwindstyling besprenkeld. We gebruiken de Link
component van Remix (wat in feite slechts een proxy is voor de Link
component van react-router) om te linken naar de lijst met sessies pagina. Een ander opmerkelijk ding hier is dat we a . gebruiken font-cursive
stijl op de koptekst om het een beetje op een logo te laten lijken. Cursieve lettertypestijl is niet opgenomen in de standaard staartwindconfiguratie, dus we zullen het zelf moeten configureren. Open de tailwind.config.js
bestand uit de hoofdmap van het project en pas de theme
eigendom zoals hieronder:
module.exports = { content: ["./app/**/*.{ts,tsx,jsx,js}"], theme: { extend: { fontFamily: { cursive: ["Pinyon Script", "cursive"], }, }, }, plugins: [],
};
Merk op dat het extra bit het thema uitbreidt om een nieuwe fontFamily met de naam toe te voegen cursive
en de waarde is Pinyon Script
Ik koos dit uit google fonts, maar voel je vrij om je eigen lettertype te kiezen. Als u niet zo bekend bent met wind in de rug, geeft dit ons alleen de mogelijkheid om deze lettertypefamilie op een tekst toe te passen met behulp van de font-cursive
helper klasse, maar we moeten nog steeds het lettertype zelf op onze webpagina laden. Het toevoegen van externe middelen aan Remix is vrij eenvoudig. Open de app/root.tsx
bestand en update de links
definitie om 3 nieuwe objecten aan de array toe te voegen:
export const links: LinksFunction = () => { return [ { rel: "stylesheet", href: tailwindStylesheetUrl }, { rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.gstatic.com", }, { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Pinyon+Script&display=swap", }, ];
};
Alle bovenstaande links zijn opgehaald uit de google pagina met lettertypen hier.
Onze stappen terugvoeren naar de sessions/index.tsx
bestand, de andere gedeelde component daar is de knopcomponent. Laten we die heel snel maken app/components/shared/button.tsx
:
import React from "react";
import { Link } from "@remix-run/react";
import type { LinkProps } from "@remix-run/react"; export type ButtonProps = { isAction?: boolean; isLink?: boolean;
}; export const Button: React.FC< ButtonProps & (ButtonProps["isLink"] extends true ? LinkProps : React.ButtonHTMLAttributes<HTMLButtonElement>)
> = ({ children, isLink, isAction, className, ...props }) => { let classNames = `${className || ""} px-3 py-2 rounded`; if (isAction) { classNames += " bg-green-300 text-gray-600 text-sm font-semi-bold"; } if (isLink) { return ( <Link className={classNames} {...(props as LinkProps)}> {children} </Link> ); } return ( <button className={classNames} {...props}> {children} </button> );
};
Dit is een eenvoudige knopcomponent die ons zal helpen het uiterlijk en het gevoel te verenigen van knoppen die ofwel link- of actieknoppen zijn op verschillende plaatsen in de app. Om het componenttype veilig te maken terwijl rekwisieten voor knop en link worden geaccepteerd, passen we wat getypte magie toe op de rekwisieten en weergave.
Ten slotte kijken we naar de eigenlijke paginacomponentcode zelf. De pagina geeft een overzicht van alle sessie-items en toont de datum van de sessie, de naam van de host van de sessie, het uitgangspunt/detail dat door de host voor de sessie is toegevoegd en een totaal aantal vragen dat er zijn. Om datums weer te geven, gebruiken we de ingebouwde browser Intl-module die op locale gebaseerde opmaak ondersteunt. We gebruiken een klein SVG-pictogram naast het aantal vragen. Je kunt hier alle middelen vinden die in de app worden gebruikt https://github.com/foysalit/remix-ama/tree/main/public/icons maar voel je vrij om je eigen iconen te gebruiken zoals je wilt. Alle openbare activa moeten worden toegevoegd aan de /public
map en om alle pictogrammen bij elkaar te houden, hebben we een pictogrammenmap gemaakt.
Met al het bovenstaande zou je nu in staat moeten zijn om naar http://localhost:3000/sessions url en zie de 404-foutpagina omdat we nog geen sessies hebben gemaakt.
Laten we nu de nieuwe sessiepagina gaan bouwen zodat we een sessie kunnen hosten en die op de lijstpagina kunnen zien. We zullen dat op een andere pagina plaatsen zodat gebruikers gemakkelijk naar kunnen gaan /sessions/new
op onze app en start met het hosten van een sessie. Een nieuw bestand maken routes/sessions/new.tsx
met de volgende code:
import { Form, useActionData, useTransition } from "@remix-run/react";
import { ActionFunction, json, LoaderFunction, redirect,
} from "@remix-run/node";
import { startSessionsForUser } from "~/models/session.server";
import { requireUserId } from "~/session.server";
import { Header } from "~/components/shared/header";
import { Button } from "~/components/shared/button"; export type ActionData = { errors?: { content?: string; alreadyRunning?: string; };
}; export const action: ActionFunction = async ({ request }) => { const userId = await requireUserId(request); const formData = await request.formData(); try { const content = formData.get("content"); if (typeof content !== "string" || content.length < 90) { return json<ActionData>( { errors: { content: "Content is required and must be at least 90 characters.", }, }, { status: 400 } ); } const session = await startSessionsForUser(userId, content); return redirect(`/sessions/${session.id}`); } catch (err: any) { if (err?.message === "already-running-session") { return json<ActionData>( { errors: { alreadyRunning: "You already have a session running." }, }, { status: 400 } ); } return json({ error: err?.message }); }
}; // A simple server-side check for authentication to ensure only logged in users can access this page
export const loader: LoaderFunction = async ({ request }) => { await requireUserId(request); return json({ success: true });
}; export default function SessionNewPage() { const transition = useTransition(); const actionData = useActionData(); return ( <> <Header /> <div className="p-5 bg-gray-50 px-6 md:w-5/6 lg:w-4/5 xl:w-2/3 mx-auto rounded"> <h4 className="font-bold text-lg"> Sure you want to start a new AMA session? </h4> <p className="mb-4"> An AMA session lasts until the end of the day regardless of when you start the session. During the session, any user on the platform can ask you any question. You always have the option to not answer. <br /> <br /> Please add a few lines to give everyone some context for the AMA session before starting. </p> <Form method="post"> <textarea rows={5} autoFocus name="content" className="w-full block rounded p-2" placeholder="Greetings! I am 'X' from 'Y' TV show and I am delighted to be hosting today's AMA session..." /> {actionData?.errors?.content && ( <p className="text-red-500 text-sm">{actionData.errors.content}</p> )} <Button className="px-3 py-2 rounded mt-3" disabled={transition.state === "submitting"} type="submit" isAction > {transition.state === "submitting" ? "Starting..." : "Start Session"} </Button> </Form> </div> {actionData?.errors?.alreadyRunning && ( <div className="mt-4 p-5 bg-red-500 mx-auto min-w-[24rem] max-w-3xl rounded"> <p>{actionData.errors.alreadyRunning}</p> </div> )} </> );
}
Laten we op de gebruikelijke manier dit grote stuk code afbreken.
- Actie – Wanneer de gebruiker de details en hits van de sessie invult
Start Session
we willen de formuliergegevens ontvangen als een POST-verzoek en een nieuwe sessie maken voor de momenteel ingelogde gebruiker. Dus de actie begint met derequireUserId(request)
controleren. Het is een hulpmethode die bij de stapel wordt geleverd en die ongeautoriseerde gebruikers eenvoudig omleidt naar de inlogpagina of de geautoriseerde gebruikers-ID retourneert. Dan halen we de gebruikersinvoer op voor de sessie'scontent
kolom met behulp vanrequest.formData()
die ons toegang geeft tot alle POST-gegevens. Als de inhoud niet is ingevuld of een bepaalde lengte overschrijdt, geven we een foutmelding terug. Anders starten we de sessie en leiden de gebruiker naar de nieuw aangemaakte sessiepagina. - startSessionsForUser – Dit is alleen een serverfunctie die een nieuw sessie-item in de database maakt. Laten we dit toevoegen aan onze
models/session.server.ts
file:
import type { User, Session } from "@prisma/client";
import startOfDay from "date-fns/startOfDay";
import endOfDay from "date-fns/endOfDay"; export const startSessionsForUser = async ( userId: User["id"], content: Session["content"]
) => { const runningSession = await prisma.session.findFirst({ where: { createdAt: { lte: endOfDay(new Date()), gte: startOfDay(new Date()), }, userId, }, }); if (runningSession) { throw new Error("already-running-session"); } return prisma.session.create({ data: { userId, content } });
};
Deze functie ontvangt een userId en de inhoud van de sessie. Als er binnen de grenzen van vandaag al een sessie door de gebruiker is gemaakt, wordt er een fout gegenereerd, anders wordt er een nieuw sessie-item gemaakt. Het manipuleren van datums is een beetje raar in JS, dus ik geef er de voorkeur aan een bibliotheek in mijn project te laten vallen voor het verwerken van datums. In dit geval gebruik ik date-fns lib maar voel je vrij om je favoriete lib te gebruiken.
- Loader: we willen dat alleen geautoriseerde gebruikers deze pagina zien, dus de loader voert gewoon de
requireUserId()
functie die niet-geverifieerde gebruikers uitlogt en voorkomt dat ze het sessie-aanmaakformulier zien. - Overgang - Remix wordt geleverd met een zeer nuttige
useTransition()
hook die u toegang geeft tot verschillende statussen van een pagina. Als u een formulier verzendt vanaf een pagina, verzendt u gegevens naar de server en wacht u op het antwoord,transition.state
zal veranderen insubmitting
gedurende die duur. Hiermee schakelen we de verzendknop uit om te voorkomen dat gebruikers per ongeluk meerdere sessies proberen te maken. - Foutafhandeling - Als gebruikers proberen een sessie te starten, krijgen we ofwel een validatiefout voor het inhoudsveld terug of we krijgen een specifieke fout als er al een actieve sessie is, we behandelen beide via UI-weergave van een foutmelding door toegang te krijgen tot de gegevens van
useActionData()
. - Vormcomponent – De
Form
component van remix is slechts een kleine syntactische suiker bovenop de formuliercomponent van de browser. Het handhaaft al het standaardgedrag van een formulier. Hier kun je er meer over lezen: https://remix.run/docs/en/v1/guides/data-writes#plain-html-forms
Als je alle bovenstaande stappen hebt gevolgd, open http://localhost:3000/sessions/new in uw browser en u zou een pagina zoals hierboven moeten zien. Als u echter het invoerveld invult en op Sessie starten klikt, gaat u naar een 404 niet gevonden pagina, maar dat betekent niet dat de knop niet werkte. U kunt handmatig teruggaan naar http://localhost:3000/sessions en bekijk de nieuw gemaakte sessie zelf op de lijstpagina. Iets zoals dit:
Q & A
Omdat de sessielijst en het maken van pagina's goed werken, kunnen we nu Q&A per sessie bouwen. Elke sessie moet toegankelijk zijn via sessions/:sessionId
url waar :sessionId
is een variabele die zal worden vervangen door id's van sessies. Om dynamische routeparameter toe te wijzen aan een routebestand in Remix, moeten we de bestandsnaam beginnen met: $
teken achter de naam van de parameter. Laten we in ons geval dus een nieuw bestand maken routes/sessions/$sessionId.tsx
met de volgende code:
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useCatch, useLoaderData, Outlet, useParams,
} from "@remix-run/react";
import invariant from "tiny-invariant"; import { addAnswerToQuestion, addQuestionToSession, getSession,
} from "~/models/session.server";
import { getUserId, requireUserId } from "~/session.server";
import { Button } from "~/components/shared/button";
import { QuestionAnswer } from "~/components/sessions/question-answer";
import { Header } from "~/components/shared/header"; type ActionData = { errors?: { title?: string; body?: string; };
}; type LoaderData = { session: Awaited<ReturnType<typeof getSession>>; currentUserId?: string;
}; export type OutletContext = LoaderData; export const loader: LoaderFunction = async ({ request, params }) => { invariant(params.sessionId, "sessionId not found"); const session = await getSession(params.sessionId); if (!session) { throw new Response("Not Found", { status: 404 }); } const currentUserId = await getUserId(request); return json<LoaderData>({ session, currentUserId });
}; export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); invariant(params.sessionId, "sessionId not found"); const formData = await request.formData(); const questionId = formData.get("answer_to_question"); if (typeof questionId === "string") { const answer = formData.get("answer"); if (typeof answer !== "string" || answer?.trim()?.length < 3) { return json<ActionData>( { errors: { title: "Answer is required" } }, { status: 400 } ); } await addAnswerToQuestion({ id: questionId, userId, answer }); return redirect(`/sessions/${params.sessionId}/questions/${questionId}`); } const content = formData.get("content"); if (typeof content !== "string" || content?.trim()?.length < 3) { return json<ActionData>( { errors: { title: "Question is required" } }, { status: 400 } ); } const question = await addQuestionToSession({ userId, sessionId: params.sessionId, content, }); return redirect(`/sessions/${params.sessionId}/questions/${question.id}`);
}; export default function SessionDetailsPage() { const params = useParams(); const data = useLoaderData() as LoaderData; const dateFormatter = new Intl.DateTimeFormat("en-GB"); return ( <> <Header /> <div className="mx-auto flex w-full flex-row justify-between px-6 md:w-5/6 lg:w-4/5 xl:w-2/3"> <div className={params.questionId ? "w-1/2" : "w-full"}> <h3 className="flex flex-row items-center justify-between"> <span className="text-2xl font-bold"> {data.session?.user.name} </span> <span> {dateFormatter.format( new Date(data.session?.createdAt || Date.now()) )} </span> </h3> <p className="py-6">{data.session?.content}</p> {data.currentUserId !== data.session?.userId && ( <div className="mb-4 rounded bg-gray-100 p-3"> <Form method="post"> <div> <label htmlFor="question" className="block"> <div className="mb-2 flex flex-row items-center"> <img alt="Question logo" src="/icons/question.svg" width={45} height={45} /> <span className="ml-2 leading-4"> Ask your question <br /> <i className="text-xs text-gray-800"> Please be concise and expressive. No explicit content allowed! </i> </span> </div> <textarea rows={5} name="content" className="block w-full rounded p-2" /> </label> </div> <div className="mt-2 flex justify-end"> <Button type="submit" isAction> Ask Question </Button> </div> </Form> </div> )} {!!data.session?.questions?.length && ( <ul> {data.session.questions.map((q) => ( <QuestionAnswer question={q} key={`question_${q.id}`} canAnswer={data.currentUserId === data.session?.userId} isSelected={params.questionId === q.id} /> ))} </ul> )} </div> <Outlet context={data} /> </div> </> );
} export function ErrorBoundary({ error }: { error: Error }) { console.error(error); return <div>An unexpected error occurred: {error.message}</div>;
} export function CatchBoundary() { const caught = useCatch(); if (caught.status === 404) { return <div>Session not found</div>; } throw new Error(`Unexpected caught response with status: ${caught.status}`);
}
Met deze zullen we snel enkele van de concepten die we al hebben besproken doornemen en ons meer concentreren op de nieuwe stukjes:
- Loader: retourneert het sessie-item en de huidige gebruiker-ID. Het roept een oproep op om
invariant
wat een externe bibliotheek is om eenvoudig te controleren of een variabele waarheidsgetrouw is en zo niet. - getSession: ontvangt de sessionId als het enige argument. Laten we het implementeren in onze
models/session.server.ts
file:
export const getSession = (id: Session["id"]) => prisma.session.findFirst({ where: { id }, include: { questions: { include: { user: true, }, }, user: true, }, });
Opmerken hoe het alle vragen omvat die bij een sessie horen en ook de gebruikers die die vragen hebben gesteld.
- Actie: deze pagina kan 2 dingen doen op basis van wie hem bekijkt. De gastheer van de sessie kan elke vraag beantwoorden, maar kan geen vraag stellen. Alle andere gebruikers kunnen alleen het tegenovergestelde doen. Dus de actie moet beide acties afhandelen en de manier waarop we onderscheid maken tussen de twee is via de
formData.get("answer_to_question")
invoer. Van de kant van de klant sturen we dit alleen wanneer de host een antwoord op een vraag indient. Opmerken hoe we de gebruiker omleiden naar/sessions/${params.sessionId}/questions/${questionId}
in het geval van een van beide acties? Dat is onze toegang tot geneste routering. Bewaar dit in je achterhoofd voor later. - addAnswerToQuestion: Deze helper voegt het antwoord van de host toe aan een vraag door een object als argument op te nemen dat de id van de vraag en de invoer van het antwoord bevat. Laten we dit implementeren in
models/session.server.ts
:
import type { User, Session, Question } from "@prisma/client"; export const addAnswerToQuestion = async ({ id, userId, answer,
}: Pick<Question, "id" | "userId" | "answer">) => { const existingQuestion = await prisma.question.findFirst({ where: { id }, include: { session: true }, }); if (!existingQuestion) { throw new Error("question-not-found"); } if (existingQuestion.session.userId !== userId) { throw new Error("not-session-author"); } return prisma.question.update({ where: { id }, data: { answer } });
};
Merk op dat de implementatie controleert of de gebruiker die het verzoek doet inderdaad de host van de sessie is of niet en een specifieke fout genereert als dat niet het geval is.
- addQuestionToSession: deze voegt een vraag van een niet-hostgebruiker toe aan een sessie door een objectargument op te nemen dat de gebruikers- en sessie-ID en de vraaginvoer bevat. Zo is het geïmplementeerd in
models/session.server.ts
:
export const addQuestionToSession = async ({ userId, sessionId, content,
}: Pick<Question, "userId" | "sessionId" | "content">) => { const existingQuestion = await prisma.question.findFirst({ where: { userId, sessionId, content, }, }); if (existingQuestion) { throw new Error("already-asked"); } const isSessionHost = await prisma.session.findFirst({ where: { userId, id: sessionId, }, }); if (isSessionHost) { throw new Error("host-can-not-ask-questions"); } return prisma.question.create({ data: { sessionId, userId, content } });
};
Merk je op hoe we voorkomen dat een gebruiker dezelfde vraag meer dan één keer per sessie plaatst?
- useParams hook: deze hook is een andere proxy om router te reageren die ons eenvoudig toegang geeft tot elke routeparameter zoals sessionId in ons geval.
- Vragenformulier: aan alle niet-host, geverifieerde gebruikers, tonen we een vraaginvoerformulier bij elke sessie boven de lijst met eerder geplaatste vragen.
- QuestionAnswer-component: om een groot deel van de code deelbaar en geïsoleerd te houden, plaatsen we een enkele vraag in een gedeeld componentbestand. We zullen zo meteen zien waarom, maar laten we eerst de implementatie van dit onderdeel bekijken. Een nieuw bestand maken
app/components/sessions/question-answer.tsx
en zet daar de volgende code in:
import { Form, Link } from "@remix-run/react";
import React from "react"; import type { Question } from "~/models/session.server";
import type { User } from "~/models/user.server";
import { Button } from "~/components/shared/button"; export const QuestionAnswer: React.FC<{ question: Question & { user: User }; isSelected?: boolean; as?: React.ElementType; canAnswer: boolean; hideCommentsLink?: boolean;
}> = ({ question, hideCommentsLink, isSelected, as: Component = "li", canAnswer, ...rest
}) => { const dateFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short", }); return ( <Component className={`mb-4 rounded p-2 ${isSelected ? "bg-gray-50" : ""}`} {...rest} > <div className="flex flex-row"> <div className="max-w-40 mr-2"> <img width={50} height={50} alt="Question icon" src="/icons/question.svg" /> </div> <p> <span className="font-semi-bold text-xs text-gray-500"> {question.user?.name} at{" "} {dateFormatter.format(new Date(question.createdAt))} {!hideCommentsLink && ( <> {" "} |{" "} <Link className="underline" to={`questions/${question.id}`}> Comments </Link> </> )} </span> <br /> {question.content} </p> </div> {question.answer ? ( <div className="mt-2 pl-10"> <div className="flex flex-row p-2 shadow-sm"> <img width={50} height={50} alt="Question icon" src="/icons/answer.svg" /> <p> <span className="font-semi-bold text-xs text-gray-500"> {dateFormatter.format(new Date(question.updatedAt))} </span> <br /> {question.answer} </p> </div> </div> ) : ( canAnswer && ( <div className="mt-4 px-4"> <Form method="post"> <textarea rows={5} name="answer" className="block w-full rounded p-2" /> <div className="mt-2 flex justify-end"> <Button name="answer_to_question" value={question.id} isAction> Answer </Button> </div> </Form> </div> ) )} </Component> );
};
Merk op dat deze component er een formulier in insluit, wat betekent dat elke vraag dit formulier voor de host zal weergeven om hen een gemakkelijke manier te geven om antwoorden toe te voegen op vragen die ze nog niet hebben beantwoord en de verzendknop van het formulier heeft name="answer_to_question" value={question.id}
props die ons helpt de backend (actie) te signaleren dat deze formulierinzending moet worden aangepakt als antwoordinvoer door de host.
Het is je misschien ook opgevallen dat elke vraag verwijst naar: to={
vragen/${question.id}}
wat ons bij het geneste routeringsonderwerp brengt. Laten we daar nu eens naar kijken.
Geneste routering
In een traditionele reactie-app zou je een pagina opsplitsen in meerdere componenten en de componenten laden intern hun eigen gegevens of worden gevoed door een wereldwijde gegevensopslag die de gegevens eraan doorgeeft. In Remix zou je dat doen via geneste routering, waarbij een pagina een andere pagina kan insluiten die zijn eigen levenscyclus heeft, zoals dataloader, actie, foutgrens enz. Dit is ongelooflijk krachtig en voegt een geheel nieuw niveau van betrouwbaarheid en snelheid toe aan UX . We gaan dit gebruiken om een commentaarthread per vraag in een sessie te tonen.
Om dit te vergemakkelijken, hebben we een <Outlet context={data.session} />
component op de pagina met sessiedetails. Outlet
is de container voor geneste pagina-inhoud en het geeft ons de mogelijkheid om de lay-out voor een onderliggende pagina op bovenliggend niveau te bouwen. Wanneer de gebruiker naar een geneste route gaat, wordt deze vervangen door de html die wordt weergegeven door het laagste niveau van de geneste paginaroute.
Om toegang te krijgen tot de commentaarthread, leiden we gebruikers nu door naar: session/:sessionId/questions/:questionId
route dus om dat in het bestandssysteem te evenaren, moeten we een nieuwe map binnenin maken in routes/sessions/$sessionId/questions
en maak een bestand met de naam $questionId.tsx
binnenkant ervan. Merk op dat we nu een bestand hebben met de naam $sessionId.tx
en een map met de naam $sessionId
. Dit kan verwarrend zijn, maar is zoals bedoeld. Dit vertelt Remix om de . te gebruiken sessionId.tsx-bestand als de bovenliggende pagina en rendert alle geneste routes van het `sessieIddirectory. Now let’s put in the following code in the
$questionId.tsx` bestand:
import type { LoaderFunction, ActionFunction } from "@remix-run/node"; // or "@remix-run/cloudflare"
import { Form, Link, useLoaderData, useOutletContext, useParams, useTransition,
} from "@remix-run/react";
import type { Comment } from "~/models/session.server";
import { addCommentToAnswer, getCommentsForQuestion,
} from "~/models/session.server";
import invariant from "tiny-invariant";
import { json, redirect } from "@remix-run/node"; import type { OutletContext } from "../../$sessionId";
import { requireUserId } from "~/session.server";
import type { User } from "~/models/user.server";
import { QuestionAnswer } from "~/components/sessions/question-answer";
import { Button } from "~/components/shared/button";
import React, { useEffect, useRef } from "react"; type LoaderData = { comments: Awaited<ReturnType<typeof getCommentsForQuestion>>;
}; type ActionData = { errors?: { title?: string; body?: string; };
}; export const loader: LoaderFunction = async ({ params }) => { invariant(params.questionId); const data: LoaderData = { comments: await getCommentsForQuestion(params.questionId), }; return json(data);
}; export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); invariant(params.sessionId, "sessionId not found"); invariant(params.questionId, "questionId not found"); const formData = await request.formData(); const content = formData.get("content"); if (typeof content !== "string" || content?.trim()?.length < 3) { return json<ActionData>( { errors: { title: "Comment is required" } }, { status: 400 } ); } await addCommentToAnswer({ userId, content, questionId: params.questionId, }); return redirect( `/sessions/${params.sessionId}/questions/${params.questionId}` );
}; export default function SessionQuestion() { const params = useParams(); const commentFormRef = useRef<HTMLFormElement>(null); const transition = useTransition(); const outletData = useOutletContext<OutletContext>(); const data = useLoaderData(); const question = outletData?.questions.find( (q) => q.id === params.questionId ); const isCommenting = transition.state === "submitting"; useEffect(() => { if (!isCommenting) { commentFormRef?.current?.reset(); } }, [isCommenting]); if (!question) return null; const dateFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short", }); return ( <div className="w-1/2"> <div className="pl-8"> <Link to={`/sessions/${params.sessionId}`} className="bg-gray-500 rounded-sm px-2 py-1 text-white flex flex-row justify-between" > <span>Thread</span> <span>✕</span> </Link> <QuestionAnswer question={question} as="div" hideCommentsLink /> <div className="bg-gray-100 p-3 mb-4 rounded"> <Form method="post" ref={commentFormRef}> <label htmlFor="comment" className="block"> <div className="flex flex-row mb-2 items-center"> <img alt="Question logo" src="/icons/comment.svg" width={45} height={45} /> <span className="ml-2 leading-4"> Add a comment <br /> <i className="text-xs text-gray-800"> Please be polite. No explicit content allowed! </i> </span> </div> <textarea rows={5} className="w-full block rounded p-2" name="content" /> </label> <div className="mt-2 flex justify-end"> <Button type="submit" isAction> Comment </Button> </div> </Form> </div> <ul> {data.comments?.map((comment: Comment & { user: User }) => ( <li key={`comment_${comment.id}`} className="mt-4"> <div className="flex flex-row"> <div> <img width={40} height={40} alt="Question icon" className="mr-2" src="/icons/comment.svg" /> </div> <p> <span className="font-semi-bold text-xs text-gray-500"> {comment.user?.name} at{" "} {dateFormatter.format(new Date(comment.createdAt))} </span> <br /> <span className="text-gray-800 text-sm">{comment.content}</span> </p> </div> </li> ))} </ul> </div> </div> );
}
Hier gebruiken we dat question-answer.tsx
component om dezelfde UI-component weer te geven die we onder de sessie laten zien, maar in dit geval bovenaan de opmerkingenreeks, om lezers context te geven voor de opmerkingen. We plaatsen er ook een formulier in waardoor elke geverifieerde gebruiker een opmerking kan plaatsen. Laten we eens kijken naar de 2 nieuwe serverfuncties die we in de loader gebruiken en vervolgens actie voor deze pagina van models/session.server.ts
:
import type { User, Session, Question, Comment } from "@prisma/client"; export const addCommentToAnswer = async ({ questionId, userId, content,
}: Pick<Comment, "questionId" | "userId" | "content">) => { return prisma.comment.create({ data: { questionId, userId, content } });
}; export const getCommentsForQuestion = async (questionId: string) => { return prisma.comment.findMany({ where: { questionId }, include: { user: true }, });
};
Een paar opmerkelijke dingen in dit onderdeel zijn:
- useOutletContext hook: Dit geeft ons toegang tot alle rekwisieten die zijn doorgegeven aan de onderliggende pagina via de
<Outlet … />
component op de bovenliggende pagina. Dus hier hebben we toegang tot de hele sessie met alle vragen erin en in plaats van te zoeken naar de enkele vraag van de thread, kiezen we deze gewoon uit de reeds doorgegeven gegevens. - Opmerkingen laden: we laden alle opmerkingen voor een vraag zonder paginering, wat geen goed idee is voor een productie-app.
Afronden
Als je alle voorgaande stappen hebt gevolgd, open je de app in een incognitovenster en maak je een nieuw account aan. Als u vervolgens in de eerder gemaakte sessie klikt, ziet u een invoerveld om een vraag te stellen:
Als je nu een vraag typt en deze vanuit dat nieuwe account plaatst, zou je zoiets als dit moeten zien:
Die uw opmerking toont, de opmerking opent als een thread aan de rechterkant en u of een andere gebruiker een opmerking aan de thread laat toevoegen.
Als u ten slotte teruggaat naar het andere browservenster waar u bent ingelogd als de host van de sessie en de sessiepagina vernieuwt, zou u de opmerking daar moeten zien met een invoer eronder om uw antwoord te plaatsen:
What’s next?
Je hebt geweldig werk geleverd tot hier, dus geef jezelf een applaus! Als je net als ik bent en nooit genoeg kunt krijgen van glimmende nieuwe JS-dingen, vraag je je misschien af: "Dit is geweldig, maar is dit iets dat ik als gebruiker zou gebruiken?" en als je trouw bent aan jezelf, dan zou het antwoord een dikke vette zijn NO
. Dus ik zal je een paar ideeën geven die deze speelgoed-app snel in elkaar kunnen zetten in een productieklare app die in de echte wereld wat tractie kan krijgen:
- Realtime gegevenssynchronisatie - bij AMA-sessies draait alles om timing. De goede zijn tenminste. Mensen die ze hosten hebben niet de tijd om rond te hangen en elke 10 seconden op vernieuwen te drukken om te zoeken naar nieuwe opmerkingen/vragen enz. Dus al deze zouden in realtime moeten worden gesynchroniseerd en naar de host worden gemarkeerd. Idem voor de deelnemers.
- Paginering - Zoals in de hele post vermeld, hebben we een aantal bochten gemaakt bij het laden van gegevens die zeker niet zullen schalen in een echte wereld-app. Paginering toevoegen aan alle zoekopdrachten zou ook een goede leerervaring zijn.
- Sessietimer en toekomstige sessie: aangezien sessies in deze app per dag in een timebox zitten, kan het tonen van een timer voor het einde van de sessie een spannend element aan de ervaring toevoegen. Een andere geweldige functie zou zijn dat hosts sessies voor de toekomst plannen en er een hype omheen creëren door de aankomende sessie op de startpagina op een meer gemarkeerde manier te presenteren
Resources
- Door SEO aangedreven content en PR-distributie. Word vandaag nog versterkt.
- Platoblockchain. Web3 Metaverse Intelligentie. Kennis versterkt. Toegang hier.
- Bron: https://www.codementor.io/foysalit/build-a-fullstack-ama-app-with-remix-prisma-postgresql-1vsbmepsp3
- 1
- a
- vermogen
- in staat
- Over
- boven
- toegang
- beschikbaar
- toegang
- Volgens
- dienovereenkomstig
- Account
- accuraat
- Actie
- acties
- werkelijk
- toegevoegd
- Extra
- Daarnaast
- adres
- Voegt
- Aanpassing
- Na
- eeuwenoud
- vooruit
- Alles
- Het toestaan
- alleen
- naast
- al
- altijd
- AMA
- AMA-sessies
- verbazingwekkend
- onder
- en
- Nog een
- beantwoorden
- antwoorden
- api
- gebruiken
- Solliciteer
- apps
- argument
- rond
- reeks
- aanwinst
- Activa
- proberen
- Auth
- geverifieerd
- authenticatie
- auteur
- auto
- Beschikbaar
- wachten
- terug
- met een rug
- backend
- baseren
- gebaseerde
- basis-
- Eigenlijk
- De Basis
- omdat
- wordt
- vaardigheden
- achter
- wezen
- onder
- tussen
- Groot
- Beetje
- Blok
- blokkeren
- Blog
- lichaam
- grens
- Onder
- grenzen
- Box camera's
- Breken
- Brengt
- browser
- bouw
- Gebouw
- bebouwd
- Bos
- Bellen
- bellen
- oproepen
- verzorging
- watervallen
- geval
- het worstelen
- gevangen
- zeker
- zeker
- verandering
- Wijzigingen
- tekens
- controle
- controleren
- Controles
- kind
- Kinderen
- Kies
- klasse
- duidelijk
- klant
- code
- Codebasis
- Kolom
- columns
- COM
- commentaar
- opmerkingen
- plegen
- gemeenschap
- concurrentie
- compleet
- ingewikkeldheid
- bestanddeel
- componenten
- concept
- concepten
- verwarrend
- bijgevolg
- troosten
- constante
- permanent
- Containers
- bevat
- content
- verband
- overtuigt
- hoeken
- kon
- Koppel
- cursus
- deksel
- en je merk te creëren
- aangemaakt
- creëert
- Creatieve
- CSS
- Actueel
- Op dit moment
- Snijden
- gegevens
- Database
- Datum
- Data
- datetime
- dag
- toegewijd aan
- deep
- Standaard
- definitief
- verrukt
- diepte
- ontworpen
- gedetailleerd
- gegevens
- Dev
- Ontwikkelaar
- ontwikkelaars
- Ontwikkeling
- DEED
- onderscheiden
- directories
- besproken
- discussie
- Display
- documentatie
- Nee
- doen
- Dont
- beneden
- Val
- dropping
- gedurende
- dynamisch
- elk
- Vroeg
- gemakkelijker
- gemakkelijk
- beide
- eindigt
- engagement
- genoeg
- verzekeren
- waarborgt
- Geheel
- entiteiten
- entiteit
- toegang
- fout
- fouten
- in wezen
- etc
- Ether (ETH)
- Zelfs
- iedereen
- alles
- evolueerde
- precies
- voorbeeld
- Behalve
- bestaand
- ervaring
- exporteren
- export
- expressief
- verlengen
- extern
- oog
- vergemakkelijken
- eerlijk
- Vallen
- vertrouwd
- vertrouwd raken
- familie
- Mode
- SNELLE
- Vet
- Kenmerk
- Voordelen
- Fed
- Voeten
- weinig
- veld-
- Velden
- Dien in
- Bestanden
- vullen
- gevuld
- Tot slot
- VIND DE PLEK DIE PERFECT VOOR JOU IS
- Voornaam*
- First Look
- Focus
- volgen
- gevolgd
- volgend
- fonts
- vreemd
- formulier
- gevonden
- Achtergrond
- Gratis
- veelvuldig
- vers
- vriendelijk
- oppompen van
- vol
- geheel
- leuke
- functie
- functies
- toekomst
- Krijgen
- Algemeen
- krijgen
- Git
- GitHub
- Geven
- geeft
- Vrijgevigheid
- Globaal
- Go
- Goes
- gaan
- goed
- Kopen Google Reviews
- google lettertypes
- groot
- grondslag
- Groeit
- handvat
- Behandeling
- handig
- Hangen
- hachee
- hoofd
- gezond
- hulp
- helpt
- hier
- Hoge
- hoger
- Gemarkeerd
- historisch
- Hit
- Hits
- Home
- hoop
- gastheer
- Hosting
- Huis
- Hoe
- How To
- Echter
- HTML
- HTTPS
- Honderden
- Hype
- ICON
- idee
- ideeën
- identificeren
- afbeeldingen
- uitvoeren
- uitvoering
- geïmplementeerd
- importeren
- in
- omvatten
- inclusief
- omvat
- Inclusief
- ongelooflijk
- index
- individueel
- info
- eerste
- invoer
- inzicht
- installeren
- installeren
- verkrijgen in plaats daarvan
- geïnteresseerd
- geïntroduceerd
- intuïtief
- investering
- oproept
- geïsoleerd
- IT
- zelf
- Jobomschrijving:
- json
- springen
- Houden
- sleutel
- Soort
- blijven
- label
- Landschap
- Groot
- Achternaam*
- Afgelopen jaar
- Layout
- leiden
- leren
- Verlof
- Nalatenschap
- Lengte
- Laten we
- Niveau
- LG
- Vrijheid
- Bibliotheek
- Lijn
- lijnen
- LINK
- links
- Lijst
- Elke kleine stap levert grote resultaten op!
- laden
- lader
- het laden
- ladingen
- logo
- lang
- Kijk
- ziet eruit als
- LOOKS
- lot
- laagste niveau
- magie
- onderhoudt
- groot
- maken
- maken
- gemanipuleerd
- manipuleren
- handmatig
- veel
- kaart
- Maps
- Match
- middel
- vermeld
- Bericht
- methode
- methoden
- macht
- trekken
- migratie
- model
- module
- meer
- meest
- beweging
- bewegend
- mozilla
- meervoudig
- naam
- Genoemd
- OP DEZE WEBSITE VIND JE
- Noodzaak
- behoeften
- New
- volgende
- Next.js
- knooppunt
- Node.js
- een
- opvallend
- opmerkelijk
- aantal
- object
- objecten
- opgetreden
- officieel
- EEN
- open
- opent
- operatie
- Operations
- tegenover
- Keuze
- bestellen
- Overige
- anders-
- schets
- buiten
- het te bezitten.
- Opdelen
- paradigma
- parameter
- deel
- deelnemers
- voorbij
- passes
- Wachtwoord
- verleden
- Mensen
- perspectief
- kiezen
- stuk
- stukken
- plaatsen
- plaatsing
- platform
- Plato
- Plato gegevensintelligentie
- PlatoData
- Spelen
- dan
- plugins
- punt
- Oogpunt
- Post
- geplaatst
- postgresql
- energie
- krachtige
- de voorkeur geven
- bij voorkeur
- mooi
- voorkomen
- vorig
- die eerder
- primair
- geprioriteerd
- Prisma
- waarschijnlijk
- problemen
- Product
- productie
- project
- eigendom
- zorgen voor
- mits
- biedt
- volmacht
- publiek
- gepubliceerde
- doel
- doeleinden
- geduwd
- zetten
- Putting
- Q & A
- kwaliteiten
- vraag
- Contact
- Quick
- snel
- Reageren
- Lees
- Lezer
- lezers
- lezing
- klaar
- vast
- echte wereld
- realtime
- ontvangen
- ontvangt
- adviseren
- redirect
- verminderen
- referenties
- achteloos
- registreren
- geregistreerd
- registreren
- verwant
- verwantschap
- Relaties
- relatief
- betrouwbaarheid
- betrouwbaar
- Remix
- weergave
- vervangen
- te vragen
- verzoeken
- nodig
- vereist
- Resources
- antwoord
- REST
- beperken
- terugkeer
- Retourneren
- revolutie
- wortel
- ronde
- weg
- router
- wegen
- RIJ
- lopen
- lopend
- veilig
- sake
- dezelfde
- schaalbare
- Scale
- rooster
- omvang
- Tweede
- te zien
- zin
- Sessie
- sessies
- setup
- gedeeld
- VERSCHUIVEN
- Bermuda's
- moet
- tonen
- presentatie
- Shows
- teken
- Signaal
- het ondertekenen van
- Eenvoudig
- vereenvoudigen
- vereenvoudigen
- eenvoudigweg
- sinds
- single
- Langzaam
- Klein
- So
- OPLOSSEN
- sommige
- iets
- SPA
- Tussenruimte
- special
- specifiek
- snelheid
- spinnen
- spleet
- Spot
- gesponnen
- stack
- Stacks
- standaard
- begin
- gestart
- Start
- starts
- Land
- Staten
- Status
- Stappen
- Still
- stop
- stoppen
- shop
- Strategie
- stream
- stijl
- voorlegging
- voorleggen
- ingediend
- succes
- dergelijk
- OVERZICHT
- Super
- ondersteuning
- steunen
- SVG
- syntaxis
- system
- tafel
- Tailwind
- Nemen
- neemt
- het nemen
- team
- Technisch
- vertelt
- sjabloon
- terminal
- Testen
- De
- The Basics
- Het landschap
- hun
- thema
- ding
- spullen
- het denken
- duizenden kosten
- Door
- overal
- Het werpen
- niet de tijd of
- timing
- type
- Titel
- naar
- vandaag
- samen
- ook
- tools
- top
- onderwerp
- Totaal
- opsporen
- tractie
- traditioneel
- overgang
- waar
- zelfstudie
- tv
- getypte tekst
- ui
- voor
- Onverwacht
- komende
- bijwerken
- bijwerken
- URL
- us
- .
- Gebruiker
- gebruikers
- doorgaans
- ux
- BEVESTIG
- gevalideerd
- bevestiging
- waarde
- divers
- via
- Bekijk
- .
- wachten
- manieren
- web
- Webontwikkeling
- Website
- Wat
- Wat is
- welke
- en
- WIE
- wil
- Draad
- binnen
- zonder
- afvragen
- Mijn werk
- werkte
- werkzaam
- wereld
- de moeite waard
- zou
- het schrijven van
- X
- jaar
- Your
- jezelf
- zephyrnet