Un guide détaillé pour entendre les opinions
Dans cet article, je vais vous montrer comment utiliser Ably (une plateforme de communication en temps réel) pour créer une application où les utilisateurs peuvent partager leurs sondages. Et nous utiliserons MongoDB pour stocker toutes les données du sondage.
La démo ci-dessous montre l’application en action :
Créez un nouveau projet Next.js avec Typescript, puis installez les packages suivants :
npm i @ably-labs/react-hooks ably axios chart.js connect-redis date-fns ioredis jotai mongoose react-chartjs-2 react-feather next-session
Si cela vous a semblé beaucoup, voici la répartition :
@ably-labs/react-hooks
– Fournit une interface de crochet React pour simplifier les opérations avec Ablyably
– Bibliothèque client pour les API Ably Realtime et RESTaxios
– Client HTTP facile à utiliserchart.js
– Bibliothèque de graphiques flexibles, enveloppée pour React avecreact-chartjs-2
connect-redis
– Crée un wrapper de magasin de session autour d’une instance Redismongoose
– Modélisation d’objets pour MongoDB, inclut la validation de schéma, la conversion de type et les aides à la création de requêtesioredis
– Un client Redis performant pour Node.jsjotai
– Gestion d’état simple dans Reactreact-feather
– Fournit des wrappers React pour les icônes de La plume
Assurez-vous maintenant que les secrets tels que la clé API Ably et l’URI de connexion MongoDB sont stockés dans un .env.local
dossier.
ABLY_API_KEY=******
MONGODB_URI=******
Mongoose modélise vos documents MongoDB en tant que modèles. Lorsque les modèles sont créés, les données sont validées par rapport à un schéma. Un schéma peut définir les propriétés et les types de données d’un document. Les schémas Mongoose peuvent également utiliser d’autres validateurs tels que la vérification de la longueur, de l’énumération et la correspondance des expressions régulières.
Pour notre application de vote, le seul modèle dont nous avons besoin est un sondage. Créons un fichier nommé models/Poll.ts
et créer le schéma.
import mongoose, { InferSchemaType } from "mongoose";
const { Schema } = mongoose;const pollSchema = new Schema({
creator: { type: String, required: true },
title: { type: String, required: true },
// `results` contains current votes i.e. {[candidate: string]: string[]}
// , where the array value represents the voters
results: { type: Map, of: [String], required: true },
privacy: { type: Boolean, required: true, default: false },
end: { type: Date, required: true },
});
// Creates a type interface from the given schema
// The _id field is needed to identify any given document
export type Poll = InferSchemaType<typeof pollSchema> & { _id: string };
// Uses types which can be serialised into Next.js page props
export type PollPrimitive = Omit<Omit<Poll, "end">, "results"> & {
end: string;
results: { [key: string]: string[] };
};
// Avoids re-initialisation of the model class
export default mongoose.models.Poll || mongoose.model("Poll", pollSchema);
Créez un nouveau dossier dans le projet nommé lib
. Dans ce dossier, créez un fichier nommé dbConnect.ts
. Le fichier exportera une fonction pour renvoyer une connexion en cache à la base de données.
import mongoose from "mongoose";const MONGODB_URI = process.env.MONGODB_URI;
if (!MONGODB_URI) {
throw new Error(
"Please define the MONGODB_URI environment variable inside .env.local"
);
}
/**
* Global is used here to maintain a cached connection across hot reloads
* in development. This prevents connections growing exponentially
* during API Route usage.
*/
declare global {
var mongoose: {
conn: typeof import("mongoose") | null;
promise: Promise<typeof import("mongoose")> | null;
};
}
let cached = global.mongoose;
if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}
async function dbConnect() {
if (!MONGODB_URI) return;
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
};
cached.promise = mongoose.connect(MONGODB_URI, opts);
}
try {
cached.conn = await cached.promise;
} catch (e) {
cached.promise = null;
throw e;
}
return cached.conn;
}
export default dbConnect;
Notre application se composera des itinéraires de page suivants :
/
– Liste de tous les sondages/[id]
– Affichage d’un sondage spécifique/[id]/edit
– Édition d’un sondage spécifique/new
– Création d’un sondage
et utilise ces routes d’API :
/api/ably-token
– Générer une demande de jeton pour l’authentification avec Ably/api/polls
(POST) – Créer un sondage/api/polls/[id]
(PUT, DELETE) – Modifier un sondage/api/vote
(POST) – Ajouter l’utilisateur actuel à l’option de sondage donnée
Pour commencer, nous allons commencer avec les routes d’API ci-dessus.
Pour simplifier notre application, chaque utilisateur sera représenté par son identifiant de session au lieu d’une solution traditionnelle nom d’utilisateur-mot de passe. Bien qu’il ne soit pas possible pour les utilisateurs de conserver indéfiniment le même identifiant de session, l’avantage est qu’aucun processus d’authentification n’est impliqué. Par conséquent, l’utilisation de l’application sera plus pratique.
Dans le lib
dossier, créez un nouveau fichier nommé getSession.ts
. Vous vous souvenez de Redis ? Eh bien, nous l’utilisons ici pour stocker les données de session.
import nextSession from "next-session";
import { expressSession, promisifyStore } from "next-session/lib/compat";
import RedisStoreFactory from "connect-redis";
import Redis from "ioredis";const RedisStore = RedisStoreFactory(expressSession);
export const getSession = nextSession({
autoCommit: false,
store: promisifyStore(
new RedisStore({
client: new Redis(),
})
),
});
Assurez-vous également de définir autoCommit
à false
. Lorsqu’il est réglé sur true
, next-session
enregistre uniquement la session si elle change, mais nous n’accéderons qu’à l’identifiant et n’apporterons aucune modification. Par conséquent, nous devons appeler await session.commit()
chaque fois que nous accédons à la session.
Ici, nous initialisons un objet client pour l’API Ably REST. Ensuite (dans la route), l’ID client est défini sur l’ID de session et la demande de jeton est envoyée au client.
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "lib/getSession";
import Ably from "ably/promises";const rest = new Ably.Rest(process.env.ABLY_API_KEY as string);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getSession(req, res);
await session.commit();
const tokenParams = {
clientId: session.id,
};
const tokenRequest = await rest.auth.createTokenRequest(tokenParams);
res.status(200).json(tokenRequest);
}
Dans cette route API, nous utiliserons la requête POST pour construire un nouveau Poll
maquette. Comme précisé précédemment, le créateur du Poll
sera l’identifiant de session.
import type { NextApiRequest, NextApiResponse } from "next";
import Poll from "models/Poll";
import { getSession } from "lib/getSession";
import dbConnect from "lib/dbConnect";export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
res.status(405).end(`Method ${req.method} Not Allowed`);
return;
}
await dbConnect();
const session = await getSession(req, res);
await session.commit();
const { title, options, end, privacy } = req.body;
const poll = new Poll({
creator: session.id,
title: title,
results: options.map((option: string) => [option, []]),
end: end && new Date(end),
privacy: privacy,
});
await poll.save();
res.status(200).json(poll);
}
Lors de la mise à jour ou de la suppression d’un sondage, un client doit envoyer une requête à cette route API. Nous devons d’abord vérifier si le spécifié Poll
existe. Ensuite, nous vérifions si l’utilisateur a créé le Poll
.
Une fois le sondage mis à jour, un message est publié sur le canal Ably. Le message contient les informations mises à jour. Ainsi, les modifications apportées aux propriétés, telles que le titre du sondage, peuvent être visualisées en temps réel.
import type { NextApiRequest, NextApiResponse } from "next";
import Ably from "ably/promises";
import Poll from "models/Poll";
import { getSession } from "lib/getSession";
import dbConnect from "lib/dbConnect";const rest = new Ably.Rest(process.env.ABLY_API_KEY as string);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
await dbConnect();
const session = await getSession(req, res);
await session.commit();
const { id } = req.query;
let poll = await Poll.findById(id).exec();
if (!poll) {
res.status(404).send("Cannot find poll at requested ID");
return;
}
if (poll.creator !== session.id) {
res.status(403).end("Unauthorized access to poll");
return;
}
switch (req.method) {
case "PUT":
const { title, options, end, privacy } = req.body;
// Any new options will have empty arrays for their voters,
// but existing options requested will be overwritten
// by their previous value
let results = Object.fromEntries(
options.map((option: string) => [option, []])
);
results = { ...results, ...Object.fromEntries(poll.results) };
poll.title = title;
poll.results = results;
poll.end = new Date(end);
poll.privacy = privacy;
// Save the updated document and return it
poll = await poll.save();
const channel = rest.channels.get(`polls:${id}`);
await channel.publish("update-info", poll);
res.status(200).end();
break;
case "DELETE":
await poll.delete();
res.status(200).end();
break;
default:
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
Lorsqu’un utilisateur sélectionne une option, cette route d’API vérifie d’abord que l’option est valide. Ensuite, il ajoute l’identifiant de session au tableau des électeurs de l’option si l’utilisateur n’a pas voté.
import type { NextApiRequest, NextApiResponse } from "next";
import Ably from "ably/promises";
import Poll from "models/Poll";
import { getSession } from "lib/getSession";
import dbConnect from "lib/dbConnect";const rest = new Ably.Rest(process.env.ABLY_API_KEY as string);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
res.status(405).end(`Method ${req.method} Not Allowed`);
return;
}
await dbConnect();
const session = await getSession(req, res);
await session.commit();
const { id, option } = req.body;
const poll = await Poll.findById(id).exec();
if (!poll) {
res.status(404).send("Cannot find poll at requested ID");
return;
}
const results = Object.fromEntries(poll.results);
if (!Object.keys(results).includes(option)) {
res.status(400).json(`Invalid option "${option}"`);
return;
}
let voters: string[];
for (let candidate of Object.keys(results)) {
voters = results[candidate];
if (voters.includes(session.id)) {
// Already voted, stop from voting again
res.status(403).json(`Already voted for "${candidate}"`);
return;
}
}
results[option].push(session.id);
poll.results = results;
await poll.save();
// Publish update to the channel
const channel = rest.channels.get(`polls:${id}`);
channel.publish("update-votes", results);
res.status(200).end();
}
Remarquez comment nous devons appeler session.commit()
chaque fois que nous avons besoin de la session. Comment pourriez-vous supprimer cette redondance ?
Avant de faire quoi que ce soit d’autre, nous devons nous assurer que nous avons configuré Ably pour le client. Nous devons également enregistrer les éléments nécessaires à l’affichage des graphiques.
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { configureAbly } from "@ably-labs/react-hooks";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
} from "chart.js";ChartJS.register(CategoryScale, LinearScale, BarElement);
// Change this URL if necessary
configureAbly({ " });
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
Nous devons récupérer les données de tous les sondages sur la page de liste. Les sondages demandés sont filtrés pour être publics ou du créateur du sondage. Avant de renvoyer les accessoires de la page, les données doivent être sérialisées pour types de données primitifs.
import dbConnect from "lib/dbConnect";
import { getSession } from "lib/getSession";
import Poll, { PollPrimitive } from "models/Poll";
import { GetServerSideProps, NextPage } from "next";const Home: NextPage<{ polls: PollPrimitive[] }> = ({ polls }) => {
// ...
return <div />;
};
export const getServerSideProps: GetServerSideProps = async ({
req,
res,
query,
}) => {
// If `personal` is "true", filter by only the current user's polls
const { personal } = query;
const session = await getSession(req, res);
await session.commit();
await dbConnect();
const filter =
personal === "true" ? { creator: session.id } : { privacy: false };
// Serialize ObjectId and Date to string
const result = await Poll.find(filter);
const polls = result.map((doc) => {
const poll = doc.toJSON();
poll._id = poll._id.toString();
poll.end = poll.end.toString();
return poll;
});
return {
props: { polls },
};
};
export default Home;
Dans le composant de page, nous affichons tous les sondages disponibles ou un message si aucun sondage n’existe. Assurez-vous également que TailwindCSS est configuré pour ce projet Next.js.
import AppBar from "components/AppBar";
import PollDisplay from "components/PollDisplay";
import dbConnect from "lib/dbConnect";
import { getSession } from "lib/getSession";
import Poll, { PollPrimitive } from "models/Poll";
import { GetServerSideProps, NextPage } from "next";
import Head from "next/head";
import { Terminal } from "react-feather";const Home: NextPage<{ polls: PollPrimitive[] }> = ({ polls }) => {
return (
<>
<Head>
<title>VotR</title>
<meta
name="description"
content="Generated by create next app"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<AppBar />
<main className="p-3">
{polls.length === 0 && (
<div className="h-80 w-full flex flex-col justify-center items-center space-y-5 text-2xl font-semibold">
<h3>No polls created.</h3>
<Terminal className="w-9 h-9" />
<h3>Create one now!</h3>
</div>
)}
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{polls.map((poll) => (
<PollDisplay key={poll._id} data={poll} />
))}
</div>
</main>
</>
);
};
// ...
Créez maintenant un nouveau dossier nommé components
avec son premier fichier, AppBar.tsx
. Ce composant crée un bouton pour changer le filtre du sondage et un bouton pour créer un sondage.
Ajoutez le code suivant au fichier :
import { useRouter } from "next/router";
import React from "react";
import { Plus, Filter } from "react-feather";export default function AppBar() {
const router = useRouter();
const viewingPersonal = router.query.personal === "true";
const openYourPolls = () => {
const newUrl = `/?personal=${(!viewingPersonal).toString()}`;
router.replace(newUrl, newUrl);
};
const openCreate = () => {
router.push("/new");
};
return (
<header className="py-6 px-5 h-20 w-full border-b-2 border-b-gray-300 flex flex-row justify-between">
<h1 className="text-3xl font-bold">VotR</h1>
<div className="flex flex-row justify-center space-x-3">
<button
onClick={openYourPolls}
type="button"
className="bg-blue-500 rounded-xl border-white border-2 h-10 px-2 text-white"
>
<Filter className="inline mr-2" />
{viewingPersonal ? "All Polls" : "Your Polls"}
</button>
<button
onClick={openCreate}
type="button"
className="bg-blue-500 rounded-xl border-white border-2 h-10 px-2 text-white"
>
<Plus className="inline mr-2" />
Create
</button>
</div>
</header>
);
}
Maintenant, créez un nouveau fichier nommé PollDisplay.tsx
dans le même dossier et insérez le code suivant. Ce composant prend les données du sondage comme accessoire et affiche quelques informations de base :
- Le titre
- Combien d’utilisateurs ont voté au total
- Combien d’options sont disponibles
import { PollPrimitive } from "models/Poll";
import { useRouter } from "next/router";
import React from "react";
import { User, MapPin } from "react-feather";export default function PollDisplay({ data }: { data: PollPrimitive }) {
const router = useRouter();
const voters = Object.values(data.results).reduce(
(acc, curr) => acc + curr.length,
);
const options = Object.keys(data.results).length;
const openPoll = () => {
router.push(`/${data._id}`);
};
return (
<div
onClick={openPoll}
className="mx-auto sm:m-0 rounded-xl border-gray-300 border-4 bg-slate-200 flex flex-col p-4 w-60 h-40 items-center justify-around hover:cursor-pointer"
>
<h3 className="text-xl font-semibold text-center">{data.title}</h3>
<span
className="flex flex-row"
title={`${options} options available`}
>
<MapPin className="mr-1" /> {options}
</span>
<span className="flex flex-row" title={`${voters} people voted`}>
<User className="mr-1" /> {voters}
</span>
</div>
);
}
Dans cette route dynamique, nous récupérons les données du sondage et les renvoyons sous forme d’accessoires, avec l’identifiant de session. Créez le fichier de route dans un index.tsx
dossier dans un [id]
dossier dans le pages
dossier.
import React from "react";
import type { GetServerSideProps, NextPage } from "next";
import { PollPrimitive } from "../../models/Poll";
import dbConnect from "lib/dbConnect";
import { getSession } from "lib/getSession";
import getPoll from "lib/getPoll";const PollPage: NextPage<{ poll: PollPrimitive; sessionId: string }> = (
props
) => {
// ...
return <div />;
};
export const getServerSideProps: GetServerSideProps = async ({
params,
req,
res,
}) => {
const session = await getSession(req, res);
await session.commit();
await dbConnect();
const id = params?.id as string;
const poll = await getPoll(id);
if (!poll) {
return {
notFound: true,
};
}
return {
props: {
poll,
sessionId: session.id,
},
};
};
export default PollPage;
Vous devez créer le lib/getPoll.ts
fichier contenant la fonction pour récupérer un sondage de la base de données.
import Poll, { PollPrimitive } from "models/Poll";export default async function getPoll(
id: string
): Promise<PollPrimitive | null> {
const poll = await Poll.findById(id).lean();
if (!poll) {
return null;
}
poll._id = poll._id.toString();
poll.end = poll.end.toString();
return poll;
}
Passant au composant de page, voici la liste complète des importations pour le fichier :
import React, { useEffect, useMemo } from "react";
import type { GetServerSideProps, NextPage } from "next";
import { PollPrimitive } from "../../models/Poll";
import dbConnect from "lib/dbConnect";
import Head from "next/head";
import { useChannel } from "@ably-labs/react-hooks";
import { useAtom } from "jotai";
import { pollAtom, sessionIdAtom } from "lib/store";
import { getSession } from "lib/getSession";
import VotesDisplay from "components/VotesDisplay";
import VotesControl from "components/VotesControl";
import { Clipboard, Edit2, Home, Trash } from "react-feather";
import { useRouter } from "next/router";
import { differenceInMinutes } from "date-fns";
import EndResult from "components/EndResult";
import getPoll from "lib/getPoll";
import axios from "axios";
Notez les deux atomes à partir desquels nous importons lib/store
. Sur notre page, nous allons créer un Provider
composant pour initialiser les valeurs de ces atomes. Cela signifie que nous pouvons partager les données entre les composants.
Utilisez notre PollPrimitve
taper et jotai
pour exporter les deux atomes de lib/store.ts
:
import { atom } from "jotai";
import { PollPrimitive } from "models/Poll";export const pollAtom = atom<PollPrimitive>({
_id: "",
creator: "",
title: "",
results: {},
privacy: false,
end: "",
});
export const sessionIdAtom = atom("");
Maintenant, ajoutez le code suivant au page
composant:
// ...const PollPage: NextPage<{ poll: PollPrimitive; sessionId: string }> = (
props
) => {
const router = useRouter();
const [sessionId, setSessionId] = useAtom(sessionIdAtom);
const [poll, setPoll] = useAtom(pollAtom);
const hasVoted = useMemo(() => {
for (let voters of Object.values(poll.results)) {
if (voters.includes(sessionId)) {
return true;
}
}
return false;
}, [poll, sessionId]);
useEffect(() => {
setPoll(props.poll);
setSessionId(props.sessionId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props]);
// ...
});
// ...
Ce code fait trois choses principales :
- Il consomme nos deux atomes
- Calcule une propriété pour vérifier si l’utilisateur a déjà voté dans le sondage
- Met à jour les valeurs atomiques avec les données des accessoires
Maintenant que les atomes sont initialisés, nous pouvons écouter toutes les mises à jour d’Ably pour mettre à jour à nouveau les atomes.
// ...useChannel(`polls:${poll._id}`, "update-votes", (message) => {
setPoll((oldValue: PollPrimitive) => ({
...oldValue,
results: message.data,
}));
});
useChannel(`polls:${poll._id}`, "update-info", (message) => {
setPoll(message.data);
});
// ...
Le rendu conditionnel obtenu lors du rendu provient de ces trois variables :
const isCreator = poll.creator === sessionId;
const hasEnded = differenceInMinutes(new Date(), new Date(poll.end)) > 0;
const showDisplay = hasEnded || isCreator || hasVoted;
- Si
isCreator
esttrue
nous affichons des boutons pour modifier le sondage et supprimer le sondage, comme indiqué précédemment - Si
hasEnded
esttrue
nous présentons l’option gagnante pour le sondage - Si l’un ou l’autre est
true
ouhasVoted
esttrue
nous affichons un graphique à barres des résultats au lieu des options de vote
Renvoyez maintenant le code suivant depuis le composant :
return (
<>
<Head>
<title>{poll.title}</title>
</Head>
<div className="w-full flex flex-row justify-between absolute top-0 left-0 space-x-3 p-4">
<div className="flex flex-row space-x-3">
<button
type="button"
title="Go home"
onClick={() => router.push("/")}
className="p-3 rounded-full bg-blue-300 hover:ring"
>
<Home className="text-white" />
</button>
{isCreator && (
<button
type="button"
title="Open edit page"
onClick={() => router.push(`/${poll._id}/edit`)}
className="p-3 rounded-full bg-blue-300 hover:ring"
>
<Edit2 className="text-white" />
</button>
)}
</div>
<div className="flex flex-row space-x-3">
<button
type="button"
title="Copy the link to this poll"
onClick={() =>
navigator.clipboard.writeText(window.location.href)
}
className="p-3 rounded-full bg-blue-300 hover:ring active:scale-90"
>
<Clipboard className="text-white" />
</button>
{isCreator && (
<button
type="button"
title="Delete this poll"
onClick={onDelete}
className="p-3 rounded-full bg-red-300 hover:ring ring-fuchsia-400"
>
<Trash className="text-white" />
</button>
)}
</div>
</div>
<main className="p-5 h-screen w-screen flex flex-col">
<h1 className="font-bold text-4xl text-center my-5 underline">
{poll.title}
</h1>
<div className="px-5 flex-1 flex flex-col justify-center items-center">
{showDisplay ? <VotesDisplay /> : <VotesControl />}
{hasEnded && <EndResult />}
</div>
</main>
</>
);
Pour modifier les données de sondage existantes, cette route doit d’abord récupérer les données pour remplir le formulaire. Créez cette route dans le pages/[id]
dossier créé précédemment.
import React from "react";
import type { GetServerSideProps, NextPage } from "next";
import Head from "next/head";
import axios from "axios";
import { useRouter } from "next/router";
import { PollPrimitive } from "models/Poll";
import PollEditForm from "components/PollEditForm";
import dbConnect from "lib/dbConnect";
import getPoll from "lib/getPoll";
import { format } from "date-fns";
import { getSession } from "lib/getSession";const EditPage: NextPage<{ poll: PollPrimitive }> = ({ poll }) => {
// ...
return <div />;
};
export const getServerSideProps: GetServerSideProps = async ({
params,
req,
res,
}) => {
const session = await getSession(req, res);
await session.commit();
await dbConnect();
const id = params?.id as string;
const poll = await getPoll(id);
if (!poll) {
return {
notFound: true,
};
}
if (poll.creator !== session.id) {
// Only allow the poll creator at the route
return {
redirect: {
destination: `/${id}`,
permanent: false,
},
};
}
return {
props: {
poll,
},
};
};
export default EditPage;
Maintenant, dans le composant de page, nous formatons les données du sondage et rendons un formulaire pour modifier le sondage. Ajoutez le code suivant au fichier :
const EditPage: NextPage<{ poll: PollPrimitive }> = ({ poll }) => {
const router = useRouter();
const id = router.query.id;const onSubmit = async (data: any) => {
await axios.put(`/api/polls/${id}`, data);
router.push(`/${id}`);
};
const end = new Date(poll.end);
const time = format(end, "HH:mm");
const date = format(end, "yyyy-MM-dd");
const options = Object.keys(poll.results);
const title = `Votr - Editing "${poll.title}"`;
return (
<div className="w-screen h-screen bg-slate-500 overflow-x-hidden">
<Head>
<title>{title}</title>
</Head>
<main
className="
p-5 flex flex-col
justify-center items-center"
>
<h1
className="
font-bold text-4xl
text-center mb-5 text-gray-200"
>
Edit Poll
</h1>
<PollEditForm
initialData={{
title: poll.title,
options,
privacy: poll.privacy,
time,
date,
}}
onSubmit={onSubmit}
/>
</main>
</div>
);
};
Cette route fonctionne comme la précédente, sauf qu’il n’y a pas de récupération initiale des données. N’oubliez pas non plus que nous soumettons les données à une route d’API différente.
import React from "react";
import type { NextPage } from "next";
import Head from "next/head";
import axios from "axios";
import { useRouter } from "next/router";
import PollEditForm from "components/PollEditForm";const NewPage: NextPage = () => {
const router = useRouter();
const onSubmit = async (data: any) => {
const response = await axios.post("/api/polls", data);
const poll = response.data;
router.push(`/${poll._id}`);
};
return (
<div className="w-screen h-screen bg-slate-500 overflow-x-hidden">
<Head>
<title>Votr - New Poll</title>
</Head>
<main
className="
p-5 flex flex-col
justify-center items-center"
>
<h1
className="
font-bold text-4xl
text-center mb-5 text-gray-200"
>
New Poll
</h1>
<PollEditForm onSubmit={onSubmit} />
</main>
</div>
);
};
export default NewPage;
Nous utilisons le même composant pour afficher un formulaire de création et de modification d’un sondage. La différence est que lors de l’édition, nous initialiserons les champs avec les données existantes.
Créer un nouveau fichier nommé PollEditForm.tsx
dans le components
dossier.
Ce fichier comprend le composant de formulaire principal et des composants distincts pour chaque champ. Nous allons commencer par définir l’exportation principale.
import { differenceInMinutes, format } from "date-fns";
import React, { useEffect, useRef, useState } from "react";
import { PlusSquare, XCircle } from "react-feather";type PollData = {
title: string;
options: string[];
end: Date;
privacy: boolean;
};
interface PollEditFormProps {
onSubmit: (data: PollData) => void;
initialData?: Omit<PollData, "end"> & { time: string; date: string };
}
export default function PollEditForm(props: PollEditFormProps) {
// ...
}
La PollData
représente les valeurs envoyées à la route API lors de la soumission du formulaire.
Maintenant, à l’intérieur du composant, ajoutez le code suivant :
// ...const [title, setTitle] = useState(props.initialData?.title ?? "");
// At least 2 options are needed to have a poll
const [options, setOptions] = useState(
props.initialData?.options ?? ["", ""]
);
const [time, setTime] = useState(props.initialData?.time ?? "");
const [date, setDate] = useState(props.initialData?.date ?? "");
const [privacy, setPrivacy] = useState(props.initialData?.privacy ?? false);
const onSubmit = (event: React.FormEvent) => {
event.preventDefault();
const [hours, minutes] = time.split(":");
const end = new Date(
new Date(date).setHours(parseInt(hours), parseInt(minutes))
);
return props.onSubmit({ title, options, end, privacy });
};
// ...
Tout d’abord, les variables d’état sont créées et remplacées par tout ensemble de données initial dans les props. Suivant dans onSubmit
la date
et time
les variables sont converties en un Date
objet, alors le onSubmit
la fonction prop est appelée.
Ajoutez le code suivant pour renvoyer les composants de champ et un bouton d’envoi. Voici le code :
return (
<form
onSubmit={onSubmit}
className="
bg-gray-200/30
rounded-lg
border-gray-50
border-2 px-6 py-3
text-gray-200 w-96
min-h-[500px] shadow-lg
flex flex-col
items-center space-y-4"
>
<TitleInput title={title} setTitle={setTitle} />
<DateTimeInput
date={date}
setDate={setDate}
time={time}
setTime={setTime}
/>
<OptionsInput options={options} setOptions={setOptions} />
<PrivacyInput privacy={privacy} setPrivacy={setPrivacy} />
<button
type="submit"
className="self-end rounded-2xl p-3 font-bold font-mono1 bg-gray-600 hover:bg-gray-700 focus:ring focus:border-indigo-500"
>
Submit
</button>
</form>
);
A partir de maintenant, ces *Input
les composants rendent les entrées de formulaire contrôlées avec validation.
Dans l’ordre approximatif du plus simple au plus complexe :
Saisie du titre
const TitleInput = ({
title,
setTitle,
}: {
title: string;
setTitle: React.Dispatch<React.SetStateAction<string>>;
}) => (
<div className="flex flex-col w-full">
<label className="font-bold text-sm mb-2" htmlFor="title">
Title
</label>
<input
type="text"
id="title"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
className="
rounded-xl bg-transparent
focus:border-indigo-300 focus:ring
focus:ring-indigo-200 focus:ring-opacity-50"
/>
</div>
);
Entrée de confidentialité
const PrivacyInput = ({
privacy,
setPrivacy,
}: {
privacy: boolean;
setPrivacy: React.Dispatch<React.SetStateAction<boolean>>;
}) => (
<div className="flex flex-row w-full items-center">
<label htmlFor="privacy" className="font-bold text-sm mr-2">
Private
</label>
<input
type="checkbox"
id="privacy"
title="Display poll on listing page"
checked={privacy}
onChange={(event) => setPrivacy(event.target.checked)}
className="focus:ring-0 active:ring-0 focus:border-0 rounded-xl w-8 h-6"
/>
</div>
);
Entrée DateHeure
Attention à la validation dans le useEffect
.
const DateTimeInput = ({
date,
time,
setDate,
setTime,
}: {
date: string;
time: string;
setDate: React.Dispatch<React.SetStateAction<string>>;
setTime: React.Dispatch<React.SetStateAction<string>>;
}) => {
const timeInput = useRef<HTMLInputElement | null>(null);useEffect(() => {
if (!timeInput.current) return;
const now = new Date();
const [hours, minutes] = time.split(":");
const newDateTime = new Date(date).setHours(
parseInt(hours),
parseInt(minutes)
);
if (differenceInMinutes(newDateTime, now) < 5) {
timeInput.current.setCustomValidity(
"Time set must be at least 5 minutes from now"
);
timeInput.current.reportValidity();
} else {
timeInput.current.setCustomValidity("");
}
}, [date, time]);
return (
<div className="flex flex-col w-full">
<label className="font-bold text-sm mb-2">End Date & Time</label>
<input
required
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
min={format(new Date(), "yyyy-MM-dd")}
className="mb-2 bg-white/50 h-12 rounded-xl focus:ring-gray-400 text-gray-800"
/>
<input
required
type="time"
value={time}
onChange={(event) => setTime(event.target.value)}
ref={timeInput}
className="bg-white/50 h-12 rounded-xl focus:ring-gray-400 text-gray-800"
/>
</div>
);
};
Saisie des options
Il existe quatre caractéristiques principales de ce composant :
- Deux fonctions pour changer le tableau d’état, en utilisant
array.splice
- La saisie de texte est rendue pour chaque option afin de mettre à jour l’option
- Un bouton est rendu pour chaque option pour supprimer l’option s’il y en a déjà plus de deux
- Un bouton pour ajouter une nouvelle option, représenté par une chaîne vide
Créer un fichier nommé VotesDisplay.tsx
dans le components
dossier. Ici on utilise chart.js
pour afficher un graphique à barres des résultats :
import React, { useMemo } from "react";
import { ChartData, ChartOptions } from "chart.js";
import { Bar } from "react-chartjs-2";
import { pollAtom } from "lib/store";
import { useAtomValue } from "jotai";const chartOptions: ChartOptions<"bar"> = {
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
},
},
},
};
export default function VotesDisplay() {
const { results } = useAtomValue(pollAtom);
const chartData = useMemo<ChartData<"bar">>(() => {
return {
labels: Object.keys(results),
datasets: [
{
data: Object.values(results).map((voters) => voters.length),
borderWidth: 5,
},
],
};
}, [results]);
return <Bar data={chartData} options={chartOptions} />;
}
Créer un fichier nommé VotesControl.tsx
dans le même dossier. Ajoutez le code suivant pour afficher un bouton pour chaque option. Lorsque l’utilisateur clique sur un bouton, une demande de /api/vote
est envoyé.
import React from "react";
import { useAtomValue } from "jotai";
import { pollAtom } from "lib/store";
import axios from "axios";export default function VotesControl() {
const { _id: id, results } = useAtomValue(pollAtom);
const onVote = (option: string) => {
return axios.post("/api/vote", {
id,
option,
});
};
return (
<div className="mt-3 p-4 w-full grid gap-3 h-full ">
{Object.keys(results).map((name) => (
<button
key={name}
type="button"
onClick={() => onVote(name)}
className={`h-32 bg-sky-400 rounded-lg px-4 py-3 text-white border-gray-200 text-3xl`}
>
{name}
</button>
))}
</div>
);
}
Ce dernier élément (EndResult.tsx
) affiche un badge de récompense en or pour l’option gagnante. Cependant, le scrutin peut toujours se terminer par un match nul.
Donc, nous devons avoir un éventail de toutes les options avec le plus d’électeurs. Par conséquent, nous pouvons afficher différents messages en fonction du nombre de gagnants.
Enfin, ajoutez le code suivant au composant :
import React from "react";
import { useAtomValue } from "jotai";
import { pollAtom } from "lib/store";
import { Award } from "react-feather";const listFormatter = new Intl.ListFormat("en");
export default function EndResult() {
const { results } = useAtomValue(pollAtom);
let winningVotes = 0;
let winners: string[] = [];
for (let [candidate, voters] of Object.entries(results)) {
if (voters.length > winningVotes) {
winningVotes = voters.length;
winners = [candidate];
} else if (voters.length === winningVotes) {
winners.push(candidate);
}
}
const isDraw = winners.length > 1;
const bgColor = isDraw ? "bg-slate-400" : "bg-yellow-400";
let message: string;
if (winners.length === Object.keys(results).length) {
message = "Nobody wins. It's a draw!";
} else if (isDraw) {
message = `It's a tie between ${listFormatter.format(winners)}`;
} else {
message = `Winner is ${winners[0]}`;
}
return (
<div
className={`cursor-pointer w-80 rounded-xl px-6 py-4 border-2 border-gray-100 flex flex-col justify-center items-center ${bgColor} text-white`}
>
<Award className="w-10 h-10 mb-2" />
<h2 className="text-2xl font-bold text-center">{message}</h2>
</div>
);
}
Démarrez le serveur Redis avec sudo redis-server start
puis essayez d’exécuter le projet avec npm run dev
et voir si ça marche !
Si vous êtes arrivé jusqu’ici, je l’apprécie. Aujourd’hui, nous avons appris à utiliser Next.js, Ably et Mongoose pour créer une application de vote.
Voici quelques extras auxquels penser :
- Réactivité mobile
- Afficher le nombre de vues d’un sondage
- Afficher des images dans les sondages
- Ajout d’un vote de gamme (échelle de 1 à 10) ainsi que d’options simples
Pour voir le résultat final, vous pouvez trouver le code complet de cet article ici.
Concepts clés / Docs | Habilement en temps réel
Authentification / Fonctionnalités principales / Docs | Habilement en temps réel