Opprinnelig publisert her.
Remix er et relativt nytt, fullstack JS-rammeverk, støttet av noen av gigantene i JS-fellesskapet som f.eks. Kent C. Dodds, Ryan T. Florence og Michael Jackson. Frem til Next.js kom, var det å sette sammen ulike verktøy for å bygge SPA-en din den faktiske måten å bygge JS-apper på. Next.js revolusjonerte dette til en viss grad og gikk uovertruffen en stund. Imidlertid har landskapet endret seg raskt det siste året eller så med sunn konkurranse fra RedwoodJs, BlitzJs og nå Remix. Alle disse verktøyene prøver å løse noen av de eldgamle problemene innen webutvikling på en mer kreativ, pålitelig og viktigst, utviklervennlig måte slik at det å bygge en effektiv nettapp blir standard for JS-utviklere.
Det er definitivt veldig tidlig å identifisere en klar vinner blant alle disse verktøyene i dette rommet, men Remix ser absolutt ut som en verdig utfordrer. Så hvis du ikke allerede har våt føttene dine i det fantastiske som er Remix, håper jeg denne opplæringen vil hjelpe deg i gang og overbevise deg om å velge den for det du bygger neste!
Fugleperspektiv
I dette innlegget vil jeg lede deg gjennom å bygge en AMA (Ask Me Anything)-app ved hjelp av Remix. Nedenfor er en liste over de primære verktøyene vi skal bruke for å bygge denne appen. Det vil sikkert være lettere for leseren å følge med hvis de er kjent med det grunnleggende i noen av verktøyene (bortsett fra Remix, selvfølgelig), men ikke bekymre deg for mye hvis ikke.
- Remix – Primært rammeverk
- React – UI-rammeverk
- Prisma – Database ORM
- PostgreSQL – Database
- TailwindCSS – CSS-rammeverk
Dette kommer til å bli et langt innlegg, så jeg anbefaler å følge med i flere møter og for å gjøre det lettere for deg å avgjøre om det er en verdifull investering å lese hele greia, her er en oversikt over hva vi vil gjøre/lære om gjennom hele hele greia, i kronologisk rekkefølge:
- Appspesifikasjoner – Skisser funksjonene til appen vi skal bygge fra et høyere nivå.
- Kom i gang med Remix – Følg for det meste deres offisielle dokumenter og installer et par ting.
- Databaseskjema – Sett opp databaseskjemaet som kan støtte alt det dynamiske innholdet som trengs av appen vår.
- CRUD – Grunnleggende CRUD-operasjoner på standard Remix-måte.
- UI/UX – Dryss litt medvind for å få ting til å se pent ut.
Som du kan se, har vi mye å dekke, så la oss dykke rett inn. Å, men før det, hvis du er utålmodig som meg og bare vil se koden, her er hele appen på github: https://github.com/foysalit/remix-ama
App Spes
I ethvert prosjekt, hvis du vet nøyaktig hva du skal bygge, blir det mye lettere å navigere i landskapet fra starten. Du har kanskje ikke alltid den friheten, men heldigvis vet vi i vårt tilfelle alle funksjonene vi trenger for appen vår. Før vi metodisk lister opp alle funksjonene fra et teknisk perspektiv, la oss se på dem fra et generelt produktsynspunkt.
AMA-økt
En bruker på appen vår skal kunne være vert for flere AMA-økter. Det gir imidlertid ikke mening å være vert for flere økter i løpet av samme dag, så la oss begrense en økts varighet til en hel dag og bare tillate 1 økt per bruker per dag.
Q & A
En bruker på appen vår skal kunne stille et spørsmål til en vert under en løpende AMA-økt. For å bygge eksklusivitet, la oss blokkere brukere fra å stille spørsmål etter at økten er over. Selvsagt skal verten for økten kunne svare på spørsmålene som stilles i øktene sine.
Kommentar
For å bygge mer engasjement og gjøre ting litt morsommere enn tradisjonelle spørsmål og svar, la oss legge til en kommentartrådfunksjon som lar enhver bruker legge til en kommentar til et spørsmål. Dette kan brukes til å legge til mer kontekst til et allerede stilt spørsmål eller ha en diskusjon om det gitte svaret fra verten osv.
La oss nå bryte ned hvordan vi skal implementere dem:
Autentisering – Brukere må kunne registrere seg for å være vert for en AMA-økt, stille et spørsmål til en vert eller kommentere i en tråd. La oss imidlertid ikke hindre en uautentisert bruker fra å se en allerede kjørende økt. For autentisering, la oss bruke e-postadresse og passord. I tillegg, når du registrerer deg, la oss også be brukeren om å skrive inn hele navnet sitt for å bli brukt overalt i appen. En brukerenhet vil bli brukt til å lagre autentiseringsrelaterte data.
Økter – Vis en liste over alle nåværende og tidligere økter på en indeksside til alle (autentiserte/uautentiserte) brukere som lar dem klikke seg inn på hver økt og se spørsmål/svar/kommentarer osv. Autentiserte brukere kan starte en ny økt hvis det allerede er er ikke en for den dagen. La oss be verten om å gi noen kontekst/detaljer til hver økt når du starter en. Hver økt er en enhet som tilhører en bruker.
spørsmål – Hver enkelt økt kan ha flere spørsmål fra alle registrerte brukere bortsett fra verten. Spørsmålsenheten vil også inneholde svaret fra verten i databasen, og alle svarinndata vil bli validert for å sikre at forfatteren er verten for sesjonen. Enheten tilhører en økt og en bruker. La oss sørge for at en bruker bare kan stille ett spørsmål per økt, så inntil de stiller et spørsmål, la oss vise en tekstinngang til hver bruker. La oss under hvert besvarte spørsmål vise en tekstinngang til verten for å legge til svaret deres.
Kommentar – Hvert spørsmål (besvart eller ikke) kan ha flere kommentarer. For å redusere kompleksiteten, la oss ikke legge til tråder i kommentarer foreløpig. Hver bruker kan legge inn flere kommentarer under et spørsmål, så la oss alltid vise kommentarteksten til alle brukere under hvert spørsmål. For å forenkle brukergrensesnittet, la oss vise spørsmålslisten (og svar) på øktsiden som standard og legge til en lenke for å åpne kommentartråden i en sidefelt.
Kom i gang med Remix
Remix har mange gode kvaliteter, men dokumentasjon tar sannsynligvis toppen. Et rammeverk under tung utvikling vil garantert ha mange, mange bevegelige deler som stadig utvikles av vedlikeholderne, så dokumentasjonen er nødt til å falle bak ettersom funksjoner blir prioritert. Remix-teamet legger imidlertid stor vekt på å holde dokumentasjonen oppdatert og synkronisert med den konstante strømmen av fantastiske endringer som blir presset ut. Så for å komme i gang, selvfølgelig offisielle dokumenter vil være vårt første inngangspunkt.
Hvis du er for lat til å gå til et annet nettsted og lese en annen vegg med tekst, ikke bekymre deg. Her er alt du trenger å gjøre for å installere Remix:
- Sørg for at du har Node.js utvikling env oppsett.
- Åpne Terminal-vinduet og kjør følgende kommando
npx create-remix@latest
. - Ferdig.
Remix gir deg ikke bare en haug med verktøy og ber deg bygge tingen din, de går foran som et godt eksempel, og det er derfor de har konseptet med Stabler. Stabler er i hovedsak maler/startsett som gir deg grunnlaget for et komplett prosjekt rett ut av esken. For vårt prosjekt vil vi bruke Blues Stack som gir oss et fullt konfigurert Remix-prosjekt med Prisma, Tailwind og en hel modul som viser hvordan du bruker disse verktøyene til å bygge en CRUD-funksjon. Jeg mener ærlig talt, jeg føler at jeg ikke engang burde skrive dette innlegget siden malen gjorde alt arbeidet allerede. Nåvel... jeg er for dypt nå, så kan like gjerne fullføre det.
Alt du trenger å gjøre er å kjøre kommandoen npx create-remix --template remix-run/blues-stack ama
i terminalen din og Remix vil slippe hele prosjektet i en ny mappe kalt ama
etter at du har svart på et par spørsmål.
La oss nå åpne opp ama
mappe og sette oss litt inn i innholdet inni. Det er en haug med konfigurasjonsfiler i roten, og vi kommer ikke inn på de fleste av dem. Vi er mest interessert i prisme, offentlig og app kataloger. Prismakatalogen vil inneholde vårt databaseskjema og migrering. Den offentlige katalogen vil inneholde alle eiendeler appen trenger som ikoner, bilder osv. Til slutt vil appkatalogen inneholde all vår kode, både klient og server. Ja, du leste riktig, både klient og server. Hvis dette gir deg store gamle kodebase-flashbacks, må du vite at du ikke er alene.
Før vi dykker ned i å skrive vår egen app-kode, la oss sjekke alt inn i git slik at vi kan spore endringene våre fra det som allerede ble gjort for oss ved å remikse bluesstack.
cd ama
git init
git add .
git commit -am ":tada: Remix blues stack app"
Og til slutt, la oss kjøre appen og sjekke ut hvordan den ser ut før vi berører noe. README.md-filen inneholder allerede alle de detaljerte trinnene som skal hjelpe deg med dette, og siden disse er gjenstand for hyppige endringer, kommer jeg til å lenke ut til trinnene i stedet for å skrive dem ned her https://github.com/remix-run/blues-stack#development
Hvis du følger trinnene nøyaktig, bør appen være tilgjengelig på http://localhost:3000
Stabelen kommer med en standard notatmodul som du kan leke med etter å ha registrert deg med e-post og passord.
Databaseskjema
Vanligvis liker jeg å begynne å tenke på en funksjon/entitet fra databaseskjemaet og jobbe meg opp til brukergrensesnittet hvor dataene blir tolket, vist og manipulert på forskjellige måter. Når du har utarbeidet skjemaet, blir det mye lettere å gå raskt gjennom den implementeringen.
Som diskutert ovenfor i app-spesifikasjonen, trenger vi 3 enheter i databasen vår: økt, spørsmål og kommentar. Vi trenger også en brukerenhet for å lagre hver registrerte bruker, men bluesstakken fra Remix inkluderer den allerede. Vi trenger bare å endre den litt for å legge til en name
kolonne. La oss åpne filen prisma/schema.prisma
og legg til linjene nedenfor på slutten av filen:
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
}
Og legg deretter til denne linjen i definisjonen av User
modell:
model User { … name String sessions Session[] questions Question[] comments Comment[] …
}
Nå er det mye å pakke ut her, men det meste er utenfor rammen av dette innlegget. Denne skjemadefinisjonen er alt vi trenger for at prisma skal ta seg av å bygge alle tabellene med de riktige kolonnene for de 3 nye enhetene vi trenger. Hvordan definisjonene og syntaksen fungerer bør du gå over til denne lenken https://www.prisma.io/docs/concepts/components/prisma-schema og les litt. En oppsummering på høyt nivå er:
- En enhet/tabelldefinisjon begynner med
model <EntityName> {}
og inne i de krøllete klammeparentesene går alle kolonnene/egenskapene til enheten og relasjoner til de andre enhetene. Så en tabell for kommentar vil se utmodel Comment {}
- Kolonnedefinisjoner ser vanligvis ut
<columnName> <columnType> <default/relationship/other specifiers>
. Så hvis kommentarenheten vår krever en kolonne for å lagre innholdet i kommentarinngangen fra brukeren, vil den se ut
model Comment { content String
}
- Relasjoner mellom 2 tabeller/enheter er vanligvis definert via en fremmednøkkelkolonne, så disse er også definert sammen med andre kolonner. Definisjonen krever vanligvis 2 linjer. En kolonne som inneholder fremmednøkkel-IDen og den andre for å spesifisere navnet som brukes for å få tilgang til relatert enhet, som vanligvis ser slik ut:
<entity> <entityName> @relation(fields: [<foreignKeyColumnName>], references: [id], onDelete: Cascade, onUpdate: Cascade)
. Så for å relatere kommentarenheten til spørsmålsenheten med en en-til-mange-relasjon, må vi definere den som
model Comment { content String question Question @relation(fields: [questionId], references: [id], onDelete: Cascade, onUpdate: Cascade) questionId String
}
Ovennevnte dekker ikke engang toppen av isfjellet som er prisma, så vær så snill, les opp på det fra deres offisielle dokumenter, og du vil se dens sanne kraft. Av hensyn til dette blogginnlegget bør ovenstående gi deg en ide om hvorfor vi trenger prismaskjemaet ovenfor.
Vi må gjøre en siste justering knyttet til databasen. Sammen med hele autentiseringssystemet inkluderer blues-stakken også en innledende datasøker som fyller databasen din med en dummy-bruker for testformål. Siden vi introduserte en ny kolonne name
i brukertabellen må vi også justere seederen for å legge til et dummynavn til brukeren. Åpne filen prisma/seed.js
og endre brukerinnsettingskoden som nedenfor:
const user = await prisma.user.create({ data: { Email, name: 'Rachel Remix', password: { create: { hash: hashedPassword, }, }, }, });
Med det er vi endelig klare til å synkronisere alle disse endringene med databasen vår. Siden databasen vår allerede har blitt spunnet opp med tidligere opprettet skjema og noen seeded data, og siden da har db-en vår endret seg, kan vi egentlig ikke synkronisere alle endringene våre med en gang. I stedet må vi justere litt på migreringen. Prisma gir kommandoer for denne typen justeringer men heldigvis er ikke våre eksisterende data og skjema i produksjon eller noe så på dette tidspunktet, det er bare enklere å nuke db og begynne på nytt med vårt nåværende skjema. Så la oss gå med den enklere ruten og kjøre disse kommandoene:
./node_modules/.bin/prisma migrate reset
./node_modules/.bin/prisma migrate dev
Den første kommandoen tilbakestiller db-en vår, og den andre bruker den gjeldende skjemadefinisjonen for å gjenskape db-en med alle tabellene og fyller den med seeded data.
La oss nå stoppe den kjørende appserveren, konfigurere appen på nytt og snurre den opp igjen
npm run setup
npm run dev
Oppdater brukerregistrering
Siden vi har lagt til en ny navnekolonne i brukertabellen, la oss starte med å kreve at brukerne fyller inn navnet sitt når de registrerer seg. Dette vil gi oss en fin inngang til remix-måten å gjøre ting på uten å gjøre det til et stort sjokk hvis du stort sett er kjent med Reacts vanlige måte å bygge apper på.
Koden for brukerregistrering finner du i ./app/routes/join.tsx
fil. Åpne den opp og rett under <Form>
komponent følgende kode for å legge til inndatafeltet for navn:
<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>
Det etterligner i utgangspunktet det allerede eksisterende e-postfeltet. Nå må vi justere noen flere ting her for å sikre at navneinntastingen blir håndtert riktig. La oss først lage en ref til navnefeltet, og hvis det er en feil i håndteringen av navneinntastingen, ønsker vi å autofokusere det feltet akkurat som de andre feltene i skjemaet.
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]);
Hva er nå actionData
? Det er ganske enkelt det returnerte svaret fra serveren fra innsendingsforespørselen. Enhver handling for innsending av skjema vil sende postforespørselen fra nettleseren til serveren og remix vil håndtere den via action
funksjon definert rett over komponenten. Denne funksjonen mottar et objekt med en request-egenskap som gir deg noen veldig nyttige metoder for å få tilgang til dataene som sendes over fra nettleseren, og du kan returnere et svar fra denne funksjonen som nettleserkoden kan håndtere deretter. I vårt tilfelle ønsker vi å validere de innsendte dataene og sørge for at navnefeltet faktisk er fylt ut. Så her er endringene vi trenger i action
funksjon:
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 } ); }
Som koker ned til å hente inn navn fra innsendingsforespørselen og deretter returnere en feilmelding hvis navnet ikke er fylt ut. Siden returdataene skrives inn via ActionData
type, må vi justere definisjonen og legge til navneegenskapen:
interface ActionData { errors: { email?: string; name?: string; password?: string; };
}
Vi har kun håndtert feil inndatatilfelle, så la oss gå videre og sørge for at brukerens navn blir satt inn i kolonneegenskapen ved å oppdatere linjen const user = await createUser(email, password);
til const user = await createUser(email, password, name);
og følgelig må vi justere definisjonen av createUser
i 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, }, }, }, });
}
Et par ting å merke seg her:
- For å holde serverspesifikk kode isolert og borte fra klienten, kan vi suffikse filer med
.server.ts
. - Vi bruker en veldig uttrykksfull og intuitiv prisma API for enkelt å sette inn en ny rad i db. Dette tar vanligvis form av
prisma.<entityName>.<actionName>({})
hvorentityName
er tabellnavnet med små bokstaver ogactionName
er db operasjonen som create, update, findOne etc. Vi vil se mer bruk av disse snart.
Med det har vi nettopp lagt til et nytt navn som vil bli validert når brukeren treffer Create Account
.
Dette er sannsynligvis et godt stopp for å sjekke inn endringene våre på git, så la oss bruke koden vår: git add . && git commit -am “:sparkles: Add name field to the sign up form”
Økter
Så langt har vi for det meste justert eksisterende kode her og der for å få litt innsikt i hvordan Remix gjør ting. Nå får vi dykke ned i å bygge vår egen modul fra bunnen av. Det første vi skal bygge er en måte for brukere å være vert for en AMA-økt i henhold til den opprinnelige appspesifikasjonsdefinisjonen.
I remix er url-ruter filbasert. Jeg mener, det oppfinner ganske mye et helt nytt paradigme som forenkler det ned til file based routing
er sannsynligvis ikke veldig nøyaktig eller rettferdig, men vi vil sakte komme inn i det. For å starte med økter, ønsker vi
- En listeside der alle nåværende og historiske økter er oppført
- En dedikert side per økt hvor alle spørsmål, svar og kommentartråder vises
- En side for å starte en ny økt for alle påloggede brukere
La oss starte med listesiden. Opprett en ny fil i app/routes/sessions/index.tsx
og legg inn følgende kode i den:
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> </> );
}
Hvis du er kjent med reagere, bør dette se kjent ut for deg, for det meste. La oss imidlertid bryte det ned bit for bit. Remix vil gjengi standard eksporterte komponent. Over komponentdefinisjonen har vi en loader
funksjon. Dette er en spesialfunksjon som du kun kan ha 1 per rute/fil og ved sideinnlasting, vil Remix kalle denne funksjonen for å hente dataene siden din trenger. Den vil deretter hydrere komponenten din med dataene og sende den gjengitte HTML-en over ledningen som et svar som er en av de magiske atferdene eller Remix. Dette sikrer at brukere ikke trenger å se en lastestatus ettersom nettleserens JS-kode laster data fra API-forespørsler. Kroppen til handlingsfunksjonen roper ut til en getSessions()
funksjon som er importert fra ~/models/session.server
. Her følger vi den allerede diskuterte strategien for å sette db-operasjoner i kun serverfiler. La oss lage den nye filen i app/models/session.server.ts
og legg inn følgende kode:
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 }, }, }, });
};
Det er ganske enkelt å hente alle oppføringer fra sesjonstabellen og alle brukeroppføringer relatert til dem, siden vi vil bruke vertens informasjon om brukergrensesnittet og det inkluderer også det totale antallet spørsmål hver økt har. Dette er ikke superskalerbart fordi etter hvert som appen vår vokser, kan det være hundretusenvis av AMA-økter, og å hente dem alle vil ikke skalere godt. For formålet med dette innlegget vil vi imidlertid hoppe over paginering for nå.
La oss hoppe tilbake til vår sessions/index.tsx
rutefil. Hvis det ikke er noen økter i databasen, returnerer vi et 404-feilsvar ved å bruke Response
hjelper fra Remix. Ellers returnerer vi et JSON-svar som inneholder utvalget av økter som bruker json
hjelper fra Remix.
De const data = useLoaderData<LoaderData>();
ringer en spesiell Remix-hook som gir oss tilgang til dataene i svaret sendt tilbake fra action
. Du lurer kanskje på hvordan vi håndterer feilreaksjonen? Det blir definitivt ikke håndtert i kroppen SessionIndexPage
funksjon. Remix bruker den lange tilgjengelige ErrorBoundary
funksjon for håndtering av feilvisninger. Alt vi trenger å gjøre er å eksportere en reaksjonskomponent kalt CatchBoundary
fra en rutefil og eventuelle feil som oppstår ved å gjengi ruten (klient eller server). CatchBoundary
komponenten vil bli gjengitt. La oss definere dette raskt over SessionIndexPage
komponent:
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() {
…
Dette er ganske enkelt å gjengi en delt overskriftskomponent og en lenke til å starte en ny økt. Den bruker også en delt Button
komponent. La oss bygge disse delte komponentene. Vi skal legge dem i app/components/shared/
katalog. La oss starte med 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> );
};
Dette er en grunnleggende reaksjonskomponent med litt medvindsstyling drysset. Vi bruker Link
komponent fra Remix (som i utgangspunktet bare er en proxy til Link
komponent fra react-router) for å lenke til listen over økter-siden. En annen bemerkelsesverdig ting her er at vi bruker en font-cursive
stil på overskriftsteksten for å få den til å se litt ut som en logo. Kursiv skriftstil er ikke inkludert i standard tailwind-konfigurasjonen, så vi må konfigurere den selv. Åpne opp tailwind.config.js
fil fra roten til prosjektet og juster theme
eiendom som nedenfor:
module.exports = { content: ["./app/**/*.{ts,tsx,jsx,js}"], theme: { extend: { fontFamily: { cursive: ["Pinyon Script", "cursive"], }, }, }, plugins: [],
};
Legg merke til at den ekstra biten utvider temaet for å legge til en ny fontFamily med navnet cursive
og verdien er Pinyon Script
Jeg valgte dette fra google-fonter, men velg gjerne din egen font. Hvis du ikke er veldig kjent med medvind, gir dette oss bare muligheten til å bruke denne skriftfamilien på en tekst ved å bruke font-cursive
hjelperklasse, men vi må fortsatt laste inn selve skriften på nettsiden vår. Å legge til eksterne eiendeler til Remix er ganske enkelt. Åpne app/root.tsx
fil og oppdater links
definisjon for å legge til 3 nye objekter til matrisen:
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 linkene ovenfor er hentet fra google fontsiden her.
Sporer skrittene våre tilbake til sessions/index.tsx
fil, den andre delte komponenten der er knappekomponenten. La oss lage den raskt inn 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> );
};
Dette er en enkel knappekomponent som vil hjelpe oss å forene utseendet og følelsen til knapper som enten er lenke- eller handlingsknapper på forskjellige steder i appen. For å gjøre komponenttypen trygg mens vi aksepterer rekvisitter for knapp og lenke, bruker vi litt maskinskriftmagi på rekvisittene og gjengivelsen.
Til slutt ser vi på selve sidekomponentkoden. Siden kartlegger alle sesjonsoppføringer og viser datoen for økten, navnet på verten for økten, premisset/detaljene lagt til av verten for økten og en total telling av hvor mange spørsmål det er. For å gjengi datoer bruker vi nettleserens innebygde Intl-modul som støtter lokalbasert formatering. Vi bruker et lite svg-ikon ved siden av antall spørsmål. Du kan finne alle eiendelene som brukes i appen her https://github.com/foysalit/remix-ama/tree/main/public/icons men bruk gjerne dine egne ikoner som du vil. Alle offentlige eiendeler må legges til /public
mappen og for å holde alle ikonene samlet, opprettet vi en ikonkatalog.
Med alt det ovennevnte bør du nå kunne gå til http://localhost:3000/sessions url og se 404-feilsiden siden vi ikke har opprettet noen økter ennå.
La oss nå bygge den nye øktsiden slik at vi kan være vert for en økt og se den på listesiden. Vi legger det på en annen side slik at brukerne enkelt kan gå til /sessions/new
på appen vår og begynn å holde en økt. Opprett en ny fil routes/sessions/new.tsx
med følgende kode:
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> )} </> );
}
På vanlig måte, la oss bryte ned denne store kodebiten.
- Handling – Når brukeren fyller ut øktens detaljer og treff
Start Session
vi ønsker å motta skjemadataene som en POST-forespørsel og opprette en ny økt for den påloggede brukeren. Så handlingen starter medrequireUserId(request)
kryss av. Det er en hjelpemetode som følger med stabelen og rett og slett omdirigerer uautoriserte brukere til påloggingssiden eller returnerer den autoriserte brukerens id. Deretter henter vi brukerinngangen for øktenscontent
kolonne ved hjelp avrequest.formData()
som gir oss tilgang til alle POST-data. Dersom innholdet ikke er fylt ut eller krysser en viss lengde, returnerer vi en feilmelding. Ellers starter vi økten og ruter brukeren til den nyopprettede øktsiden. - startSessionsForUser – Dette er kun en serverfunksjon som oppretter en ny sesjonsoppføring i databasen. La oss legge dette til vår
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 } });
};
Denne funksjonen mottar en bruker-ID og innholdet i økten. Hvis det allerede er en økt opprettet av brukeren innenfor dagens grenser, gir den en feil, ellers oppretter den en ny øktoppføring. Å manipulere datoer er litt rart i JS, så jeg foretrekker å slippe et bibliotek inn i prosjektet mitt for å håndtere datoer. I dette tilfellet bruker jeg dato-fns lib men bruk gjerne din foretrukne lib.
- Laster: Vi vil at bare autoriserte brukere skal se denne siden, så lasteren kjører ganske enkelt
requireUserId()
funksjon som vil logge ut uautentiserte brukere og hindre dem i å se sesjonsopprettingsskjemaet. - Transition – Remix kommer med en veldig nyttig
useTransition()
krok som gir deg tilgang til ulike tilstander på en side. Når du sender inn et skjema fra en side, send data til serveren og vent på svaret,transition.state
vil skifte tilsubmitting
i hele denne varigheten. Ved å bruke dette deaktiverer vi send-knappen for å forhindre at brukere ved et uhell forsøker å opprette flere økter. - Feilhåndtering – Når brukere prøver å starte en økt, får vi tilbake enten valideringsfeil for innholdsfeltet eller vi får en spesifikk feil hvis det allerede er en kjørende økt, vi håndterer begge via UI-visning av feilmelding ved å få tilgang til dataene fra
useActionData()
. - Skjemakomponent – Den
Form
komponent fra remix er bare et lite syntaktisk sukker på toppen av nettleserens skjemakomponent. Den opprettholder all standardoppførselen til et skjema. Du kan lese mer om det her: https://remix.run/docs/en/v1/guides/data-writes#plain-html-forms
Hvis du har fulgt alle trinnene ovenfor, åpner du http://localhost:3000/sessions/new i nettleseren din, og du bør se en side som ovenfor. Imidlertid, hvis du fyller ut inntastingsfeltet og trykker på Start økt, vil det ta deg til en 404 ikke funnet side, men det betyr ikke at knappen ikke fungerte. Du kan manuelt gå tilbake til http://localhost:3000/sessions og se den nyopprettede økten selv på listesiden. Noe sånt som dette:
Q & A
Med øktliste og opprettingssider som fungerer bra, kan vi nå bygge spørsmål og svar per økt. Hver økt skal være tilgjengelig via sessions/:sessionId
url hvor :sessionId
er en variabel som vil bli erstattet av id-er for økter. For å kartlegge dynamisk ruteparam til en rutefil i Remix, må vi starte filnavnet med $
tegn suffikset med navnet på parameteren. Så, i vårt tilfelle, la oss lage en ny fil routes/sessions/$sessionId.tsx
med følgende kode:
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}`);
}
Med denne vil vi raskt skumme gjennom noen av konseptene vi allerede har diskutert og fokusere mer på de nye bitene:
- Loader: returnerer øktoppføringen og gjeldende brukers ID. Det påkaller et anrop til
invariant
som er et eksternt bibliotek for enkelt å sjekke om en variabel er sann og kaste feil hvis ikke. - getSession: mottar sessionId som eneste argument. La oss implementere det i vår
models/session.server.ts
file:
export const getSession = (id: Session["id"]) => prisma.session.findFirst({ where: { id }, include: { questions: { include: { user: true, }, }, user: true, }, });
Legge merke til hvordan det inkluderer alle spørsmål som hører til en økt og brukerne som stilte disse spørsmålene også.
- Handling: denne siden kan gjøre 2 ting basert på hvem som ser på den. Verten for sesjonen kan svare på alle spørsmål, men kan ikke stille et spørsmål. Alle de andre brukerne kan bare gjøre det motsatte. Så handlingen må håndtere begge handlingene, og måten vi skiller mellom de to på er via
formData.get("answer_to_question")
input. Fra klientsiden vil vi kun sende dette når verten sender inn svar på et spørsmål. Legge merke til hvordan vi omdirigerer brukeren til/sessions/${params.sessionId}/questions/${questionId}
i tilfelle en av handlingene? Det er vår inngang til nestet ruting. Hold dette i bakhodet til senere. - addAnswerToQuestion: Denne hjelperen legger til vertens svar på et spørsmål ved å ta inn et objekt som et argument som inneholder spørsmålets id og svarinndata. La oss implementere dette
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 } });
};
Legg merke til at implementeringen sjekker om brukeren som sender forespørselen virkelig er verten for økten eller ikke, og gir en spesifikk feil hvis ikke.
- addQuestionToSession: Denne legger til en hvilken som helst ikke-vertsbrukers spørsmål til en sesjon ved å ta inn et objektargument som inneholder brukerens og sesjonens id og spørsmålet. Slik er det implementert i
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 } });
};
Legger du merke til hvordan vi blokkerer en bruker fra å legge ut det samme spørsmålet mer enn én gang per økt?
- useParams hook: Denne kroken er en annen proxy to react-ruter som ganske enkelt gir oss tilgang til alle ruteparametere som sessionId i vårt tilfelle.
- Spørsmålsskjema: Til alle autentiserte brukere som ikke er vert, viser vi et inndataskjema for spørsmål på hver økt over listen over tidligere stilte spørsmål.
- QuestionAnswer-komponent: For å holde en stor del av koden delbar og isolert, legger vi et enkelt spørsmål i en delt komponentfil. Vi vil se hvorfor om litt, men la oss se implementeringen av denne komponenten først. Opprett en ny fil
app/components/sessions/question-answer.tsx
og legg inn følgende kode der:
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> );
};
Legg merke til at denne komponenten bygger inn et skjema inne i det som betyr at hvert spørsmål vil gjengi dette skjemaet for verten for å gi dem en enkel måte å legge til svar på spørsmål som de ikke har besvart ennå, og send inn-knappen i skjemaet har name="answer_to_question" value={question.id}
rekvisitter som hjelper oss å signalisere backend (handling) at denne skjemainnsendingen må håndteres som svarinndata fra verten.
Du har kanskje også lagt merke til at hvert spørsmål lenker til to={
spørsmål/${question.id}}
som bringer oss til det nestede ruting-emnet. La oss ta en titt på det nå.
Nestet ruting
I en tradisjonell react-app vil du dele opp en side i flere komponenter og komponentene laster inn sine egne data internt eller blir matet av et globalt datalager som sender dataene til den. I Remix vil du gjøre det via nestet ruting der en side kan legge inn en annen side inne som har sin egen livssyklus som datalaster, handling, feilgrense osv. Dette er utrolig kraftig og legger til et helt nytt nivå av pålitelighet og hastighet i UX . Vi skal bruke dette til å vise en kommentartråd per spørsmål i en økt.
For å lette dette, la vi til en <Outlet context={data.session} />
komponenten på siden med øktdetaljer. Outlet
er beholderen for nestet sideinnhold, og den gir oss muligheten til å bygge oppsettet for en underordnet side på overordnet nivå. Når brukeren går inn i en nestet rute, vil denne bli erstattet av html-en som er gjengitt med det laveste nivået av den nestede sideruten.
Nå, for å få tilgang til kommentartråden, dirigerer vi brukere til session/:sessionId/questions/:questionId
rute så for å matche det i filsystemet, må vi opprette en ny katalog inne i routes/sessions/$sessionId/questions
og lag en fil med navnet $questionId.tsx
innsiden av den. Legg merke til at vi nå har en fil med navnet $sessionId.tx
og en katalog som heter $sessionId
. Dette kan være forvirrende, men er som designet. Dette forteller Remix å bruke sessionId.tsxfilas overordnetsiden og gjengi eventuelle nestede ruter fra sessionId.tsx-filen som overordnet side og gjengi eventuelle nestede ruter fra `øktnummerdirectory. Now let’s put in the following code in the
$questionId.tsx` fil:
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> );
}
Her bruker vi det question-answer.tsx
komponent for å vise den samme UI-komponenten vi viser under økten, men i dette tilfellet øverst i kommentartråden, for å gi leserne kontekst for kommentarene. Vi legger også inn et skjema inne i det der alle autentiserte brukere kan legge inn en kommentar. La oss sjekke ut de 2 nye serverfunksjonene vi bruker i lasteren og deretter handling for denne siden fra 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 }, });
};
Et par bemerkelsesverdige ting i denne komponenten er:
- useOutletContext hook: Dette gir oss tilgang til alle rekvisittene som sendes til barnesiden via
<Outlet … />
komponent på den overordnede siden. Så her har vi tilgang til hele økten med alle spørsmålene i den, og i stedet for å spørre etter enkeltspørsmålet i tråden, plukker vi det ganske enkelt ut av de allerede beståtte dataene. - Laster kommentarer: Vi laster inn alle kommentarer for et spørsmål uten paginering, noe som ikke er en god idé for noen produksjonsapp.
Wrap up
Hvis du har fulgt alle de foregående trinnene, åpner du appen i et inkognitovindu og oppretter en ny konto. Så hvis du klikker deg inn på den tidligere opprettede økten, bør du se et inndatafelt for å stille et spørsmål:
Nå hvis du skriver inn et spørsmål og legger det fra den nye kontoen, bør du se noe sånt som dette:
Som viser kommentaren din, åpner kommentaren som en tråd på høyre side og lar deg eller en annen bruker legge til en kommentar i tråden.
Til slutt, hvis du går tilbake til det andre nettleservinduet der du er logget på som vert for økten og oppdaterer øktsiden, bør du se kommentaren der med en inngang rett under for å legge ut svaret ditt:
Hva blir det neste?
Du har gjort en fantastisk jobb med å følge med her, så vennligst gi deg selv en runde med applaus! Hvis du er som meg og aldri kan få nok av skinnende nye JS-ting, lurer du kanskje på: "Dette er flott, men er dette noe jeg ville brukt som bruker?" og hvis du er tro mot deg selv, vil svaret være et stort fett NO
. Så jeg vil gi deg noen ideer som kan gjøre denne raskt sammensatte leketøysappen til en produksjonsklar app som kan få litt drahjelp i den virkelige verden:
- Sanntidsdatasynkronisering – AMA-økter handler om timing. Det er i hvert fall de gode. Folk som er vert for dem har ikke tid til å henge rundt og trykke på oppdatering hver 10. for å se etter nye kommentarer/spørsmål osv. Så alle disse bør synkroniseres i sanntid og fremheves til verten. Samme for deltakerne.
- Paginering – Som nevnt gjennom hele innlegget, kuttet vi noen hjørner i datainnlastingen som absolutt ikke vil skaleres i en virkelig app. Å legge til paginering i alle spørsmål vil også være en god læringsopplevelse.
- Sesjonstidtaker og fremtidig økt: Siden økter på denne appen er tidsrammede per dag, kan det å vise en tidtaker for når økten slutter gi opplevelsen et spenningselement. En annen morder funksjon ville være å la verter planlegge økter for fremtiden og skape litt hype rundt det ved å vise frem kommende økt på hjemmesiden på en mer fremhevet måte
Ressurser
- SEO-drevet innhold og PR-distribusjon. Bli forsterket i dag.
- Platoblokkkjede. Web3 Metaverse Intelligence. Kunnskap forsterket. Tilgang her.
- kilde: https://www.codementor.io/foysalit/build-a-fullstack-ama-app-with-remix-prisma-postgresql-1vsbmepsp3
- 1
- a
- evne
- I stand
- Om oss
- ovenfor
- adgang
- tilgjengelig
- Tilgang
- Ifølge
- tilsvar
- Logg inn
- nøyaktig
- Handling
- handlinger
- faktisk
- la til
- Ytterligere
- I tillegg
- adresse
- Legger
- Justering
- Etter
- eldgamle
- fremover
- Alle
- tillate
- alene
- sammen
- allerede
- alltid
- AMA
- AMA -økter
- utrolig
- blant
- og
- En annen
- besvare
- svar
- api
- app
- Påfør
- apps
- argument
- rundt
- Array
- eiendel
- Eiendeler
- forsøker
- Auth
- autentisert
- Autentisering
- forfatter
- auto
- tilgjengelig
- avvente
- tilbake
- Backed
- Backend
- basen
- basert
- grunnleggende
- I utgangspunktet
- Grunnleggende
- fordi
- blir
- før du
- bak
- være
- under
- mellom
- Stor
- Bit
- Blokker
- blokkering
- Blogg
- kroppen
- grensen
- Bunn
- grenser
- Eske
- Break
- Bringer
- nett~~POS=TRUNC leseren~~POS=HEADCOMP
- bygge
- Bygning
- bygget
- Bunch
- knapp
- ring
- ringer
- Samtaler
- hvilken
- cascade
- saken
- Catch
- fanget
- viss
- Gjerne
- endring
- Endringer
- tegn
- sjekk
- kontroll
- Sjekker
- barn
- Barn
- Velg
- klasse
- fjerne
- kunde
- kode
- kodebase
- Kolonne
- kolonner
- COM
- kommentere
- kommentarer
- forplikte
- samfunnet
- konkurranse
- fullføre
- kompleksitet
- komponent
- komponenter
- konsept
- konsepter
- forvirrende
- Følgelig
- Konsoll
- konstant
- stadig
- Container
- inneholder
- innhold
- kontekst
- overbevise
- hjørner
- kunne
- Par
- kurs
- dekke
- skape
- opprettet
- skaper
- Kreativ
- CSS
- Gjeldende
- I dag
- Kutt
- dato
- Database
- Dato
- datoer
- dato tid
- dag
- dedikert
- dyp
- Misligholde
- helt sikkert
- glad
- dybde
- designet
- detaljert
- detaljer
- dev
- Utvikler
- utviklere
- Utvikling
- gJORDE
- differensiere
- kataloger
- diskutert
- diskusjon
- Vise
- dokumentasjon
- ikke
- gjør
- ikke
- ned
- Drop
- slippe
- under
- dynamisk
- hver enkelt
- Tidlig
- enklere
- lett
- enten
- emalje
- slutter
- engasjement
- nok
- sikre
- sikrer
- Hele
- enheter
- enhet
- entry
- feil
- feil
- hovedsak
- etc
- Eter (ETH)
- Selv
- alle
- alt
- utviklet seg
- nøyaktig
- eksempel
- Unntatt
- eksisterende
- erfaring
- eksportere
- eksporten
- uttrykks
- utvide
- utvendig
- øye
- legge til rette
- rettferdig
- Fall
- kjent
- lest
- familie
- Mote
- FAST
- Fett
- Trekk
- Egenskaper
- Fed
- Feet
- Noen få
- felt
- Felt
- filet
- Filer
- fyll
- fylt
- Endelig
- Finn
- Først
- Første øyekast
- Fokus
- følge
- fulgt
- etter
- fonter
- utenlandske
- skjema
- funnet
- Rammeverk
- Gratis
- hyppig
- fersk
- vennlig
- fra
- fullt
- fullt
- moro
- funksjon
- funksjoner
- framtid
- Gevinst
- general
- få
- gå
- GitHub
- Gi
- gir
- Giving
- Global
- Go
- Går
- skal
- god
- google skrifter
- flott
- grunnarbeid
- Vokser
- håndtere
- Håndtering
- praktisk
- Henge
- hash
- hode
- sunt
- hjelpe
- hjelper
- her.
- Høy
- høyere
- Fremhevet
- historisk
- hit
- Hits
- Hjemprodukt
- håp
- vert
- Hosting
- hus
- Hvordan
- Hvordan
- Men
- HTML
- HTTPS
- Hundrevis
- Hype
- ICON
- Tanken
- Ideer
- identifisere
- bilder
- iverksette
- gjennomføring
- implementert
- importere
- in
- inkludere
- inkludert
- inkluderer
- Inkludert
- utrolig
- indeks
- individuelt
- info
- innledende
- inngang
- innsikt
- installere
- installere
- i stedet
- interessert
- introdusert
- intuitiv
- investering
- påkaller
- isolert
- IT
- selv
- Jobb
- JSON
- hoppe
- Hold
- nøkkel
- Type
- Vet
- Etiketten
- landskap
- stor
- Siste
- I fjor
- Layout
- føre
- læring
- Permisjon
- Legacy
- Lengde
- Lar
- Nivå
- LG
- Liberty
- Bibliotek
- linje
- linjer
- LINK
- lenker
- Liste
- lite
- laste
- loader
- lasting
- laster
- logo
- Lang
- Se
- ser ut som
- UTSEENDE
- Lot
- laveste nivå
- magi
- opprettholder
- større
- gjøre
- Making
- manipulert
- manipulere
- manuelt
- mange
- kart
- Kart
- Match
- midler
- nevnt
- melding
- metode
- metoder
- kunne
- migrere
- migrasjon
- modell
- moduler
- mer
- mest
- flytte
- flytting
- Mozilla
- flere
- navn
- oppkalt
- Naviger
- Trenger
- behov
- Ny
- neste
- Next.js
- node
- node.js
- normal
- bemerkelsesverdig
- bemerkelsesverdig
- Antall
- objekt
- gjenstander
- forekom
- offisiell
- ONE
- åpen
- åpner
- drift
- Drift
- motsatt
- Alternativ
- rekkefølge
- Annen
- ellers
- omriss
- utenfor
- egen
- Paginering
- paradigmet
- parameter
- del
- deltakere
- bestått
- passerer
- Passord
- Past
- Ansatte
- perspektiv
- plukke
- brikke
- stykker
- steder
- plassering
- plattform
- plato
- Platon Data Intelligence
- PlatonData
- Spille
- vær så snill
- plugins
- Point
- Synspunkt
- Post
- postet
- postgresql
- makt
- kraftig
- trekker
- trekkes
- pen
- forebygge
- forrige
- tidligere
- primære
- prioritert
- prisme
- sannsynligvis
- problemer
- Produkt
- Produksjon
- prosjekt
- eiendom
- gi
- forutsatt
- gir
- proxy
- offentlig
- publisert
- formål
- formål
- presset
- sette
- Sette
- Q & A
- kvaliteter
- spørsmål
- spørsmål
- Rask
- raskt
- Reager
- Lese
- Reader
- lesere
- Lesning
- klar
- ekte
- virkelige verden
- realtime
- motta
- mottar
- anbefaler
- omdirigere
- redusere
- referanser
- Uansett
- registrere
- registrert
- registrering
- i slekt
- forholdet
- Relasjoner
- relativt
- pålitelighet
- pålitelig
- Remix
- gjengivelse
- erstattet
- anmode
- forespørsler
- påkrevd
- Krever
- Ressurser
- svar
- REST
- begrense
- retur
- avkastning
- revolusjon
- root
- runde
- Rute
- router
- ruter
- RAD
- Kjør
- rennende
- trygge
- sake
- samme
- skalerbar
- Skala
- planlegge
- omfang
- Sekund
- se
- forstand
- Session
- sesjoner
- oppsett
- delt
- SKIFTENDE
- Kort
- bør
- Vis
- utstillingsvindu
- Viser
- undertegne
- Signal
- signering
- Enkelt
- forenkle
- forenkle
- ganske enkelt
- siden
- enkelt
- Sakte
- liten
- So
- LØSE
- noen
- noe
- SPA
- Rom
- spesiell
- spesifikk
- fart
- Snurre rundt
- splittet
- Spot
- spunnet
- stable
- Stabler
- Standard
- Begynn
- startet
- Start
- starter
- Tilstand
- Stater
- status
- Steps
- Still
- Stopp
- stoppe
- oppbevare
- Strategi
- stream
- stil
- innsending
- send
- innsendt
- suksess
- slik
- SAMMENDRAG
- Super
- støtte
- Støtter
- SVG
- syntaks
- system
- bord
- Tailwind
- Ta
- tar
- ta
- lag
- Teknisk
- forteller
- mal
- terminal
- Testing
- De
- Grunnleggende
- Landskapet
- deres
- tema
- ting
- ting
- tenker
- tusener
- Gjennom
- hele
- Kaster
- tid
- timing
- typen
- Tittel
- til
- i dag
- sammen
- også
- verktøy
- topp
- Tema
- Totalt
- berøre
- spore
- trekkraft
- tradisjonelle
- overgang
- sant
- tutorial
- tv
- Loggfila
- ui
- etter
- Uventet
- kommende
- Oppdater
- oppdatering
- URL
- us
- bruke
- Bruker
- Brukere
- vanligvis
- ux
- VALIDERE
- validert
- validering
- verdi
- ulike
- av
- Se
- visninger
- vente
- måter
- web
- Webutvikling
- Nettsted
- Hva
- Hva er
- hvilken
- mens
- HVEM
- vil
- Metalltråd
- innenfor
- uten
- lurer
- Arbeid
- arbeidet
- arbeid
- verden
- verdt
- ville
- skriving
- X
- år
- Din
- deg selv
- zephyrnet