Publicat inițial aici
Remix este un cadru JS relativ nou, full-stack, susținut de unii dintre giganții din comunitatea JS, cum ar fi Kent C. Dodds, Ryan T. Florenţa și Michael Jackson. Până la apariția Next.js, reunirea diferitelor instrumente pentru a vă construi SPA a fost modalitatea de facto de a crea aplicații JS. Next.js a revoluționat într-o oarecare măsură acest lucru și a rămas de neegalat pentru o vreme. Cu toate acestea, peisajul se schimbă rapid în ultimul an și ceva, cu o concurență sănătoasă din partea RedwoodJ, BlitzJ și acum Remix. Toate aceste instrumente încearcă să rezolve unele dintre problemele vechi ale dezvoltării web într-un mod mai creativ, fiabil și cel mai important, mod prietenos pentru dezvoltatori, astfel încât construirea unei aplicații web performante să devină implicită pentru dezvoltatorii JS.
Este cu siguranță foarte devreme să identificăm un câștigător clar printre toate aceste instrumente din acest spațiu, dar Remix arată cu siguranță ca un concurent demn. Așadar, dacă nu ți-ai udat deja picioarele în minunatia care este Remix, sper că acest tutorial te va ajuta să începi și să te convingă să-l alegi pentru ceea ce vei construi în continuare!
Vedere de ochi de păsări
În această postare, vă voi ghida prin construirea unei aplicații AMA (Ask Me Anything) folosind Remix. Mai jos este o listă a instrumentelor principale pe care le vom folosi pentru a construi această aplicație. Cu siguranță va fi mai ușor pentru cititor să urmărească dacă este familiarizat cu elementele de bază ale unora dintre instrumente (cu excepția Remixului, desigur), dar nu vă faceți griji prea mult dacă nu.
- Remix – cadru primar
- React – cadru UI
- Prisma – Baza de date ORM
- PostgreSQL – Baza de date
- TailwindCSS – cadru CSS
Aceasta va fi o postare lungă, așa că vă recomand să urmăriți în mai multe ședințe și pentru a vă ajuta să decideți dacă citirea întregului lucru este o investiție utilă sau nu, iată o schiță a ceea ce vom face/învăța pe parcursul întregul, în ordine cronologică:
- Specificații aplicației – Subliniați caracteristicile aplicației pe care o vom construi de la un nivel superior.
- Începeți cu Remix – Urmăriți în principal documentele oficiale și instalând câteva lucruri.
- Schema bazei de date - Configurați schema bazei de date care poate suporta tot conținutul dinamic necesar aplicației noastre.
- CRUD – Operațiuni CRUD de bază în mod standard de Remix.
- UI/UX – Stropiți puțin Tailwind pentru a face lucrurile să arate frumos și frumos.
După cum vă puteți da seama, avem multe de acoperit, așa că, haideți să ne scufundăm. Oh, înainte de asta, dacă sunteți nerăbdător ca mine și doriți doar să vedeți codul, iată întreaga aplicație în github: https://github.com/foysalit/remix-ama
Specificații aplicației
În orice proiect, dacă știi exact ce vei construi, devine mult mai ușor să navighezi în peisaj de la început. Poate că nu aveți întotdeauna această libertate, dar, din fericire, în cazul nostru, cunoaștem toate funcțiile de care avem nevoie pentru aplicația noastră. Înainte de a enumera metodic toate caracteristicile din perspectivă tehnică, să le privim din punct de vedere general al produsului.
Sesiunea AMA
Un utilizator al aplicației noastre ar trebui să poată găzdui mai multe sesiuni AMA. Cu toate acestea, nu are sens să găzduiești mai multe sesiuni în aceeași zi, așa că haideți să limităm durata unei sesiuni la o zi întreagă și să permitem doar 1 sesiune per utilizator pe zi.
Q & A
Un utilizator al aplicației noastre ar trebui să poată pune o întrebare unei gazde în timpul unei sesiuni AMA în curs de desfășurare. Pentru a crea exclusivitate, să blocăm utilizatorii să pună întrebări după încheierea sesiunii. Desigur, gazda sesiunii ar trebui să poată răspunde la întrebările puse în sesiunile lor.
Comentarii
Pentru a crea mai multă implicare și pentru a face lucrurile puțin mai distractive decât întrebările și răspunsurile tradiționale, să adăugăm o funcție de fir de comentarii care permite oricărui utilizator să adauge un comentariu la o întrebare. Aceasta poate fi folosită pentru a adăuga mai mult context unei întrebări deja puse sau pentru a avea o discuție despre răspunsul oferit de gazdă etc.
Acum să dezvăluim cum le vom implementa:
Autentificare – Utilizatorii trebuie să se poată înregistra pentru a găzdui o sesiune AMA, să pună o întrebare unei gazde sau să comenteze într-un thread. Cu toate acestea, să nu împiedicăm un utilizator neautentificat să vadă o sesiune care rulează deja. Pentru autentificare, să folosim adresa de e-mail și parola. În plus, atunci când vă înscrieți, să cerem utilizatorului să introducă numele complet pentru a fi folosit peste tot în aplicație. O entitate de utilizator va fi utilizată pentru stocarea datelor legate de autentificare.
Sesiuni – Afișați o listă cu toate sesiunile curente și trecute într-o pagină de index tuturor utilizatorilor (autentificați/neautentificați), care le va permite să facă clic în fiecare sesiune și să vadă întrebări/răspunsuri/comentarii etc. Utilizatorii autentificați pot începe o nouă sesiune dacă există deja nu este unul pentru ziua aceea. Să cerem gazdei să ofere un context/detalii pentru fiecare sesiune atunci când începem una. Fiecare sesiune este o entitate care aparține unui utilizator.
Întrebări – Fiecare sesiune individuală poate avea mai multe întrebări de la orice utilizator înregistrat, cu excepția gazdei. Entitatea întrebare va conține, de asemenea, răspunsul de la gazdă în baza de date și fiecare răspuns introdus va fi validat pentru a se asigura că autorul este gazda sesiunii. Entitatea aparține unei sesiuni și unui utilizator. Să ne asigurăm că un utilizator poate pune o singură întrebare pe sesiune, așa că până când pune o întrebare, să arătăm o introducere de text fiecărui utilizator. Sub fiecare întrebare la care s-a răspuns, să arătăm gazdei un text introdus pentru a adăuga răspunsul acestuia.
Comentarii – Fiecare întrebare (la care se răspunde sau nu) poate avea mai multe comentarii. Pentru a reduce complexitatea, să nu adăugăm threading în comentarii deocamdată. Fiecare utilizator poate posta mai multe comentarii la o întrebare, așa că haideți să arătăm întotdeauna textul introdus pentru comentarii tuturor utilizatorilor la fiecare întrebare. Pentru a simplifica interfața de utilizare, să arătăm implicit lista de întrebări (și răspunsuri) pe pagina sesiunii și să adăugăm un link pentru a deschide firul de comentarii într-o bară laterală.
Începeți cu Remix
Remixul are multe calități excelente, dar documentarea ocupă probabil primul loc. Un cadru aflat în curs de dezvoltare abundentă este obligat să aibă multe piese în mișcare care sunt în mod constant evoluate de către întreținători, astfel încât documentația este obligată să rămână în urmă pe măsură ce caracteristicile primesc prioritate. Cu toate acestea, echipa Remix are mare grijă să mențină documentația la zi și să fie sincronizată cu fluxul constant de modificări uimitoare care sunt transmise. Deci, pentru a începe, desigur, documente oficiale va fi primul nostru punct de intrare.
Dacă ți-e prea lene să mergi pe un alt site web și să citești un alt perete de text, nu-ți face griji. Iată tot ce trebuie să faci pentru a instala Remix:
- Asigurați-vă că aveți configurarea Node.js development env.
- Deschideți fereastra Terminal și executați următoarea comandă
npx create-remix@latest
. - Doneaza.
Remixul nu îți oferă doar o grămadă de instrumente și îți cere să te duci să-ți construiești lucrul, ci dau exemplul, motiv pentru care au conceptul de Stive. Stivele sunt în esență șabloane/kit-uri de pornire care vă oferă bazele pentru un proiect complet imediat din cutie. Pentru proiectul nostru, vom folosi Blues Stack care ne oferă un proiect Remix complet configurat cu Prisma, Tailwind și un modul întreg care arată cum să folosim acele instrumente pentru a construi o caracteristică CRUD. Adică sincer, simt că nici nu ar trebui să scriu această postare, deoarece șablonul a făcut deja toată treaba. Ei bine... Sunt prea adânc acum, așa că ar fi bine să-l termin.
Tot ce trebuie să faceți este să rulați comanda npx create-remix --template remix-run/blues-stack ama
în terminalul dvs. și Remix va plasa întregul proiect într-un folder nou numit ama
după ce ai răspuns la câteva întrebări.
Acum să deschidem ama
folder și să ne familiarizăm puțin cu conținutul din interior. Există o grămadă de fișiere de configurare în rădăcină și nu vom intra în majoritatea acestora. Suntem în mare parte interesați de prismă, public și aplicaţia directoare. Directorul prisma va conține schema și migrarea bazei noastre de date. Directorul public va conține orice activ de care are nevoie aplicația, cum ar fi pictograme, imagini etc. În cele din urmă, directorul aplicației va găzdui tot codul nostru, atât client, cât și server. Da, ai citit bine, atât client cât și server. Dacă acest lucru vă oferă flashback-uri majore ale bazei de cod vechi, vă rugăm să știți că nu sunteți singuri.
Înainte de a începe să scriem propriul cod al aplicației, să verificăm totul în git, astfel încât să ne putem urmări modificările din ceea ce a fost deja făcut pentru noi prin remix blues stack.
cd ama
git init
git add .
git commit -am ":tada: Remix blues stack app"
Și, în sfârșit, să rulăm aplicația și să vedem cum arată înainte să atingem ceva. Fișierul README.md conține deja toți pașii detaliați care ar trebui să vă ajute în acest sens și, deoarece aceștia sunt supuși unor modificări frecvente, voi face legătura cu pașii în loc să îi scriu aici. https://github.com/remix-run/blues-stack#development
Dacă urmați întocmai pașii, aplicația ar trebui să fie accesibilă la http://localhost:3000
Stiva vine cu un modul de note implicit cu care vă puteți juca după ce vă înregistrați cu e-mailul și parola.
Schema bazei de date
De obicei, îmi place să încep să mă gândesc la o caracteristică/entitate din schema bazei de date și să merg până la interfața de utilizare, unde datele sunt interpretate, afișate și manipulate în diferite moduri. Odată ce ați elaborat schema, devine mult mai ușor să treceți rapid prin acea implementare.
După cum sa discutat mai sus în specificațiile aplicației, avem nevoie de 3 entități în baza noastră de date: Sesiune, Întrebare și Comentariu. De asemenea, avem nevoie de o entitate User pentru a stoca fiecare utilizator înregistrat, dar stiva de blues de la Remix o include deja. Trebuie doar să-l modificăm ușor pentru a adăuga un name
coloană. Să deschidem fișierul prisma/schema.prisma
și adăugați rândurile de mai jos la sfârșitul fișierului:
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
}
Și apoi adăugați această linie în definiția User
model:
model User { … name String sessions Session[] questions Question[] comments Comment[] …
}
Acum sunt multe de despachetat aici, dar cea mai mare parte este în afara domeniului de aplicare al acestei postări. Această definiție a schemei este tot ce avem nevoie pentru ca prisma să se ocupe de construirea tuturor tabelelor cu coloanele potrivite pentru cele 3 noi entități de care avem nevoie. Cum funcționează definițiile și sintaxa, ar trebui să accesați acest link https://www.prisma.io/docs/concepts/components/prisma-schema si citeste putin. Un rezumat la nivel înalt este:
- O definiție de entitate/tabel începe cu
model <EntityName> {}
iar în interiorul acoladelor merg toate coloanele/proprietățile entității și relațiile cu celelalte entități. Deci, ar arăta un tabel pentru comentariimodel Comment {}
- Definițiile coloanelor arată de obicei ca
<columnName> <columnType> <default/relationship/other specifiers>
. Deci, dacă entitatea noastră de comentarii necesită o coloană pentru a stoca conținutul comentariului introdus de utilizator, ar arăta
model Comment { content String
}
- Relațiile dintre 2 tabele/entități sunt de obicei definite printr-o coloană cu cheie străină, astfel încât acestea sunt, de asemenea, definite alături de alte coloane. Definiția necesită de obicei 2 rânduri. O coloană care conține id-ul cheii străine și cealaltă pentru a specifica numele folosit pentru a accesa entitatea asociată, care arată de obicei astfel:
<entity> <entityName> @relation(fields: [<foreignKeyColumnName>], references: [id], onDelete: Cascade, onUpdate: Cascade)
. Deci, pentru a lega entitatea comentariu la entitatea întrebare cu o relație unu-la-mulți, trebuie să o definim ca
model Comment { content String question Question @relation(fields: [questionId], references: [id], onDelete: Cascade, onUpdate: Cascade) questionId String
}
Cele de mai sus nici măcar nu acoperă vârful aisbergului care este prismă, așa că te rog, te rog, citește despre el din documentele lor oficiale și vei vedea adevărata sa putere. De dragul acestei postări pe blog, cele de mai sus ar trebui să vă ofere o idee despre motivul pentru care avem nevoie de schema prismă de mai sus.
Trebuie să facem o ultimă ajustare legată de baza de date. Împreună cu întregul sistem de autentificare, stiva de blues include și un seeder inițial de date care populează baza de date cu un utilizator fals în scopuri de testare. Din moment ce am introdus o nouă rubrică name
în tabelul de utilizatori, trebuie, de asemenea, să ajustăm semănătorul pentru a adăuga un nume fals utilizatorului. Deschideți fișierul prisma/seed.js
și modificați codul de inserare al utilizatorului după cum urmează:
const user = await prisma.user.create({ data: { Email, name: 'Rachel Remix', password: { create: { hash: hashedPassword, }, }, }, });
Cu asta, suntem în sfârșit gata să sincronizăm toate aceste modificări cu baza noastră de date. Cu toate acestea, din moment ce baza noastră de date a fost deja creată cu o schemă creată anterior și câteva date însmânțate și de atunci, db-ul nostru s-a schimbat, nu ne putem sincroniza cu adevărat toate modificările imediat. În schimb, va trebui să ajustam puțin migrarea. Prisma oferă comenzi pentru acest tip de ajustări dar, din fericire, datele și schema noastră existente nu sunt în producție sau nimic, așa că în acest moment, este mai ușor să distrugem db-ul și să începem din nou cu schema noastră actuală. Deci, să mergem cu ruta mai ușoară și să rulăm aceste comenzi:
./node_modules/.bin/prisma migrate reset
./node_modules/.bin/prisma migrate dev
Prima comandă resetează db-ul nostru, iar a doua folosește definiția curentă a schemei pentru a recrea db-ul cu toate tabelele și îl populează cu date seeded.
Acum, să oprim serverul de aplicații care rulează, să re-configuram aplicația și să o facem înapoi
npm run setup
npm run dev
Actualizați înregistrarea utilizatorului
Deoarece am adăugat o nouă coloană cu nume în tabelul de utilizatori, să începem prin a le cere utilizatorilor să-și completeze numele atunci când se înregistrează. Acest lucru ne va oferi o intrare frumoasă în modul remix de a face lucrurile fără a face un șoc mare dacă sunteți în mare parte familiarizați cu modul obișnuit al reacției de a crea aplicații.
Codul pentru înregistrarea utilizatorului poate fi găsit în ./app/routes/join.tsx
fişier. Deschide-l și chiar sub <Form>
component următorul cod pentru a adăuga câmpul de intrare pentru nume:
<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>
Practic, imită câmpul de e-mail deja existent. Acum, trebuie să mai ajustăm câteva lucruri aici pentru a ne asigura că introducerea numelui este gestionată corect. Mai întâi, să creăm o referință la câmpul de nume și dacă există o eroare în gestionarea introducerii numelui, dorim să focalizăm automat acel câmp la fel ca și celelalte câmpuri din formular.
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]);
Acum ce este actionData
? Este pur și simplu răspunsul returnat de la server din cererea de trimitere. Orice acțiune de trimitere a formularului va trimite cererea de postare din browser către server, iar remixul o va gestiona prin intermediul action
funcție definită chiar deasupra componentei. Această funcție primește un obiect cu o proprietate de solicitare care vă oferă câteva metode foarte utile de a accesa datele trimise din browser și puteți returna un răspuns de la această funcție pe care codul browserului îl poate gestiona în consecință. În cazul nostru, dorim să validăm datele trimise și să ne asigurăm că câmpul de nume este de fapt completat. Așadar, iată modificările de care avem nevoie în action
funcţie:
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 } ); }
Ceea ce se rezumă la, preluarea numelui introdus din cererea de trimitere a formularului și apoi returnează un mesaj de eroare dacă numele nu este completat. Deoarece datele de returnare sunt tastate prin intermediul ActionData
tip, trebuie să ajustam definiția și să adăugăm proprietatea nume:
interface ActionData { errors: { email?: string; name?: string; password?: string; };
}
Am tratat doar cazurile de introducere incorecte, așa că haideți și să ne asigurăm că, în cazul introducerii corecte, numele utilizatorului este inserat în proprietatea coloanei prin actualizarea liniei const user = await createUser(email, password);
la const user = await createUser(email, password, name);
și, în consecință, trebuie să ajustăm definiția createUser
în app/models/user.server.ts
fișier:
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, }, }, }, });
}
Câteva lucruri de remarcat aici:
- Pentru a păstra codul specific serverului izolat și departe de client, putem sufix fișierele cu
.server.ts
. - Folosim un API prisma foarte expresiv și intuitiv pentru a introduce cu ușurință un nou rând în db. Aceasta ia de obicei forma
prisma.<entityName>.<actionName>({})
UndeentityName
este numele tabelului cu litere mici șiactionName
este operația db, cum ar fi create, update, findOne etc. Vom vedea mai multă utilizare a acestora în curând.
Cu asta tocmai am adăugat o nouă intrare de nume care va fi validată când utilizatorul atinge Create Account
.
Acesta este probabil un bun punct de oprire pentru a verifica modificările noastre pe git, așa că haideți să comităm codul nostru: git add . && git commit -am “:sparkles: Add name field to the sign up form”
Sesiuni
Până acum am ajustat în mare parte codul existent aici și acolo pentru a obține o perspectivă asupra modului în care Remix face lucrurile. Acum ajungem să ne aprofundăm în construirea propriului nostru modul de la zero. Primul lucru pe care îl vom construi este o modalitate prin care utilizatorii pot găzdui o sesiune AMA în conformitate cu definiția inițială a specificațiilor aplicației.
În remix, rutele URL sunt bazate pe fișiere. Adică, inventează aproape o nouă paradigmă, simplificând-o până la file based routing
probabil că nu este foarte precis sau corect, dar vom intra încet în ea. Pentru a începe cu sesiunile, vrem
- O pagină cu listă în care sunt listate toate sesiunile curente și istorice
- O pagină dedicată per sesiune în care sunt afișate toate întrebările, răspunsurile și firele de comentarii
- O pagină pentru a începe o nouă sesiune pentru orice utilizator conectat
Să începem cu pagina cu listă. Creați un fișier nou în app/routes/sessions/index.tsx
și pune următorul cod în interiorul acestuia:
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> </> );
}
Dacă sunteți familiarizat cu react, acest lucru ar trebui să vă pară familiar, în cea mai mare parte. Cu toate acestea, să o descompunem bucată cu bucată. Remix va reda componenta implicită exportată. Deasupra definiției componentei, avem a loader
funcţie. Aceasta este o funcție specială pe care o puteți avea doar 1 pe rută/fișier și la încărcarea paginii, Remix va apela această funcție pentru a prelua datele de care are nevoie pagina dvs. Apoi îți va hidrata componenta cu datele și va trimite HTML redat prin fir ca răspuns, care este unul dintre comportamentele magice sau Remix. Acest lucru asigură că utilizatorii nu trebuie să vadă o stare de încărcare, deoarece codul JS al browserului dvs. încarcă date din solicitările API. Corpul funcției de acțiune cheamă la a getSessions()
funcția din care este importată ~/models/session.server
. Aici, urmăm strategia deja discutată de a pune operațiunile db în fișierele numai pe server. Să creăm noul fișier în app/models/session.server.ts
și pune următorul cod în el:
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 }, }, }, });
};
Pur și simplu preia toate intrările din tabelul de sesiune și toate intrările de utilizator legate de acestea, deoarece vom folosi informațiile gazdei pe UI și include, de asemenea, numărul total de întrebări pe care le are fiecare sesiune. Acest lucru nu este super scalabil, deoarece pe măsură ce aplicația noastră crește, ar putea exista sute de mii de sesiuni AMA și recuperarea tuturor nu se va scala bine. Cu toate acestea, în scopul acestei postări, vom sări peste paginare pentru moment.
Să sărim înapoi în noul nostru sessions/index.tsx
fișier de rută. Dacă nu există sesiuni în baza de date, returnăm un răspuns de eroare 404 folosind Response
ajutor de la Remix. În caz contrar, returnăm un răspuns JSON care conține matricea de sesiuni folosind json
ajutor de la Remix.
const data = useLoaderData<LoaderData>();
apelează un hook special Remix care ne oferă acces la datele din răspunsul trimis înapoi de la action
. S-ar putea să vă întrebați cum gestionăm răspunsul la eroare? Cu siguranță nu este manipulat în corpul SessionIndexPage
funcţie. Remixul folosește mult disponibil ErrorBoundary
caracteristică pentru gestionarea vizualizărilor erorilor. Tot ce trebuie să facem este să exportăm o componentă react numită CatchBoundary
dintr-un fișier de rută și orice eroare aruncată de la redarea rutei (client sau server) CatchBoundary
componenta va fi redată. Să definim acest lucru foarte repede deasupra 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() {
…
Aceasta înseamnă pur și simplu redarea unei componente de antet partajate și un link către începerea unei noi sesiuni. De asemenea, folosește un shared Button
componentă. Să construim aceste componente partajate. O să le punem în app/components/shared/
director. Să începem cu app/components/shared/header.tsx
fișier:
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> );
};
Aceasta este o componentă de reacție de bază, cu un stil presărat. Noi folosim Link
componentă de la Remix (care este practic doar un proxy pentru Link
componentă de la react-router) pentru a face legătura la pagina cu lista de sesiuni. Un alt lucru notabil aici este că folosim un font-cursive
stil pe textul antetului pentru a-l face să semene un pic cu un logo. Stilul de font cursiv nu este inclus în configurația implicită tailwind, așa că va trebui să-l configuram singuri. Deschide tailwind.config.js
fișier de la rădăcina proiectului și ajustați theme
proprietate ca mai jos:
module.exports = { content: ["./app/**/*.{ts,tsx,jsx,js}"], theme: { extend: { fontFamily: { cursive: ["Pinyon Script", "cursive"], }, }, }, plugins: [],
};
Observați că bitul suplimentar extinde tema pentru a adăuga o nouă fontFamily cu numele cursive
iar valoarea este Pinyon Script
Am ales acest lucru din fonturile Google, dar nu ezitați să vă alegeți propriul font. Dacă nu sunteți foarte familiarizat cu vântul din spate, acest lucru ne oferă doar posibilitatea de a aplica această familie de fonturi pe un text folosind font-cursive
clasă helper, dar încă trebuie să încărcăm fontul în sine pe pagina noastră web. Adăugarea de active externe la Remix este destul de simplă. Deschide app/root.tsx
fișier și actualizați links
definiție pentru a adăuga 3 obiecte noi la matrice:
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", }, ];
};
Toate linkurile de mai sus sunt preluate de pe Google pagina cu fonturi aici.
Urmărindu-ne pașii înapoi la sessions/index.tsx
fișier, cealaltă componentă partajată este componenta buton. Să-l creăm foarte repede 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> );
};
Aceasta este o componentă de buton simplă care ne va ajuta să unificăm aspectul și senzația butoanelor care sunt fie butoane de legătură, fie butoane de acțiune în diferite locuri ale aplicației. Pentru a face tipul de componentă sigur în timp ce acceptăm elemente de recuzită pentru buton și link, aplicăm o oarecare magie dactilografiată elementelor de recuzită și redării.
În cele din urmă, ne uităm la codul real al componentei paginii în sine. Pagina cartografiază toate intrările de sesiune și arată data sesiunii, numele gazdei sesiunii, premisa/detaliul adăugat de gazdă pentru sesiune și un număr total al câte întrebări există. Pentru a reda datele, folosim browserul încorporat Modulul Intl care acceptă formatarea bazată pe locale. Folosim o pictogramă svg mică lângă numărul de întrebări. Puteți găsi toate activele utilizate în aplicație aici https://github.com/foysalit/remix-ama/tree/main/public/icons dar simțiți-vă liber să utilizați propriile pictograme după cum doriți. Toate bunurile publice trebuie adăugate la /public
folder și pentru a păstra toate pictogramele împreună, am creat un director de pictograme.
Cu toate cele de mai sus, acum ar trebui să puteți merge la http://localhost:3000/sessions url și vedeți pagina de eroare 404, deoarece nu am creat încă nicio sesiune.
Acum, să construim noua pagină de sesiune, astfel încât să putem găzdui o sesiune și să vedem asta pe pagina cu listă. Vom pune asta într-o altă pagină, astfel încât utilizatorii să poată accesa cu ușurință /sessions/new
pe aplicația noastră și începeți să găzduiți o sesiune. Creați un fișier nou routes/sessions/new.tsx
cu următorul cod:
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> )} </> );
}
În mod obișnuit, să defalcăm această bucată mare de cod.
- Acțiune – Când utilizatorul completează detaliile sesiunii și accesează
Start Session
dorim să primim datele formularului ca cerere POST și să creăm o nouă sesiune pentru utilizatorul conectat în prezent. Deci, acțiunea începe curequireUserId(request)
Verifica. Este o metodă de ajutor care vine cu stiva și pur și simplu redirecționează utilizatorii neautorizați către pagina de conectare sau returnează id-ul utilizatorului autorizat. Apoi, preluăm intrarea utilizatorului pentru sesiunecontent
coloana folosindrequest.formData()
care ne oferă acces la toate datele POST. Dacă conținutul nu este completat sau depășește o anumită lungime, returnăm un mesaj de eroare. În caz contrar, începem sesiunea și direcționăm utilizatorul către pagina de sesiune nou creată. - startSessionsForUser – Aceasta este o funcție doar de server care creează o nouă intrare de sesiune în baza de date. Să adăugăm asta la noi
models/session.server.ts
fișier:
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 } });
};
Această funcție primește un ID de utilizator și conținutul sesiunii. Dacă există deja o sesiune creată de utilizator în limitele zilei de astăzi, atunci ea aruncă o eroare, în caz contrar, creează o nouă intrare de sesiune. Manipularea datelor este oarecum ciudată în JS, așa că prefer să introduc o bibliotecă în proiectul meu pentru gestionarea datelor. In acest caz folosesc date-fns lib dar nu ezitați să utilizați lib-ul preferat.
- Loader: dorim doar utilizatorii autorizați să vadă această pagină, astfel încât încărcătorul pur și simplu rulează
requireUserId()
funcție care va deconecta utilizatorii neautentificați și îi va împiedica să vadă formularul de creare a sesiunii. - Tranziție – Remix vine cu un foarte util
useTransition()
cârlig care vă oferă acces la diferite stări ale unei pagini. Pe măsură ce trimiteți un formular dintr-o pagină, trimiteți date către server și așteptați răspunsul,transition.state
se va schimba însubmitting
pe tot parcursul acelei durate. Folosind aceasta, dezactivăm butonul de trimitere pentru a împiedica utilizatorii să încerce accidental să creeze mai multe sesiuni. - Gestionarea erorilor – Pe măsură ce utilizatorii încearcă să înceapă o sesiune, primim fie o eroare de validare pentru câmpul de conținut, fie primim o eroare specifică dacă există deja o sesiune în curs de desfășurare, le gestionăm pe ambele prin afișarea UI a mesajului de eroare accesând datele din
useActionData()
. - Componenta de formă – The
Form
componenta din remix este doar un mic zahăr sintactic deasupra componentei de formular a browserului. Menține tot comportamentul implicit al unui formular. Puteți citi mai detaliat aici: https://remix.run/docs/en/v1/guides/data-writes#plain-html-forms
Dacă ați urmat toți pașii de mai sus, deschideți http://localhost:3000/sessions/new în browser și ar trebui să vedeți o pagină ca mai sus. Cu toate acestea, dacă completați câmpul de introducere și apăsați Start Session, veți ajunge la o pagină 404 negăsită, dar asta nu înseamnă că butonul nu a funcționat. Puteți reveni manual la http://localhost:3000/sessions și vedeți sesiunea nou creată de dvs. pe pagina cu listă. Ceva de genul:
Q & A
Cu lista de sesiuni și crearea de pagini care funcționează bine, acum putem construi întrebări și răspunsuri pe sesiune. Fiecare sesiune ar trebui să fie accesibilă prin sessions/:sessionId
url unde :sessionId
este o variabilă care va fi înlocuită cu ID-urile sesiunilor. Pentru a mapa parametrul de rută dinamic la un fișier de rută în Remix, trebuie să începem numele fișierului cu $
semn cu sufixul denumirii parametrului. Deci, în cazul nostru, să creăm un fișier nou routes/sessions/$sessionId.tsx
cu următorul cod:
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}`);
}
Cu acesta, vom trece rapid prin câteva dintre conceptele pe care le-am discutat deja și ne vom concentra mai mult asupra noilor biți:
- Loader: returnează intrarea în sesiune și id-ul utilizatorului curent. Invocă un apel către
invariant
care este o bibliotecă externă pentru a verifica cu ușurință dacă o variabilă este adevărată și pentru a arunca o eroare dacă nu. - getSession: primește sessionId ca singur argument. Să-l implementăm în nostru
models/session.server.ts
fișier:
export const getSession = (id: Session["id"]) => prisma.session.findFirst({ where: { id }, include: { questions: { include: { user: true, }, }, user: true, }, });
Observa cum include toate întrebările aparținând unei sesiuni și utilizatorii care au pus acele întrebări.
- Acțiune: această pagină poate face 2 lucruri în funcție de cine o vede. Gazda sesiunii poate răspunde la orice întrebare, dar nu poate pune o întrebare. Toți ceilalți utilizatori pot face doar invers. Deci, acțiunea trebuie să gestioneze ambele acțiuni, iar modul în care facem diferența între cele două este prin intermediul
formData.get("answer_to_question")
intrare. Din partea clientului, vom trimite acest lucru numai atunci când gazda trimite un răspuns la o întrebare. Observa cum redirecționăm utilizatorul/sessions/${params.sessionId}/questions/${questionId}
in cazul oricarei actiuni? Aceasta este intrarea noastră la rutarea imbricată. Ține asta în spatele capului pentru mai târziu. - addAnswerToQuestion: Acest ajutor adaugă răspunsul gazdei la o întrebare, luând un obiect ca argument care conține ID-ul întrebării și intrarea răspunsului. Să implementăm asta în
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 } });
};
Observați că implementarea verifică dacă utilizatorul care face cererea este într-adevăr gazda sesiunii sau nu și aruncă o anumită eroare dacă nu.
- addQuestionToSession: Acesta adaugă orice întrebare a utilizatorului non-gazdă la o sesiune prin preluarea unui argument de obiect care conține id-ul utilizatorului și al sesiunii și intrarea întrebării. Acesta este modul în care este implementat în
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 } });
};
Observați cum blocăm un utilizator să posteze aceeași întrebare de mai multe ori pe sesiune?
- useParams hook: Acest cârlig este un alt proxy pentru a reacționa router care pur și simplu ne oferă acces la orice parametru de rută, cum ar fi sessionId în cazul nostru.
- Formular de întrebare: tuturor utilizatorilor autentificați, care nu sunt gazdă, le arătăm un formular de introducere a întrebării la fiecare sesiune deasupra listei de întrebări postate anterior.
- Componenta Întrebare Răspuns: Pentru a menține o bucată mare de cod partajabilă și izolată, punem o singură întrebare într-un fișier de componentă partajat. Vom vedea de ce peste puțin, dar să vedem mai întâi implementarea acestei componente. Creați un fișier nou
app/components/sessions/question-answer.tsx
și pune următorul cod acolo:
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> );
};
Observați că această componentă încorporează un formular în interiorul ei, ceea ce înseamnă că fiecare întrebare va reda acest formular pentru gazdă pentru a le oferi o modalitate ușoară de a adăuga răspunsuri la întrebările la care nu au răspuns încă și butonul de trimitere al formularului are name="answer_to_question" value={question.id}
elemente de recuzită care ne ajută să semnalăm backend (acțiunea) că această trimitere a formularului trebuie abordată ca răspuns introdus de gazdă.
Este posibil să fi observat, de asemenea, că fiecare întrebare se leagă la to={
întrebări/${question.id}}
ceea ce ne duce la subiectul de rutare imbricată. Să aruncăm o privire la asta acum.
Dirijare imbricată
Într-o aplicație tradițională react, ați împărți o pagină în mai multe componente, iar componentele își încarcă intern propriile date sau sunt alimentate de un magazin de date global care îi transmite datele. În Remix, ați face acest lucru prin rutare imbricată, în care o pagină poate încorpora o altă pagină în care are propriul ciclu de viață, cum ar fi încărcătorul de date, acțiunea, limitarea erorilor etc. Acest lucru este incredibil de puternic și adaugă un nivel cu totul nou de fiabilitate și viteză în UX . Vom folosi acest lucru pentru a afișa un fir de comentarii pentru fiecare întrebare într-o sesiune.
Pentru a facilita acest lucru, am adăugat a <Outlet context={data.session} />
componentă în pagina cu detaliile sesiunii. Outlet
este containerul pentru conținutul paginii imbricate și ne oferă posibilitatea de a construi aspectul pentru o pagină copil la nivel de părinte. Când utilizatorul intră într-o rută imbricată, aceasta va fi înlocuită cu codul html redat de cel mai jos nivel al rutei paginii imbricate.
Acum, pentru a accesa firul de comentarii, direcționăm utilizatorii către session/:sessionId/questions/:questionId
ruta, astfel încât pentru a se potrivi cu aceasta în sistemul de fișiere, trebuie să creăm un nou director în interiorul routes/sessions/$sessionId/questions
și creați un fișier numit $questionId.tsx
în interiorul acestuia. Observați că acum avem un fișier cu numele $sessionId.tx
și un director numit $sessionId
. Acest lucru poate fi confuz, dar este așa cum a fost proiectat. Acest lucru îi spune lui Remix să folosească fisierul sessionId.tsx ca pagina părinte și redați orice rute imbricate din fișierul ID.tsx al sesiunii ca pagină părinte și redați orice rute imbricate din `sesiune IDdirectory. Now let’s put in the following code in the
Fișierul $questionId.tsx`:
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> );
}
Aici, folosim asta question-answer.tsx
componentă pentru a afișa aceeași componentă UI pe care o arătăm în sesiune, dar în acest caz în partea de sus a firului de comentarii, pentru a oferi cititorilor context pentru comentarii. De asemenea, plasăm un formular în interiorul acestuia prin care orice utilizator autentificat poate posta un comentariu. Să verificăm cele 2 noi funcții de server pe care le folosim în încărcător și apoi să acționăm pentru această pagină de la 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 }, });
};
Câteva lucruri demne de remarcat în această componentă sunt:
- useOutletContext hook: Acest lucru ne oferă acces la toate elementele de recuzită transmise paginii copil prin intermediul
<Outlet … />
componentă din pagina părinte. Deci, aici, avem acces la întreaga sesiune cu toate întrebările din interiorul acesteia și, în loc să interogăm singura întrebare a firului, pur și simplu o alegem din datele deja transmise. - Încărcarea comentariilor: încărcăm toate comentariile pentru o întrebare fără paginare, ceea ce nu este o idee grozavă pentru nicio aplicație de producție.
Învelire
Dacă ați urmat toți pașii anteriori, deschideți aplicația într-o fereastră incognito și creați un cont nou. Apoi, dacă faceți clic în sesiunea creată anterior, ar trebui să vedeți un câmp de introducere pentru a pune o întrebare:
Acum, dacă introduci o întrebare și o postezi din acel cont nou, ar trebui să vezi ceva de genul acesta:
Care arată comentariul dvs., deschide comentariul ca un fir în partea dreaptă și vă permite dvs. sau oricărui alt utilizator să adăugați un comentariu în fir.
În cele din urmă, dacă reveniți la cealaltă fereastră a browserului în care sunteți autentificat ca gazdă a sesiunii și reîmprospătați pagina sesiunii, ar trebui să vedeți comentariul acolo cu o intrare chiar dedesubt pentru a posta răspunsul:
Ce urmeaza?
Ai făcut o treabă uimitoare urmărind până aici, așa că te rog să-ți aplauze! Dacă ești ca mine și nu te poți sătura niciodată de noile lucruri JS strălucitoare, s-ar putea să te întrebi: „Este grozav, dar este ceva ce l-aș folosi ca utilizator?” și dacă ești sincer cu tine însuți, atunci răspunsul ar fi o mare grăsime NO
. Așa că vă voi lăsa cu câteva idei care pot transforma această aplicație de jucărie rapidă într-o aplicație pregătită pentru producție, care ar putea avea un pic de acțiune în lumea reală:
- Sincronizarea datelor în timp real – sesiunile AMA sunt toate despre sincronizare. Cel puțin cei buni sunt. Oamenii care le găzduiesc nu au timp să stea în jur și să apese pe reîmprospătare la fiecare 10 secunde pentru a căuta comentarii/întrebări noi etc. Așa că toate acestea ar trebui sincronizate în timp real și evidențiate gazdei. La fel și pentru participanți.
- Paginare – După cum sa menționat pe parcursul postării, am tăiat câteva colțuri în încărcarea datelor care cu siguranță nu se vor scala într-o aplicație din lumea reală. Adăugarea de paginare la toate interogările ar fi și o experiență bună de învățare.
- Cronometrul sesiunii și sesiunea viitoare: deoarece sesiunile din această aplicație sunt încadrate în timp pe zi, afișarea unui temporizator pentru când se termină sesiunea poate adăuga un element de emoție experienței. O altă caracteristică ucigașă ar fi să permită gazdelor să programeze sesiuni pentru viitor și să creeze un hype în jurul acesteia, prezentând sesiunea viitoare pe pagina de pornire într-un mod mai evidențiat
Resurse
- Distribuție de conținut bazat pe SEO și PR. Amplifică-te astăzi.
- Platoblockchain. Web3 Metaverse Intelligence. Cunoștințe amplificate. Accesați Aici.
- Sursa: https://www.codementor.io/foysalit/build-a-fullstack-ama-app-with-remix-prisma-postgresql-1vsbmepsp3
- 1
- a
- capacitate
- Capabil
- Despre Noi
- mai sus
- acces
- accesibil
- accesarea
- Conform
- în consecință
- Cont
- precis
- Acțiune
- acțiuni
- de fapt
- adăugat
- Suplimentar
- În plus,
- adresa
- Adaugă
- Ajustare
- După
- vechi
- înainte
- TOATE
- Permiterea
- singur
- pe langa
- deja
- mereu
- AMA
- Sesiuni AMA
- uimitor
- printre
- și
- O alta
- răspunde
- răspunsuri
- api
- aplicaţia
- Aplică
- Apps
- argument
- în jurul
- Mulțime
- activ
- Bunuri
- încercarea
- AUTH
- autentificata
- Autentificare
- autor
- Auto
- disponibil
- astept
- înapoi
- sprijinit
- Backend
- de bază
- bazat
- de bază
- Pe scurt
- Noțiuni de bază
- deoarece
- devine
- înainte
- în spatele
- fiind
- de mai jos
- între
- Mare
- Pic
- Bloca
- blocarea
- Blog
- corp
- frontieră
- De jos
- limitele
- Cutie
- Pauză
- Aduce
- browser-ul
- construi
- Clădire
- construit
- Buchet
- buton
- apel
- apel
- apeluri
- pasă
- cascadă
- caz
- Captură
- prins
- sigur
- cu siguranță
- Schimbare
- Modificări
- caractere
- verifica
- control
- Verificări
- copil
- Copii
- Alege
- clasă
- clar
- client
- cod
- codeBase
- Coloană
- Coloane
- COM
- comentariu
- comentarii
- comite
- comunitate
- concurs
- Completă
- complexitate
- component
- componente
- concept
- Concepte
- confuz
- prin urmare
- Consoleze
- constant
- mereu
- Recipient
- conține
- conţinut
- context
- convinge
- colțuri
- ar putea
- Cuplu
- înscrie-te la cursul
- acoperi
- crea
- a creat
- creează
- Creator
- CSS
- Curent
- În prezent
- Tăiat
- de date
- Baza de date
- Data
- Date
- datetime
- zi
- dedicat
- adânc
- Mod implicit
- categoric
- Încântat
- adâncime
- proiectat
- detaliat
- detalii
- dev
- Dezvoltator
- Dezvoltatorii
- Dezvoltare
- FĂCUT
- distinge
- directoare
- discutat
- discuţie
- Afişa
- documentaţie
- Nu
- face
- Dont
- jos
- Picătură
- scăparea
- în timpul
- dinamic
- fiecare
- Devreme
- mai ușor
- cu ușurință
- oricare
- se încheie
- angajament
- suficient de
- asigura
- asigură
- Întreg
- entități
- entitate
- intrare
- eroare
- Erori
- În esență,
- etc
- Eter (ETH)
- Chiar
- toată lumea
- tot
- evoluat
- exact
- exemplu
- Cu excepția
- existent
- experienţă
- exporturile
- exporturile
- expresiv
- extinde
- extern
- ochi
- facilita
- echitabil
- Cădea
- familiar
- familiariza
- familie
- Modă
- FAST
- Grăsime
- Caracteristică
- DESCRIERE
- fed-
- picioare
- puțini
- camp
- Domenii
- Fișier
- Fişiere
- umple
- umplut
- În cele din urmă
- Găsi
- First
- În primul rând
- Concentra
- urma
- a urmat
- următor
- fonturi
- străin
- formă
- găsit
- Cadru
- Gratuit
- frecvent
- proaspăt
- prietenos
- din
- Complet
- complet
- distracţie
- funcţie
- funcții
- viitor
- Câştig
- General
- obține
- merge
- GitHub
- Da
- oferă
- Oferirea
- Caritate
- Go
- Merge
- merge
- bine
- google fonts
- mare
- pregătire
- creste
- manipula
- Manipularea
- la indemana
- Atârna
- hașiș
- cap
- sănătos
- ajutor
- ajută
- aici
- Înalt
- superior
- Evidențiat
- istoric
- Lovit
- hit-uri
- Acasă
- speranţă
- gazdă
- găzduire
- casă
- Cum
- Cum Pentru a
- Totuși
- HTML
- HTTPS
- sute
- hype
- ICON
- idee
- idei
- identifica
- imagini
- punerea în aplicare a
- implementarea
- implementat
- import
- in
- include
- inclus
- include
- Inclusiv
- incredibil
- index
- individ
- info
- inițială
- intrare
- înţelegere
- instala
- Instalarea
- in schimb
- interesat
- introdus
- intuitiv
- investiţie
- invocă
- izolat
- IT
- în sine
- Loc de munca
- JSON
- a sari
- A pastra
- Cheie
- Copil
- Cunoaște
- Etichetă
- peisaj
- mare
- Nume
- Anul trecut
- Aspect
- conduce
- învăţare
- Părăsi
- Moştenire
- Lungime
- Permite
- Nivel
- LG
- LIBERTY
- Bibliotecă
- Linie
- linii
- LINK
- Link-uri
- Listă
- mic
- încărca
- încărcător
- încărcare
- loturile
- siglă
- Lung
- Uite
- arată ca
- Se pare
- Lot
- cel mai scăzut nivel
- magie
- susține
- major
- face
- Efectuarea
- manipulat
- manipulant
- manual
- multe
- Hartă
- Harta
- Meci
- mijloace
- menționat
- mesaj
- metodă
- Metode
- ar putea
- migra
- migrațiune
- model
- modul
- mai mult
- cele mai multe
- muta
- în mişcare
- Mozilla
- multiplu
- nume
- Numit
- Navigaţi
- Nevoie
- nevoilor
- Nou
- următor
- Next.js
- nod
- Node.js
- normală.
- notabil
- remarcabil
- număr
- obiect
- obiecte
- a avut loc
- oficial
- ONE
- deschide
- deschide
- operaţie
- Operațiuni
- opus
- Opțiune
- comandă
- Altele
- in caz contrar
- schiță
- exterior
- propriu
- Paginație
- paradigmă
- parametru
- parte
- participanţi
- Trecut
- trece
- Parolă
- trecut
- oameni
- perspectivă
- alege
- bucată
- piese
- Locuri
- plasare
- platformă
- Plato
- Informații despre date Platon
- PlatoData
- Joaca
- "vă rog"
- Plugin-uri
- Punct
- Punct de vedere
- Post
- postat
- postgresql
- putere
- puternic
- a prefera
- preferat
- destul de
- împiedica
- precedent
- în prealabil
- primar
- prioritizate
- prismă
- probabil
- probleme
- Produs
- producere
- proiect
- proprietate
- furniza
- prevăzut
- furnizează
- împuternicit
- public
- publicat
- scop
- scopuri
- împins
- pune
- Punând
- Q & A
- calităţi
- întrebare
- Întrebări
- Rapid
- repede
- Reacţiona
- Citeste
- Cititor
- cititori
- Citind
- gata
- real
- lumea reală
- în timp real
- a primi
- primește
- recomanda
- redirecționa
- reduce
- referințe
- Fără deosebire
- Inregistreaza-te
- înregistrată
- înregistrare
- legate de
- relaţie
- Relaţii
- relativ
- încredere
- de încredere
- Remix
- tencuială
- înlocuiește
- solicita
- cereri de
- necesar
- Necesită
- Resurse
- răspuns
- REST
- restrânge
- reveni
- Returnează
- revoluționat
- rădăcină
- rotund
- Traseul
- router
- rute
- RÂND
- Alerga
- funcţionare
- sigur
- sake
- acelaşi
- scalabil
- Scară
- programa
- domeniu
- Al doilea
- vedere
- sens
- sesiune
- Sesiunile
- configurarea
- comun
- SCHIMBARE
- Pantaloni scurți
- să
- Arăta
- simbolizeazã
- Emisiuni
- semna
- Semnal
- semnare
- simplu
- simplifica
- simplificarea
- pur şi simplu
- întrucât
- singur
- Încet
- mic
- So
- REZOLVAREA
- unele
- ceva
- SPA
- Spaţiu
- special
- specific
- viteză
- Rotire
- împărţi
- Loc
- tors
- stivui
- Stive
- standard
- Începe
- început
- Pornire
- începe
- Stat
- Statele
- Stare
- paşi
- Încă
- Stop
- oprire
- stoca
- Strategie
- curent
- stil
- supunere
- prezenta
- prezentat
- succes
- astfel de
- REZUMAT
- Super
- a sustine
- Sprijină
- SVG
- sintaxă
- sistem
- tabel
- tailwind
- Lua
- ia
- luare
- echipă
- Tehnic
- spune
- șablon
- Terminal
- Testarea
- Noțiuni de bază
- Peisajul
- lor
- temă
- lucru
- lucruri
- Gândire
- mii
- Prin
- de-a lungul
- Aruncare
- timp
- sincronizare
- sfat
- Titlu
- la
- astăzi
- împreună
- de asemenea
- Unelte
- top
- subiect
- Total
- atingeţi
- urmări
- tracțiune
- tradiţional
- tranziţie
- adevărat
- tutorial
- tv
- manuscris dactilografiat
- ui
- în
- Neașteptat
- viitoare
- Actualizează
- actualizarea
- URL-ul
- us
- utilizare
- Utilizator
- utilizatorii
- obișnuit
- ux
- VALIDA
- validate
- validare
- valoare
- diverse
- de
- Vizualizare
- vizualizari
- aștepta
- modalități de
- web
- dezvoltare web
- website
- Ce
- Ce este
- care
- în timp ce
- OMS
- voi
- Fire de sârmă
- în
- fără
- întrebam
- Apartamente
- a lucrat
- de lucru
- lume
- merită
- ar
- scris
- X
- an
- Ta
- te
- zephyrnet